Skip to content

Commit 2f6c770

Browse files
authored
Assign users with no group memberships to automatically to group 'none' (#38)
* Assign users with no group memberships to usergroup: none * Fix conditional on allowed_groups * Change assert since admin gets picked up as an additional user
1 parent 02174fb commit 2f6c770

File tree

4 files changed

+180
-42
lines changed

4 files changed

+180
-42
lines changed

jupyterhub_groups_exporter/app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ def main():
211211
f"Filtering JupyterHub user groups exporter to only include: {args.allowed_groups}"
212212
)
213213
else:
214-
args.allowed_groups = None
214+
args.allowed_groups = []
215215

216216
if args.double_count:
217217
logger.info(

jupyterhub_groups_exporter/groups_exporter.py

Lines changed: 43 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -46,61 +46,64 @@ async def update_user_group_info(
4646
"""
4747
Update the prometheus exporter with user group memberships fetched from the JupyterHub API.
4848
"""
49+
logger.info("This is the update_user_group_info coroutine.")
4950
session = app["session"]
5051
hub_url = app["hub_url"]
5152
allowed_groups = app["allowed_groups"]
5253
double_count = app["double_count"]
5354
namespace = app["namespace"]
54-
data = await fetch_page(session, hub_url, "hub/api/groups")
55-
if "_pagination" in data:
56-
logger.debug(f"Received paginated data: {data['_pagination']}")
57-
items = data["items"]
58-
next_info = data["_pagination"]["next"]
59-
while next_info:
60-
data = await fetch_page(session, next_info["url"])
55+
endpoints = ["hub/api/users", "hub/api/groups"]
56+
results = []
57+
for i in range(len(endpoints)):
58+
endpoint = endpoints[i]
59+
data = await fetch_page(session, hub_url, endpoint)
60+
if "_pagination" in data:
61+
logger.debug(f"Received paginated data: {data['_pagination']}")
62+
items = data["items"]
6163
next_info = data["_pagination"]["next"]
62-
items.extend(data["items"])
63-
else:
64-
logger.debug("Received non-paginated data.")
65-
items = data
66-
67-
logger.debug(f"Items: {items}")
68-
list_groups = (
69-
[group["name"] for group in items] if allowed_groups is None else allowed_groups
70-
)
71-
list_users = (
72-
[
73-
user
74-
for group in items
75-
if group["name"] in allowed_groups
76-
for user in group["users"]
77-
]
78-
if allowed_groups
79-
else [user for group in items for user in group["users"]]
80-
)
64+
while next_info:
65+
data = await fetch_page(session, next_info["url"])
66+
next_info = data["_pagination"]["next"]
67+
items.extend(data["items"])
68+
else:
69+
logger.debug("Received non-paginated data.")
70+
items = data
71+
results.extend(items)
72+
list_groups = []
73+
list_users = []
74+
for r in results:
75+
if r["kind"] == "group" and (
76+
r["name"] in allowed_groups or allowed_groups == []
77+
):
78+
list_groups.append(r["name"])
79+
elif r["kind"] == "user":
80+
for group in r["groups"]:
81+
if group in allowed_groups or allowed_groups == []:
82+
list_users.append(r["name"])
8183
user_counts = Counter(list_users)
8284
users_in_multiple_groups = [
8385
user for user, count in user_counts.items() if count > 1
8486
]
8587
unique_users = list(set(list_users))
8688
logger.debug(f"List groups: {list_groups}")
89+
logger.debug(f"List users: {list_users}")
8790
logger.debug(f"Users in multiple groups: {users_in_multiple_groups}")
8891
logger.info(
8992
f"Updating {len(list_groups)} groups and {len(unique_users)} users for metric user_group_info."
9093
)
91-
# Clear previous prometheus metrics
92-
USER_GROUP.clear()
93-
# Filter out groups not in the allowed list
94-
for group in items.copy():
95-
if group["name"] not in list_groups:
96-
logger.debug(f"Group {group['name']} is not in allowed groups, skipping.")
97-
items.remove(group)
98-
# Invert the mapping from groups -> users to user -> groups
9994
user_to_groups = {}
100-
for group in items:
101-
for user in group["users"]:
102-
user_to_groups.setdefault(user, []).append(group["name"])
103-
# Loop over users
95+
for r in results:
96+
user = r["name"]
97+
if r["kind"] == "user" and user in unique_users:
98+
if r["groups"] != []:
99+
for group in r["groups"]:
100+
user_to_groups.setdefault(user, []).append(group)
101+
elif r["kind"] == "user":
102+
logger.debug(f"User {user} has no groups.")
103+
user_to_groups.setdefault(user, ["none"])
104+
logger.debug(f"User to groups mapping: {user_to_groups}")
105+
# Loop over users to export
106+
USER_GROUP.clear()
104107
for user in list(user_to_groups.keys()):
105108
if user in users_in_multiple_groups:
106109
user_to_groups[user].append("multiple")
@@ -167,9 +170,9 @@ async def update_group_usage(app: web.Application, config: dict):
167170
groups = user_group_map.get(username, [])
168171
if not groups:
169172
r_copy = copy.deepcopy(r)
170-
r_copy["metric"]["usergroup"] = "nogroup"
173+
r_copy["metric"]["usergroup"] = "none"
171174
joined.append(r_copy)
172-
logger.debug(f"User {username} has no groups, assigning to 'nogroup'.")
175+
logger.debug(f"User {username} has no groups, assigning to 'none'.")
173176
else:
174177
for group in groups:
175178
r_copy = copy.deepcopy(r)
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
[
2+
{
3+
"groups": [],
4+
"kind": "user",
5+
"created": "2025-10-14T09:12:06.065600Z",
6+
"admin": true,
7+
"last_activity": "2025-10-22T09:29:35.066034Z",
8+
"name": "admin",
9+
"pending": null,
10+
"server": null,
11+
"roles": ["user", "admin"]
12+
},
13+
{
14+
"groups": ["group-2"],
15+
"kind": "user",
16+
"created": "2025-10-14T09:12:06.068315Z",
17+
"admin": false,
18+
"last_activity": null,
19+
"name": "user-2",
20+
"pending": null,
21+
"server": null,
22+
"roles": ["user"]
23+
},
24+
{
25+
"groups": ["group-4"],
26+
"kind": "user",
27+
"created": "2025-10-14T09:12:06.068664Z",
28+
"admin": false,
29+
"last_activity": null,
30+
"name": "user-4",
31+
"pending": null,
32+
"server": null,
33+
"roles": ["user"]
34+
},
35+
{
36+
"groups": [
37+
"2i2c-org:hub-access-for-2i2c-staff",
38+
"2i2c-community-showcase:access-2i2c-showcase"
39+
],
40+
"kind": "user",
41+
"created": "2025-10-14T09:12:06.068885Z",
42+
"admin": false,
43+
"last_activity": null,
44+
"name": "jnywong",
45+
"pending": null,
46+
"server": null,
47+
"roles": ["user"]
48+
},
49+
{
50+
"groups": ["group-3"],
51+
"kind": "user",
52+
"created": "2025-10-14T09:12:06.069099Z",
53+
"admin": false,
54+
"last_activity": null,
55+
"name": "user-3",
56+
"pending": null,
57+
"server": null,
58+
"roles": ["user"]
59+
},
60+
{
61+
"groups": ["group-1"],
62+
"kind": "user",
63+
"created": "2025-10-14T09:12:06.069308Z",
64+
"admin": false,
65+
"last_activity": null,
66+
"name": "user-1",
67+
"pending": null,
68+
"server": null,
69+
"roles": ["user"]
70+
},
71+
{
72+
"groups": ["group-0", "group-1", "group-2"],
73+
"kind": "user",
74+
"created": "2025-10-14T09:12:06.069487Z",
75+
"admin": false,
76+
"last_activity": null,
77+
"name": "user-0",
78+
"pending": null,
79+
"server": null,
80+
"roles": ["user"]
81+
},
82+
{
83+
"groups": [],
84+
"kind": "user",
85+
"created": "2025-10-22T08:49:36.819722Z",
86+
"admin": false,
87+
"last_activity": null,
88+
"name": "user-no-group",
89+
"pending": null,
90+
"server": null,
91+
"roles": ["user"]
92+
},
93+
{
94+
"name": "group-0",
95+
"users": ["user-0"],
96+
"kind": "group",
97+
"properties": {}
98+
},
99+
{
100+
"name": "group-1",
101+
"users": ["user-1", "user-0"],
102+
"kind": "group",
103+
"properties": {}
104+
},
105+
{
106+
"name": "group-2",
107+
"users": ["user-0", "user-2"],
108+
"kind": "group",
109+
"properties": {}
110+
},
111+
{
112+
"name": "group-3",
113+
"users": ["user-3"],
114+
"kind": "group",
115+
"properties": {}
116+
},
117+
{
118+
"name": "group-4",
119+
"users": ["user-4"],
120+
"kind": "group",
121+
"properties": {}
122+
},
123+
{
124+
"name": "2i2c-org:hub-access-for-2i2c-staff",
125+
"users": ["jnywong"],
126+
"kind": "group",
127+
"properties": {}
128+
},
129+
{
130+
"name": "2i2c-community-showcase:access-2i2c-showcase",
131+
"users": ["jnywong"],
132+
"kind": "group",
133+
"properties": {}
134+
}
135+
]

tests/test_groups_exporter.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,6 @@ async def test_groups_exporter_number(admin_request):
4444
for family in text_string_to_metric_families(response):
4545
if family.name == "jupyterhub_user_group_info":
4646
logger.info(f"{len(family.samples)} groups and users collected.")
47-
assert len(family.samples) == 51 # see tests/jupyterhub_config.py
47+
assert len(family.samples) == 52 # see tests/jupyterhub_config.py
4848
else:
4949
raise aiohttp.ClientError(f"Bad response: {response.status}")

0 commit comments

Comments
 (0)