Skip to content

Commit 3269f60

Browse files
committed
fix(hub): tolerate missing GitHub teams during sync
1 parent 7745a59 commit 3269f60

2 files changed

Lines changed: 237 additions & 8 deletions

File tree

runtime/hub/core/groups.py

Lines changed: 117 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@
4848
_GITHUB_TEAM_SYNC_LOCKS: dict[str, asyncio.Lock] = {}
4949
_GITHUB_TEAM_MEMBERS_CACHE: dict[tuple[str, str, str, tuple[str, ...]], tuple[float, dict[str, list[str]]]] = {}
5050
_GITHUB_TEAM_MEMBERS_LOCK = asyncio.Lock()
51+
_GITHUB_APP_INSTALLATION_ID_CACHE: dict[tuple[str, str], str] = {}
52+
_GITHUB_APP_INSTALLATION_ID_LOCK = asyncio.Lock()
5153
_GITHUB_APP_INSTALLATION_TOKEN: dict[tuple[str, str], tuple[str, float]] = {}
5254
_GITHUB_APP_INSTALLATION_TOKEN_LOCK = asyncio.Lock()
5355

@@ -72,16 +74,32 @@ def _github_app_private_key(private_key: str = "", private_key_file: str = "") -
7274
return private_key.replace("\\n", "\n").strip()
7375

7476

77+
def _github_team_api_slug(team_key: str) -> str:
78+
"""Convert a configured group/team key to the GitHub team slug API form."""
79+
return team_key.strip().lower().replace(" ", "-")
80+
81+
7582
async def get_github_app_installation_token(
7683
app_id: str,
77-
installation_id: str,
84+
installation_id: str = "",
85+
org_name: str = "",
7886
*,
7987
private_key: str = "",
8088
private_key_file: str = "",
8189
) -> str | None:
8290
"""Create or reuse a GitHub App installation access token for group sync."""
91+
resolved_installation_id = await _resolve_github_app_installation_id(
92+
app_id,
93+
installation_id,
94+
org_name,
95+
private_key=private_key,
96+
private_key_file=private_key_file,
97+
)
98+
if not resolved_installation_id:
99+
return None
100+
83101
now = time.time()
84-
cache_key = (app_id, installation_id)
102+
cache_key = (app_id, resolved_installation_id)
85103
cached_token = _GITHUB_APP_INSTALLATION_TOKEN.get(cache_key)
86104
if cached_token and now < cached_token[1] - 300:
87105
return cached_token[0]
@@ -93,7 +111,7 @@ async def get_github_app_installation_token(
93111
return cached_token[0]
94112

95113
private_key = _github_app_private_key(private_key=private_key, private_key_file=private_key_file)
96-
if not app_id or not installation_id or not private_key:
114+
if not app_id or not resolved_installation_id or not private_key:
97115
log.warning(
98116
"GitHub App installation token is unavailable because app id, installation id, or private key is missing"
99117
)
@@ -114,7 +132,7 @@ async def get_github_app_installation_token(
114132
async with (
115133
aiohttp.ClientSession() as session,
116134
session.post(
117-
f"https://api.github.com/app/installations/{installation_id}/access_tokens",
135+
f"https://api.github.com/app/installations/{resolved_installation_id}/access_tokens",
118136
headers=headers,
119137
) as resp,
120138
):
@@ -143,6 +161,86 @@ async def get_github_app_installation_token(
143161
return token
144162

145163

164+
async def _resolve_github_app_installation_id(
165+
app_id: str,
166+
installation_id: str,
167+
org_name: str,
168+
*,
169+
private_key: str = "",
170+
private_key_file: str = "",
171+
) -> str | None:
172+
installation_id = installation_id.strip()
173+
if installation_id:
174+
return installation_id
175+
176+
org_name = org_name.strip()
177+
if not app_id or not org_name:
178+
log.warning("GitHub App installation ID lookup is unavailable because app id or org name is missing")
179+
return None
180+
181+
cache_key = (app_id, org_name)
182+
cached_installation_id = _GITHUB_APP_INSTALLATION_ID_CACHE.get(cache_key)
183+
if cached_installation_id:
184+
return cached_installation_id
185+
186+
async with _GITHUB_APP_INSTALLATION_ID_LOCK:
187+
cached_installation_id = _GITHUB_APP_INSTALLATION_ID_CACHE.get(cache_key)
188+
if cached_installation_id:
189+
return cached_installation_id
190+
191+
private_key = _github_app_private_key(private_key=private_key, private_key_file=private_key_file)
192+
if not private_key:
193+
log.warning("GitHub App installation ID lookup is unavailable because the private key is missing")
194+
return None
195+
196+
now = time.time()
197+
app_jwt = jwt.encode(
198+
{"iat": int(now) - 60, "exp": int(now) + 540, "iss": app_id},
199+
private_key,
200+
algorithm="RS256",
201+
)
202+
headers = {
203+
"Authorization": f"Bearer {app_jwt}",
204+
"Accept": "application/vnd.github+json",
205+
"X-GitHub-Api-Version": "2022-11-28",
206+
}
207+
208+
try:
209+
async with (
210+
aiohttp.ClientSession() as session,
211+
session.get(
212+
f"https://api.github.com/orgs/{org_name}/installation",
213+
headers=headers,
214+
) as resp,
215+
):
216+
if resp.status == 404:
217+
log.warning("GitHub App installation lookup returned 404 for org %s", org_name)
218+
return None
219+
if resp.status != 200:
220+
log.warning(
221+
"GitHub App installation lookup returned status %d for org %s",
222+
resp.status,
223+
org_name,
224+
)
225+
return None
226+
data = await resp.json()
227+
except Exception as e:
228+
log.warning("Error resolving GitHub App installation for org %s: %s", org_name, e)
229+
return None
230+
231+
resolved_installation_id = data.get("id")
232+
if resolved_installation_id is None:
233+
log.warning(
234+
"GitHub App installation lookup for org %s did not include an installation id",
235+
org_name,
236+
)
237+
return None
238+
239+
resolved_installation_id = str(resolved_installation_id)
240+
_GITHUB_APP_INSTALLATION_ID_CACHE[cache_key] = resolved_installation_id
241+
return resolved_installation_id
242+
243+
146244
async def fetch_github_team_members(access_token: str, org_name: str, team_slug: str) -> set[str] | None:
147245
"""Fetch all GitHub usernames in one team using the configured platform token."""
148246
if not access_token or not org_name or not team_slug:
@@ -160,9 +258,19 @@ async def fetch_github_team_members(access_token: str, org_name: str, team_slug:
160258
while True:
161259
url = f"https://api.github.com/orgs/{org_name}/teams/{team_slug}/members?per_page=100&page={page}"
162260
async with session.get(url, headers=headers) as resp:
261+
if resp.status == 404:
262+
log.warning(
263+
"GitHub API returned status 404 when fetching members for team %s in org %s",
264+
team_slug,
265+
org_name,
266+
)
267+
return set()
163268
if resp.status != 200:
164269
log.warning(
165-
"GitHub API returned status %d when fetching members for team %s", resp.status, team_slug
270+
"GitHub API returned status %d when fetching members for team %s in org %s",
271+
resp.status,
272+
team_slug,
273+
org_name,
166274
)
167275
return None
168276
data = await resp.json()
@@ -214,19 +322,20 @@ async def fetch_github_team_members_table(
214322
installation_token = await get_github_app_installation_token(
215323
app_id,
216324
installation_id,
325+
org_name=org_name,
217326
private_key=private_key,
218327
private_key_file=private_key_file,
219328
)
220329
if not installation_token:
221330
return None
222331

223332
teams_by_login: dict[str, list[str]] = {}
224-
for team_slug in github_team_keys:
225-
members = await fetch_github_team_members(installation_token, org_name, team_slug)
333+
for team_key in github_team_keys:
334+
members = await fetch_github_team_members(installation_token, org_name, _github_team_api_slug(team_key))
226335
if members is None:
227336
return None
228337
for login in members:
229-
teams_by_login.setdefault(login, []).append(team_slug)
338+
teams_by_login.setdefault(login, []).append(team_key)
230339

231340
_GITHUB_TEAM_MEMBERS_CACHE[cache_key] = (now, teams_by_login)
232341
return {login: list(teams) for login, teams in teams_by_login.items()}

runtime/hub/tests/test_groups.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
1818
# SOFTWARE.
1919

20+
import asyncio
2021
import importlib.util
2122
import sys
2223
import types
@@ -62,6 +63,9 @@ def load_module(name: str, path: Path):
6263

6364
groups = load_module("core.groups", CORE / "groups.py")
6465
resolve_resources_for_user = groups.resolve_resources_for_user
66+
fetch_github_team_members = groups.fetch_github_team_members
67+
get_github_app_installation_token = groups.get_github_app_installation_token
68+
fetch_github_team_members_table = groups.fetch_github_team_members_table
6569
sync_user_github_teams = groups.sync_user_github_teams
6670

6771

@@ -101,6 +105,61 @@ def commit(self):
101105
pass
102106

103107

108+
class DummyResponse:
109+
def __init__(self, status, payload):
110+
self.status = status
111+
self._payload = payload
112+
113+
async def __aenter__(self):
114+
return self
115+
116+
async def __aexit__(self, exc_type, exc, tb):
117+
return False
118+
119+
async def json(self):
120+
return self._payload
121+
122+
123+
class DummyClientSession:
124+
created = 0
125+
get_calls = 0
126+
post_calls = 0
127+
128+
def __init__(self):
129+
type(self).created += 1
130+
131+
async def __aenter__(self):
132+
return self
133+
134+
async def __aexit__(self, exc_type, exc, tb):
135+
return False
136+
137+
def get(self, url, headers=None):
138+
type(self).get_calls += 1
139+
if url.endswith("/orgs/test-org/installation"):
140+
return DummyResponse(200, {"id": 12345})
141+
if url.endswith("/orgs/test-org/teams/missing-team/members?per_page=100&page=1"):
142+
return DummyResponse(404, {})
143+
if url.endswith("/orgs/test-org/teams/aup/members?per_page=100&page=1"):
144+
return DummyResponse(200, [{"login": "OctoUser"}])
145+
return DummyResponse(200, {"repositories": []})
146+
147+
def post(self, url, headers=None):
148+
type(self).post_calls += 1
149+
if url.endswith("/app/installations/12345/access_tokens"):
150+
return DummyResponse(201, {"token": "cached-token", "expires_at": "2099-01-01T00:00:00Z"})
151+
return DummyResponse(500, {})
152+
153+
154+
def _reset_dummy_client_session():
155+
DummyClientSession.created = 0
156+
DummyClientSession.get_calls = 0
157+
DummyClientSession.post_calls = 0
158+
groups._GITHUB_APP_INSTALLATION_ID_CACHE.clear()
159+
groups._GITHUB_APP_INSTALLATION_TOKEN.clear()
160+
groups._GITHUB_TEAM_MEMBERS_CACHE.clear()
161+
162+
104163
def test_sync_user_github_teams_skips_removals_when_team_fetch_failed():
105164
existing_group = DummyGroup("team-a")
106165
user = DummyUser([existing_group])
@@ -110,6 +169,67 @@ def test_sync_user_github_teams_skips_removals_when_team_fetch_failed():
110169
assert user.orm_user.groups == [existing_group]
111170

112171

172+
def test_fetch_github_team_members_treats_missing_team_as_empty_set(monkeypatch, caplog):
173+
_reset_dummy_client_session()
174+
monkeypatch.setattr(groups.aiohttp, "ClientSession", DummyClientSession)
175+
176+
caplog.set_level("WARNING", logger="jupyterhub.groups")
177+
members = asyncio.run(fetch_github_team_members("token", "test-org", "missing-team"))
178+
179+
assert members == set()
180+
assert "team missing-team" in caplog.text
181+
assert "test-org" in caplog.text
182+
183+
184+
def test_get_github_app_installation_token_discovers_installation_id(monkeypatch):
185+
_reset_dummy_client_session()
186+
monkeypatch.setattr(groups.aiohttp, "ClientSession", DummyClientSession)
187+
monkeypatch.setattr(groups.jwt, "encode", lambda payload, private_key, algorithm: "jwt-token")
188+
189+
token = asyncio.run(
190+
get_github_app_installation_token(
191+
"app-123",
192+
"",
193+
org_name="test-org",
194+
private_key="dummy-private-key",
195+
)
196+
)
197+
cached_token = asyncio.run(
198+
get_github_app_installation_token(
199+
"app-123",
200+
"",
201+
org_name="test-org",
202+
private_key="dummy-private-key",
203+
)
204+
)
205+
206+
assert token == "cached-token"
207+
assert cached_token == "cached-token"
208+
assert DummyClientSession.created == 2
209+
assert DummyClientSession.get_calls == 1
210+
assert DummyClientSession.post_calls == 1
211+
212+
213+
def test_fetch_github_team_members_table_uses_api_slug_and_preserves_group_key(monkeypatch):
214+
_reset_dummy_client_session()
215+
monkeypatch.setattr(groups.aiohttp, "ClientSession", DummyClientSession)
216+
monkeypatch.setattr(groups.jwt, "encode", lambda payload, private_key, algorithm: "jwt-token")
217+
218+
teams_by_login = asyncio.run(
219+
fetch_github_team_members_table(
220+
"app-123",
221+
"",
222+
"dummy-private-key",
223+
"",
224+
"test-org",
225+
{"AUP"},
226+
force=True,
227+
)
228+
)
229+
230+
assert teams_by_login == {"octouser": ["AUP"]}
231+
232+
113233
def test_resolve_resources_for_user_uses_group_mapping():
114234
user = DummyUser([DummyGroup("team-a"), DummyGroup("team-b")])
115235

0 commit comments

Comments
 (0)