Skip to content

Commit 303c6f8

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) - Installation tokens are cached per repo owner in module scope and refreshed 5 minutes early, so warm invocations don't mint one per job - The JWT's iat is backdated 60s; GitHub rejects a future-dated iat - 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 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 303c6f8

5 files changed

Lines changed: 374 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: 158 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,186 @@
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
9+
from datetime import datetime, timezone
710
from urllib.error import HTTPError
811
from urllib.request import urlopen
912
from uuid import uuid4
1013

1114
import boto3
15+
import jwt
1216
import requests
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+
# GitHub rejects a JWT whose iat is in the future, so backdate it to absorb any
28+
# clock skew between the lambda and GitHub.
29+
JWT_ISSUED_SKEW = 60
30+
JWT_EXPIRY = 540
31+
# Installation tokens last an hour. Refresh early so a warm invocation never
32+
# signs a request with a token that expires mid-flight.
33+
TOKEN_EXPIRY_MARGIN = 300
34+
# How long to remember that an owner has no app installation, so repos outside
35+
# the installation don't trigger a lookup for every job.
36+
NO_INSTALLATION_TTL = 900
37+
# Used when a credential is rejected without telling us when it recovers.
38+
DEFAULT_COOL_OFF = 60
39+
# Statuses meaning "this credential can't do it, try the next one": rate limited
40+
# (403 or 429) or rejected outright (401).
41+
FALLBACK_STATUSES = (401, 403, 429)
42+
43+
# owner -> (installation token or None, epoch seconds the entry goes stale)
44+
_token_cache = {}
45+
1946

2047
def json_dumps(obj):
2148
return json.dumps(obj, sort_keys=True, indent=4, separators=(",", ": "))
2249

2350

24-
def download_log(full_name, conclusion, job_id):
25-
url = f"https://api.github.com/repos/{full_name}/actions/jobs/{job_id}/logs"
51+
def app_private_key():
52+
key = GITHUB_APP_PRIVATE_KEY
53+
if "PRIVATE KEY" not in key:
54+
key = base64.b64decode(key).decode("utf-8")
55+
return key
56+
57+
58+
def app_jwt():
59+
now = int(time.time())
60+
return jwt.encode(
61+
{
62+
"iat": now - JWT_ISSUED_SKEW,
63+
"exp": now + JWT_EXPIRY,
64+
"iss": GITHUB_APP_ID,
65+
},
66+
app_private_key(),
67+
algorithm="RS256",
68+
)
69+
70+
71+
def token_expiry(expires_at):
72+
if expires_at:
73+
try:
74+
parsed = datetime.strptime(expires_at, "%Y-%m-%dT%H:%M:%SZ")
75+
return parsed.replace(tzinfo=timezone.utc).timestamp() - TOKEN_EXPIRY_MARGIN
76+
except ValueError:
77+
pass
78+
return time.time() + 3600 - TOKEN_EXPIRY_MARGIN
79+
80+
81+
def cool_off_until(response):
82+
reset = response.headers.get("x-ratelimit-reset")
83+
if reset:
84+
try:
85+
return float(reset)
86+
except ValueError:
87+
pass
88+
return time.time() + DEFAULT_COOL_OFF
89+
90+
91+
def fetch_installation_token(full_name):
92+
"""Mint an installation token for the app installation covering full_name.
93+
94+
Returns (None, expiry) when the app isn't installed on that owner, so the
95+
caller falls back to a PAT instead of retrying the lookup for every job.
96+
"""
2697
headers = {
2798
"Accept": "application/vnd.github.v3+json",
28-
"Authorization": "token " + random.choice(GITHUB_TOKENS.split(",")),
99+
"Authorization": "Bearer " + app_jwt(),
29100
}
30-
r = requests.get(url, headers=headers)
31-
log_data = r.content
101+
102+
r = requests.get(
103+
f"{GITHUB_API_URL}/repos/{full_name}/installation", headers=headers, timeout=10
104+
)
105+
if r.status_code == 404:
106+
return None, time.time() + NO_INSTALLATION_TTL
107+
r.raise_for_status()
108+
109+
installation_id = r.json()["id"]
110+
r = requests.post(
111+
f"{GITHUB_API_URL}/app/installations/{installation_id}/access_tokens",
112+
headers=headers,
113+
timeout=10,
114+
)
115+
r.raise_for_status()
116+
body = r.json()
117+
return body["token"], token_expiry(body.get("expires_at"))
118+
119+
120+
def installation_token(full_name):
121+
"""Cached installation token for full_name's owner, or None if unavailable."""
122+
if not GITHUB_APP_ID or not GITHUB_APP_PRIVATE_KEY:
123+
return None
124+
125+
owner = full_name.split("/")[0]
126+
cached = _token_cache.get(owner)
127+
if cached and time.time() < cached[1]:
128+
return cached[0]
129+
130+
try:
131+
token, expires_at = fetch_installation_token(full_name)
132+
except Exception as err:
133+
# Deliberately broad: a bad app id, an unparseable private key or a
134+
# GitHub blip must degrade to the PAT pool, never fail the webhook and
135+
# lose the payload archiving that happens after the log download.
136+
# Not cached either, so the next invocation retries.
137+
print(f"ERROR minting installation token for {owner}: {err}")
138+
return None
139+
140+
_token_cache[owner] = (token, expires_at)
141+
return token
142+
143+
144+
def fetch_log(full_name, job_id, token):
145+
url = f"{GITHUB_API_URL}/repos/{full_name}/actions/jobs/{job_id}/logs"
146+
headers = {
147+
"Accept": "application/vnd.github.v3+json",
148+
"Authorization": "token " + token,
149+
}
150+
return requests.get(url, headers=headers, timeout=30)
151+
152+
153+
def download_log(full_name, conclusion, job_id):
154+
response = None
155+
156+
app_token = installation_token(full_name)
157+
if app_token:
158+
response = fetch_log(full_name, job_id, app_token)
159+
if response.status_code in FALLBACK_STATUSES:
160+
# Stop using the app until its window resets, otherwise every job
161+
# for the rest of the hour pays for a doomed request first.
162+
owner = full_name.split("/")[0]
163+
_token_cache[owner] = (None, cool_off_until(response))
164+
print(
165+
f"App auth returned {response.status_code} for {full_name} "
166+
f"job {job_id}, falling back to a PAT"
167+
)
168+
response = None
169+
170+
if response is None:
171+
if not GITHUB_TOKENS:
172+
print(f"ERROR no usable credential for {full_name} job {job_id}")
173+
return
174+
response = fetch_log(full_name, job_id, random.choice(GITHUB_TOKENS.split(",")))
175+
176+
if not response.ok:
177+
# Bail out rather than archive the API error body as if it were the log
178+
print(
179+
f"ERROR {response.status_code} downloading log for {full_name} job {job_id}"
180+
)
181+
return
182+
183+
log_data = response.content
32184

33185
object_path = f"log/{job_id}"
34186
if full_name != "pytorch/pytorch":
@@ -61,7 +213,7 @@ def lambda_handler(event, context):
61213
conclusion = body[event_type]["conclusion"]
62214
job_id = body[event_type]["id"]
63215
download_log(full_name, conclusion, job_id)
64-
except HTTPError as err:
216+
except (HTTPError, requests.RequestException) as err:
65217
# Just eat the error as logs are optional.
66218
print("ERROR", err)
67219
pass
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,6 @@
11
boto3==1.24.59
22
requests==2.32.2
3+
PyJWT==2.13.0
4+
# Needed by PyJWT to sign the app JWT with RS256. 43.0.3 is the last release
5+
# publishing a cp39 wheel, which is what the python3.9 runtime needs.
6+
cryptography==43.0.3

0 commit comments

Comments
 (0)