Skip to content

Commit dd331e7

Browse files
committed
github-status-test: use a GitHub App for log downloads, with PAT fallback
Job logs are currently downloaded with a randomly chosen PAT from the GITHUB_TOKENS pool. PATs are tied to a personal account, need `repo` scope to reach private repos, and rotate whenever their owner does. Download logs with a GitHub App installation token instead, falling back to the existing PAT pool when the app is rate limited, rejected, or not installed on the repo's owner. Both app env vars unset means the previous behaviour exactly, so the app can be rolled back by clearing them. - GITHUB_APP_ID / GITHUB_APP_PRIVATE_KEY (base64 PEM, raw PEM also accepted) - Tokens are minted with PyGithub's GithubIntegration, matching cross_repo_ci_relay, rather than hand-rolling the app JWT - Installation tokens are cached per repo owner in module scope and refreshed 5 minutes early, so warm invocations don't mint one per job - Fallback triggers on 401/403/429. On a rate limit the app is parked until x-ratelimit-reset so later jobs skip it rather than each paying for a doomed request first - Repos outside the installation (e.g. vllm-project/vllm) resolve to no installation and go straight to the pool, cached briefly to avoid a lookup per job Two existing bugs fixed, both needed for the fallback to work: - Non-OK responses were archived as though they were logs, so a rate-limited 403 body would land in S3 as the job's log and be sent to the classifier. Detecting that condition is also what fallback depends on. - `except HTTPError` caught urllib's, never requests'. It now catches requests.RequestException too. The lambda runs on python3.9 but the deploy workflow's setup-python pins no version, so `pip install` used the runner's default. cryptography (via PyGithub) ships compiled wheels, so the Makefile now pins the target platform and python version; otherwise the zip gets wheels the runtime can't import.
1 parent 7732fe4 commit dd331e7

5 files changed

Lines changed: 336 additions & 7 deletions

File tree

aws/lambda/github-status-test/Makefile

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1+
# The lambda runs on python3.9/x86_64. cryptography ships compiled wheels, so
2+
# pin the target platform rather than inheriting whatever python the CI runner
3+
# happens to default to -- otherwise the zip gets wheels the runtime can't load.
14
prepare:
25
mkdir -p ./packages
3-
pip install --target ./packages -r requirements.txt
6+
pip install --target ./packages \
7+
--platform manylinux2014_x86_64 --python-version 3.9 \
8+
--implementation cp --only-binary=:all: --no-compile \
9+
-r requirements.txt
410
cd packages && zip -r ../github-status-test-deployment.zip .
511
zip -g github-status-test-deployment.zip lambda_function.py
612

aws/lambda/github-status-test/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,33 @@
11
Despite the name, this is the lambda used to write GitHub webhook payloads to S3 as mentioned
22
in https://github.com/pytorch/test-infra/blob/main/torchci/docs/architecture.md
33

4+
### GitHub credentials
5+
6+
Job logs are downloaded with a GitHub App installation token, falling back to the `GITHUB_TOKENS`
7+
PAT pool when the app is rate limited, rejected, or not installed on the repo's owner.
8+
9+
| Env var | Required | Purpose |
10+
| --- | --- | --- |
11+
| `GITHUB_APP_ID` | no | App id used to mint installation tokens (e.g. `4550824`, `pytorch-bot-preview`) |
12+
| `GITHUB_APP_PRIVATE_KEY` | no | The app's private key, base64-encoded PEM (same encoding torchci uses) |
13+
| `GITHUB_TOKENS` | yes | Comma-separated PAT pool, used as the fallback and when no app is configured |
14+
15+
With both app vars unset the lambda behaves exactly as before and only uses `GITHUB_TOKENS`, so
16+
the app can be rolled back by clearing the env vars — no code change or redeploy needed.
17+
18+
Notes on the app path:
19+
20+
- Installation tokens last an hour and are cached per repo owner in module scope, so a warm
21+
invocation reuses one rather than minting a token per job.
22+
- The app's rate limit is per installation. `pytorch` is enterprise-owned, so its installation
23+
gets 15,000 requests/hour, independent of any other app's quota. Use a dedicated app rather
24+
than the shared `pytorch-bot` installation, whose quota Dr. CI and the HUD already draw on.
25+
- Repos outside the installation (e.g. `vllm-project/vllm`) resolve to no installation and go
26+
straight to the PAT pool; that negative result is cached briefly to avoid a lookup per job.
27+
- Downloading job logs is documented as needing the `actions: read` permission. It currently
28+
works without it because pytorch repos are public, but the permission should be granted so the
29+
dependency is explicit and private repos keep working.
30+
431
### Deployment
532

633
A new version of the lambda can be deployed using `make deploy` and it will be done so automatically by the workflow

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

Lines changed: 122 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,150 @@
11
# Copyright (c) 2019-present, Facebook, Inc.
22

3+
import base64
34
import gzip
45
import json
56
import os
67
import random
8+
import time
79
from urllib.error import HTTPError
810
from urllib.request import urlopen
911
from uuid import uuid4
1012

1113
import boto3
1214
import requests
15+
from github import Auth, GithubIntegration
16+
from github.GithubException import UnknownObjectException
1317

1418

1519
s3 = boto3.resource("s3")
1620
GITHUB_TOKENS = os.environ.get("GITHUB_TOKENS")
21+
GITHUB_APP_ID = os.environ.get("GITHUB_APP_ID")
22+
# Base64-encoded PEM, the same encoding torchci uses for its app key
23+
GITHUB_APP_PRIVATE_KEY = os.environ.get("GITHUB_APP_PRIVATE_KEY")
1724
BUCKET_NAME = "ossci-raw-job-status"
1825

26+
GITHUB_API_URL = "https://api.github.com"
27+
# Installation tokens last an hour. Refresh early so a warm invocation never
28+
# signs a request with a token that expires mid-flight.
29+
TOKEN_EXPIRY_MARGIN = 300
30+
# How long to remember that an owner has no app installation, so repos outside
31+
# the installation don't trigger a lookup for every job.
32+
NO_INSTALLATION_TTL = 900
33+
# Used when a credential is rejected without telling us when it recovers.
34+
DEFAULT_COOL_OFF = 60
35+
# Statuses meaning "this credential can't do it, try the next one": rate limited
36+
# (403 or 429) or rejected outright (401).
37+
FALLBACK_STATUSES = (401, 403, 429)
38+
39+
# owner -> (installation token or None, epoch seconds the entry goes stale)
40+
_token_cache = {}
41+
1942

2043
def json_dumps(obj):
2144
return json.dumps(obj, sort_keys=True, indent=4, separators=(",", ": "))
2245

2346

24-
def download_log(full_name, conclusion, job_id):
25-
url = f"https://api.github.com/repos/{full_name}/actions/jobs/{job_id}/logs"
47+
def app_private_key():
48+
key = GITHUB_APP_PRIVATE_KEY
49+
if "PRIVATE KEY" not in key:
50+
key = base64.b64decode(key).decode("utf-8")
51+
return key
52+
53+
54+
def cool_off_until(response):
55+
reset = response.headers.get("x-ratelimit-reset")
56+
if reset:
57+
try:
58+
return float(reset)
59+
except ValueError:
60+
pass
61+
return time.time() + DEFAULT_COOL_OFF
62+
63+
64+
def fetch_installation_token(full_name):
65+
"""Mint an installation token for the app installation covering full_name.
66+
67+
Returns (None, expiry) when the app isn't installed on that owner, so the
68+
caller falls back to a PAT instead of retrying the lookup for every job.
69+
"""
70+
owner, repo = full_name.split("/", 1)
71+
integration = GithubIntegration(
72+
auth=Auth.AppAuth(int(GITHUB_APP_ID), app_private_key())
73+
)
74+
75+
try:
76+
installation = integration.get_repo_installation(owner, repo)
77+
except UnknownObjectException:
78+
return None, time.time() + NO_INSTALLATION_TTL
79+
80+
token = integration.get_access_token(installation.id)
81+
return token.token, token.expires_at.timestamp() - TOKEN_EXPIRY_MARGIN
82+
83+
84+
def installation_token(full_name):
85+
"""Cached installation token for full_name's owner, or None if unavailable."""
86+
if not GITHUB_APP_ID or not GITHUB_APP_PRIVATE_KEY:
87+
return None
88+
89+
owner = full_name.split("/")[0]
90+
cached = _token_cache.get(owner)
91+
if cached and time.time() < cached[1]:
92+
return cached[0]
93+
94+
try:
95+
token, expires_at = fetch_installation_token(full_name)
96+
except Exception as err:
97+
# Deliberately broad: a bad app id, an unparseable private key or a
98+
# GitHub blip must degrade to the PAT pool, never fail the webhook and
99+
# lose the payload archiving that happens after the log download.
100+
# Not cached either, so the next invocation retries.
101+
print(f"ERROR minting installation token for {owner}: {err}")
102+
return None
103+
104+
_token_cache[owner] = (token, expires_at)
105+
return token
106+
107+
108+
def fetch_log(full_name, job_id, token):
109+
url = f"{GITHUB_API_URL}/repos/{full_name}/actions/jobs/{job_id}/logs"
26110
headers = {
27111
"Accept": "application/vnd.github.v3+json",
28-
"Authorization": "token " + random.choice(GITHUB_TOKENS.split(",")),
112+
"Authorization": "token " + token,
29113
}
30-
r = requests.get(url, headers=headers)
31-
log_data = r.content
114+
return requests.get(url, headers=headers, timeout=30)
115+
116+
117+
def download_log(full_name, conclusion, job_id):
118+
response = None
119+
120+
app_token = installation_token(full_name)
121+
if app_token:
122+
response = fetch_log(full_name, job_id, app_token)
123+
if response.status_code in FALLBACK_STATUSES:
124+
# Stop using the app until its window resets, otherwise every job
125+
# for the rest of the hour pays for a doomed request first.
126+
owner = full_name.split("/")[0]
127+
_token_cache[owner] = (None, cool_off_until(response))
128+
print(
129+
f"App auth returned {response.status_code} for {full_name} "
130+
f"job {job_id}, falling back to a PAT"
131+
)
132+
response = None
133+
134+
if response is None:
135+
if not GITHUB_TOKENS:
136+
print(f"ERROR no usable credential for {full_name} job {job_id}")
137+
return
138+
response = fetch_log(full_name, job_id, random.choice(GITHUB_TOKENS.split(",")))
139+
140+
if not response.ok:
141+
# Bail out rather than archive the API error body as if it were the log
142+
print(
143+
f"ERROR {response.status_code} downloading log for {full_name} job {job_id}"
144+
)
145+
return
146+
147+
log_data = response.content
32148

33149
object_path = f"log/{job_id}"
34150
if full_name != "pytorch/pytorch":
@@ -61,7 +177,7 @@ def lambda_handler(event, context):
61177
conclusion = body[event_type]["conclusion"]
62178
job_id = body[event_type]["id"]
63179
download_log(full_name, conclusion, job_id)
64-
except HTTPError as err:
180+
except (HTTPError, requests.RequestException) as err:
65181
# Just eat the error as logs are optional.
66182
print("ERROR", err)
67183
pass
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
boto3==1.24.59
22
requests==2.32.2
3+
# Mints the app installation token. Same version cross_repo_ci_relay uses.
4+
PyGithub==2.9.0
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import unittest
2+
from unittest.mock import MagicMock, patch
3+
4+
import lambda_function
5+
from lambda_function import download_log, installation_token
6+
7+
8+
def make_response(status_code, content=b"log data", headers=None):
9+
response = MagicMock()
10+
response.status_code = status_code
11+
response.ok = 200 <= status_code < 300
12+
response.content = content
13+
response.headers = headers or {}
14+
return response
15+
16+
17+
class TestInstallationToken(unittest.TestCase):
18+
def setUp(self):
19+
lambda_function._token_cache.clear()
20+
21+
@patch.object(lambda_function, "GITHUB_APP_ID", None)
22+
@patch.object(lambda_function, "GITHUB_APP_PRIVATE_KEY", None)
23+
def test_returns_none_without_app_credentials(self):
24+
with patch.object(lambda_function, "fetch_installation_token") as fetch:
25+
self.assertIsNone(installation_token("pytorch/pytorch"))
26+
fetch.assert_not_called()
27+
28+
@patch.object(lambda_function, "GITHUB_APP_ID", "4550824")
29+
@patch.object(lambda_function, "GITHUB_APP_PRIVATE_KEY", "key")
30+
def test_token_is_cached_per_owner(self):
31+
with patch.object(
32+
lambda_function,
33+
"fetch_installation_token",
34+
return_value=("tok", 2**31),
35+
) as fetch:
36+
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")
39+
fetch.assert_called_once()
40+
41+
@patch.object(lambda_function, "GITHUB_APP_ID", "4550824")
42+
@patch.object(lambda_function, "GITHUB_APP_PRIVATE_KEY", "key")
43+
def test_expired_cache_entry_is_refreshed(self):
44+
lambda_function._token_cache["pytorch"] = ("stale", 0)
45+
with patch.object(
46+
lambda_function,
47+
"fetch_installation_token",
48+
return_value=("fresh", 2**31),
49+
):
50+
self.assertEqual(installation_token("pytorch/pytorch"), "fresh")
51+
52+
@patch.object(lambda_function, "GITHUB_APP_ID", "4550824")
53+
@patch.object(lambda_function, "GITHUB_APP_PRIVATE_KEY", "key")
54+
def test_uninstalled_owner_is_cached_as_none(self):
55+
with patch.object(
56+
lambda_function,
57+
"fetch_installation_token",
58+
return_value=(None, 2**31),
59+
) as fetch:
60+
self.assertIsNone(installation_token("vllm-project/vllm"))
61+
self.assertIsNone(installation_token("vllm-project/vllm"))
62+
fetch.assert_called_once()
63+
64+
@patch.object(lambda_function, "GITHUB_APP_ID", "4550824")
65+
@patch.object(lambda_function, "GITHUB_APP_PRIVATE_KEY", "key")
66+
def test_mint_failure_is_not_cached(self):
67+
with patch.object(
68+
lambda_function,
69+
"fetch_installation_token",
70+
side_effect=lambda_function.requests.RequestException("boom"),
71+
):
72+
self.assertIsNone(installation_token("pytorch/pytorch"))
73+
self.assertNotIn("pytorch", lambda_function._token_cache)
74+
75+
@patch.object(lambda_function, "GITHUB_APP_ID", "4550824")
76+
@patch.object(lambda_function, "GITHUB_APP_PRIVATE_KEY", "bm90LWEta2V5")
77+
def test_unparseable_private_key_degrades_to_none(self):
78+
# A misconfigured key must not escape as an exception: the webhook still
79+
# has to archive its payload after the log download is skipped.
80+
self.assertIsNone(installation_token("pytorch/pytorch"))
81+
82+
83+
@patch.object(lambda_function, "GITHUB_TOKENS", "pat1,pat2")
84+
@patch.object(lambda_function, "s3")
85+
@patch.object(lambda_function, "urlopen")
86+
class TestDownloadLog(unittest.TestCase):
87+
def setUp(self):
88+
lambda_function._token_cache.clear()
89+
90+
def test_uses_app_token_when_available(self, urlopen, s3):
91+
with patch.object(
92+
lambda_function, "installation_token", return_value="app-token"
93+
), patch.object(
94+
lambda_function, "fetch_log", return_value=make_response(200)
95+
) as fetch_log:
96+
download_log("pytorch/pytorch", "failure", 123)
97+
98+
fetch_log.assert_called_once_with("pytorch/pytorch", 123, "app-token")
99+
s3.Object.assert_called_once_with("ossci-raw-job-status", "log/123")
100+
urlopen.assert_called_once()
101+
102+
def test_falls_back_to_pat_when_rate_limited(self, urlopen, s3):
103+
responses = [
104+
make_response(403, headers={"x-ratelimit-remaining": "0"}),
105+
make_response(200),
106+
]
107+
with patch.object(
108+
lambda_function, "installation_token", return_value="app-token"
109+
), patch.object(
110+
lambda_function, "fetch_log", side_effect=responses
111+
) as fetch_log:
112+
download_log("pytorch/pytorch", "failure", 123)
113+
114+
self.assertEqual(fetch_log.call_count, 2)
115+
self.assertIn(fetch_log.call_args_list[1][0][2], ("pat1", "pat2"))
116+
# The log still gets archived via the fallback credential
117+
s3.Object.assert_called_once_with("ossci-raw-job-status", "log/123")
118+
119+
def test_rate_limited_app_is_skipped_until_reset(self, urlopen, s3):
120+
reset_at = 2**31
121+
responses = [
122+
make_response(429, headers={"x-ratelimit-reset": str(reset_at)}),
123+
make_response(200),
124+
make_response(200),
125+
]
126+
with patch.object(
127+
lambda_function, "fetch_installation_token", return_value=("app", 2**31)
128+
), patch.object(lambda_function, "GITHUB_APP_ID", "4550824"), patch.object(
129+
lambda_function, "GITHUB_APP_PRIVATE_KEY", "key"
130+
), patch.object(
131+
lambda_function, "fetch_log", side_effect=responses
132+
) as fetch_log:
133+
download_log("pytorch/pytorch", "failure", 123)
134+
download_log("pytorch/pytorch", "failure", 456)
135+
136+
# 2 calls for the first job (app then PAT), 1 for the second (PAT only)
137+
self.assertEqual(fetch_log.call_count, 3)
138+
self.assertEqual(
139+
lambda_function._token_cache["pytorch"], (None, float(reset_at))
140+
)
141+
142+
def test_error_response_is_not_archived(self, urlopen, s3):
143+
with patch.object(
144+
lambda_function, "installation_token", return_value=None
145+
), patch.object(
146+
lambda_function,
147+
"fetch_log",
148+
return_value=make_response(404, content=b'{"message": "Not Found"}'),
149+
):
150+
download_log("pytorch/pytorch", "skipped", 123)
151+
152+
s3.Object.assert_not_called()
153+
urlopen.assert_not_called()
154+
155+
def test_non_pytorch_repo_is_prefixed(self, urlopen, s3):
156+
with patch.object(
157+
lambda_function, "installation_token", return_value=None
158+
), patch.object(lambda_function, "fetch_log", return_value=make_response(200)):
159+
download_log("pytorch/executorch", "success", 999)
160+
161+
s3.Object.assert_called_once_with(
162+
"ossci-raw-job-status", "log/pytorch/executorch/999"
163+
)
164+
165+
def test_no_credentials_at_all_is_a_noop(self, urlopen, s3):
166+
with patch.object(
167+
lambda_function, "installation_token", return_value=None
168+
), patch.object(lambda_function, "GITHUB_TOKENS", None), patch.object(
169+
lambda_function, "fetch_log"
170+
) as fetch_log:
171+
download_log("pytorch/pytorch", "failure", 123)
172+
173+
fetch_log.assert_not_called()
174+
s3.Object.assert_not_called()
175+
176+
177+
if __name__ == "__main__":
178+
unittest.main()

0 commit comments

Comments
 (0)