Skip to content

Commit d5aa1c5

Browse files
committed
Address review: key token cache by repo, run the tests in CI
get_repo_installation() resolves per repo, so an owner-keyed cache is wrong for a 'Selected repositories' install: one repo's token would be handed to a sibling, or one repo's 'not installed' result would park the whole owner on the PAT path for 15 minutes. Key by owner/repo instead. The test file was never collected -- tests.yml only runs aws/lambda/tests, and the deploy job is gated to push-on-main. Add a test job to the lambda's own workflow, matching opensearch-gha-jobs-lambda.yml. pytest goes on the install line rather than requirements.txt, which the Makefile ships in the deployment zip.
1 parent d38cc92 commit d5aa1c5

3 files changed

Lines changed: 51 additions & 17 deletions

File tree

.github/workflows/github-status-test-lambda.yml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
name: Deploy github-status-test
1+
name: Test and deploy github-status-test
22

33
on:
44
push:
@@ -17,7 +17,19 @@ defaults:
1717
working-directory: aws/lambda/github-status-test/
1818

1919
jobs:
20+
test:
21+
runs-on: ubuntu-latest
22+
steps:
23+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
24+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
25+
with:
26+
python-version: '3.11'
27+
cache: pip
28+
- run: pip3 install -r requirements.txt pytest
29+
- run: pytest -v test_lambda_function.py
30+
2031
deploy:
32+
needs: test
2133
runs-on: ubuntu-latest
2234
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
2335
permissions:

aws/lambda/github-status-test/lambda_function.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
# Installation tokens last an hour. Refresh early so a warm invocation never
2929
# signs a request with a token that expires mid-flight.
3030
TOKEN_EXPIRY_MARGIN = 300
31-
# How long to remember that an owner has no app installation, so repos outside
31+
# How long to remember that a repo has no app installation, so repos outside
3232
# the installation don't trigger a lookup for every job.
3333
NO_INSTALLATION_TTL = 900
3434
# Used when a credential is rejected without telling us when it recovers.
@@ -37,7 +37,11 @@
3737
# (403 or 429) or rejected outright (401).
3838
FALLBACK_STATUSES = (401, 403, 429)
3939

40-
# owner -> (installation token or None, epoch seconds the entry goes stale)
40+
# Keyed by "owner/repo", not owner: get_repo_installation() resolves per repo,
41+
# so a "Selected repositories" install can cover one repo of an owner and not
42+
# its sibling. Sharing an owner's entry would hand a repo a token minted for a
43+
# different one, or let one repo's "not installed" result mask another's.
44+
# full_name -> (installation token or None, epoch seconds the entry goes stale)
4145
_token_cache = {}
4246

4347

@@ -63,7 +67,7 @@ def cool_off_until(response):
6367
def fetch_installation_token(full_name):
6468
"""Mint an installation token for the app installation covering full_name.
6569
66-
Returns (None, expiry) when the app isn't installed on that owner, so the
70+
Returns (None, expiry) when the app isn't installed on that repo, so the
6771
caller falls back to a PAT instead of retrying the lookup for every job.
6872
"""
6973
owner, repo = full_name.split("/", 1)
@@ -81,12 +85,11 @@ def fetch_installation_token(full_name):
8185

8286

8387
def installation_token(full_name):
84-
"""Cached installation token for full_name's owner, or None if unavailable."""
88+
"""Cached installation token for full_name, or None if unavailable."""
8589
if not GITHUB_APP_ID or not GITHUB_APP_PRIVATE_KEY:
8690
return None
8791

88-
owner = full_name.split("/")[0]
89-
cached = _token_cache.get(owner)
92+
cached = _token_cache.get(full_name)
9093
if cached and time.time() < cached[1]:
9194
return cached[0]
9295

@@ -97,10 +100,10 @@ def installation_token(full_name):
97100
# GitHub blip must degrade to the PAT pool, never fail the webhook and
98101
# lose the payload archiving that happens after the log download.
99102
# Not cached either, so the next invocation retries.
100-
print(f"ERROR minting installation token for {owner}: {err}")
103+
print(f"ERROR minting installation token for {full_name}: {err}")
101104
return None
102105

103-
_token_cache[owner] = (token, expires_at)
106+
_token_cache[full_name] = (token, expires_at)
104107
return token
105108

106109

@@ -122,8 +125,7 @@ def download_log(full_name, conclusion, job_id):
122125
if response.status_code in FALLBACK_STATUSES:
123126
# Stop using the app until its window resets, otherwise every job
124127
# for the rest of the hour pays for a doomed request first.
125-
owner = full_name.split("/")[0]
126-
_token_cache[owner] = (None, cool_off_until(response))
128+
_token_cache[full_name] = (None, cool_off_until(response))
127129
print(
128130
f"App auth returned {response.status_code} for {full_name} "
129131
f"job {job_id}, falling back to a PAT"

aws/lambda/github-status-test/test_lambda_function.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,21 +27,41 @@ def test_returns_none_without_app_credentials(self):
2727

2828
@patch.object(lambda_function, "GITHUB_APP_ID", "4550824")
2929
@patch.object(lambda_function, "GITHUB_APP_PRIVATE_KEY", "key")
30-
def test_token_is_cached_per_owner(self):
30+
def test_token_is_cached_per_repo(self):
3131
with patch.object(
3232
lambda_function,
3333
"fetch_installation_token",
3434
return_value=("tok", 2**31),
3535
) as fetch:
3636
self.assertEqual(installation_token("pytorch/pytorch"), "tok")
37-
# Same owner, different repo: served from cache, no second mint
38-
self.assertEqual(installation_token("pytorch/executorch"), "tok")
37+
self.assertEqual(installation_token("pytorch/pytorch"), "tok")
38+
# Same repo twice is one mint...
3939
fetch.assert_called_once()
40+
# ...but a sibling repo resolves separately, because a "Selected
41+
# repositories" install can cover one and not the other.
42+
self.assertEqual(installation_token("pytorch/executorch"), "tok")
43+
self.assertEqual(fetch.call_count, 2)
44+
45+
@patch.object(lambda_function, "GITHUB_APP_ID", "4550824")
46+
@patch.object(lambda_function, "GITHUB_APP_PRIVATE_KEY", "key")
47+
def test_uninstalled_sibling_does_not_mask_an_installed_repo(self):
48+
# Under a "Selected repositories" install, querying the uninstalled
49+
# sibling first must not park the whole owner on the PAT path.
50+
def per_repo(full_name):
51+
if full_name == "pytorch/not-installed":
52+
return None, 2**31
53+
return "tok", 2**31
54+
55+
with patch.object(
56+
lambda_function, "fetch_installation_token", side_effect=per_repo
57+
):
58+
self.assertIsNone(installation_token("pytorch/not-installed"))
59+
self.assertEqual(installation_token("pytorch/pytorch"), "tok")
4060

4161
@patch.object(lambda_function, "GITHUB_APP_ID", "4550824")
4262
@patch.object(lambda_function, "GITHUB_APP_PRIVATE_KEY", "key")
4363
def test_expired_cache_entry_is_refreshed(self):
44-
lambda_function._token_cache["pytorch"] = ("stale", 0)
64+
lambda_function._token_cache["pytorch/pytorch"] = ("stale", 0)
4565
with patch.object(
4666
lambda_function,
4767
"fetch_installation_token",
@@ -70,7 +90,7 @@ def test_mint_failure_is_not_cached(self):
7090
side_effect=lambda_function.requests.RequestException("boom"),
7191
):
7292
self.assertIsNone(installation_token("pytorch/pytorch"))
73-
self.assertNotIn("pytorch", lambda_function._token_cache)
93+
self.assertNotIn("pytorch/pytorch", lambda_function._token_cache)
7494

7595
@patch.object(lambda_function, "GITHUB_APP_ID", "4550824")
7696
@patch.object(lambda_function, "GITHUB_APP_PRIVATE_KEY", "bm90LWEta2V5")
@@ -136,7 +156,7 @@ def test_rate_limited_app_is_skipped_until_reset(self, urlopen, s3):
136156
# 2 calls for the first job (app then PAT), 1 for the second (PAT only)
137157
self.assertEqual(fetch_log.call_count, 3)
138158
self.assertEqual(
139-
lambda_function._token_cache["pytorch"], (None, float(reset_at))
159+
lambda_function._token_cache["pytorch/pytorch"], (None, float(reset_at))
140160
)
141161

142162
def test_error_response_is_not_archived(self, urlopen, s3):

0 commit comments

Comments
 (0)