Skip to content

Commit bb0f11a

Browse files
committed
Update
[ghstack-poisoned]
1 parent cbc956d commit bb0f11a

5 files changed

Lines changed: 646 additions & 0 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
ZIP := gha-log-uploader-deployment.zip
2+
FUNCTION := gha-log-uploader
3+
# Must match the deployed runtime. The package contains version-specific
4+
# compiled wheels (cffi), so a package built for one python on a function
5+
# running another fails at import on every single invocation. `deploy` checks
6+
# this against the live function rather than trusting the two to stay in sync.
7+
PYTHON_VERSION := 3.12
8+
# Third-party modules lambda_function.py imports. The built zip is checked for
9+
# these before it can be deployed: a package missing a dependency fails at
10+
# import, which drops every log upload until someone notices.
11+
VENDORED := boto3 requests github
12+
13+
# The lambda runs on x86_64. cryptography ships compiled wheels, so pin the
14+
# target platform rather than inheriting whatever python the CI runner happens
15+
# to default to -- otherwise the zip gets wheels the runtime can't load.
16+
# Starts from clean so a stale packages/ or zip can't leak into the artifact.
17+
prepare: clean
18+
mkdir -p ./packages
19+
pip install --target ./packages \
20+
--platform manylinux2014_x86_64 --python-version $(PYTHON_VERSION) \
21+
--implementation cp --only-binary=:all: --no-compile \
22+
-r requirements.txt
23+
cd packages && zip -r ../$(ZIP) .
24+
zip -g $(ZIP) lambda_function.py
25+
$(MAKE) verify
26+
27+
verify:
28+
@for m in $(VENDORED); do \
29+
unzip -l $(ZIP) | grep -qE " $$m/__init__\.py$$" \
30+
|| { echo "ERROR: '$$m' missing from $(ZIP), refusing to deploy"; exit 1; }; \
31+
done
32+
@echo "verified: $(ZIP) contains $(VENDORED)"
33+
34+
# Refuse to publish a package built for a different python than the function
35+
# actually runs. Without this the two can drift silently and the first symptom
36+
# is Runtime.ImportModuleError on every invocation.
37+
check-runtime:
38+
@live=$$(aws lambda get-function-configuration --function-name $(FUNCTION) \
39+
--query Runtime --output text); \
40+
if [ "$$live" != "python$(PYTHON_VERSION)" ]; then \
41+
echo "ERROR: $(FUNCTION) runs $$live but this package targets python$(PYTHON_VERSION)."; \
42+
echo " Change the function runtime first, or set PYTHON_VERSION to $${live#python}."; \
43+
exit 1; \
44+
fi; \
45+
echo "runtime check: $(FUNCTION) runs $$live, package targets python$(PYTHON_VERSION)"
46+
47+
deploy: check-runtime prepare
48+
aws lambda update-function-code --function-name $(FUNCTION) --zip-file fileb://$(ZIP)
49+
50+
clean:
51+
rm -rf $(ZIP) packages
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# gha-log-uploader
2+
3+
Downloads a completed GitHub Actions job log and archives it to
4+
`s3://ossci-raw-job-status/log/`. This is the log-download half of the old
5+
`github-status-test` lambda, moved behind the PyTorch bot so onboarding a repo to
6+
HUD no longer needs an admin to add a repo webhook. See
7+
https://github.com/pytorch/test-infra/issues/7549.
8+
9+
`github-status-test` still exists and is untouched. It is deleted after the
10+
cutover, not edited into this shape.
11+
12+
## How it is invoked
13+
14+
Only through `lambda:InvokeFunction`. **There is no API Gateway integration and no
15+
function URL, and neither should be added** — the function must not be reachable
16+
from the internet.
17+
18+
Two callers, both in torchci, both using `InvocationType: "Event"`:
19+
20+
- `lib/bot/logUploader.ts`, on a `workflow_job` webhook with `action == completed`.
21+
- `lib/jobUtils.ts`'s `backfillMissingLog`, when Dr.CI notices a log is missing.
22+
External callers reach the same path through the authenticated
23+
`POST /api/log-uploader/backfill` route.
24+
25+
Payload:
26+
27+
```json
28+
{ "repo": "pytorch/executorch", "job_id": 12345, "conclusion": "failure" }
29+
```
30+
31+
`conclusion` is optional. A malformed payload raises, which means Lambda retries
32+
twice and then DLQs it.
33+
34+
## What it does not do
35+
36+
It does not ping the log classifier. An S3 `ObjectCreated` notification on the
37+
`log/` prefix invokes `call-log-classifier`, which invokes `log_classifier`. That
38+
split is deliberate: the old lambda called the classifier with an untimed
39+
`urlopen` and blocked until classification finished, which is what produced its
40+
274s/344s/400s/900s duration tails.
41+
42+
It does not archive raw webhook payloads. Nothing read them —
43+
`clickhouse-replicator-s3` has no `SUPPORTED_PATHS` entry for `workflow_job/`,
44+
`workflow_run/`, or `full_workflow_*/`, and ClickHouse gets jobs from DynamoDB via
45+
`clickhouse-replicator-dynamo`.
46+
47+
## S3 key scheme
48+
49+
`log/<job_id>` for `pytorch/pytorch`, `log/<owner>/<repo>/<job_id>` for everything
50+
else. The asymmetry is historical but load-bearing: the `log_url` ALIAS in
51+
`clickhouse_db_schema/default.workflow_job/schema.sql` derives URLs from exactly
52+
this shape, so changing it silently breaks every log link in the HUD.
53+
54+
## GitHub credentials
55+
56+
Job logs are downloaded with a GitHub App installation token, falling back to the
57+
`GITHUB_TOKENS` PAT pool when the app is rate limited, rejected, or not installed
58+
on the repo.
59+
60+
| Env var | Required | Purpose |
61+
| --- | --- | --- |
62+
| `GITHUB_APP_ID` | no | App id used to mint installation tokens (e.g. `4550824`, `pytorch-bot-preview`) |
63+
| `GITHUB_APP_PRIVATE_KEY` | no | The app's private key, base64-encoded PEM (same encoding torchci uses) |
64+
| `GITHUB_TOKENS` | yes | Comma-separated PAT pool, used as the fallback and when no app is configured |
65+
66+
With both app vars unset the function only uses `GITHUB_TOKENS`, so the app can be
67+
rolled back by clearing the env vars — no code change or redeploy needed.
68+
69+
Notes on the app path:
70+
71+
- Installation tokens last an hour and are cached per repo in module scope, so a
72+
warm invocation reuses one rather than minting a token per job.
73+
- The app's rate limit is per installation. `pytorch` is enterprise-owned, so its
74+
installation gets 15,000 requests/hour, independent of any other app's quota.
75+
Use a dedicated app rather than the shared `pytorch-bot` installation, whose
76+
quota Dr. CI and the HUD already draw on.
77+
- Repos outside the installation (e.g. `vllm-project/vllm`) resolve to no
78+
installation and go straight to the PAT pool; that negative result is cached
79+
briefly to avoid a lookup per job.
80+
- Downloading job logs is documented as needing `actions: read`. It currently
81+
works without it because pytorch repos are public, but the permission should be
82+
granted before any private repo is onboarded.
83+
84+
## One-time AWS setup
85+
86+
Not done by CI. Needed before the deploy workflow can run.
87+
88+
1. Create the function: python3.12, x86_64, handler `lambda_function.lambda_handler`.
89+
512 MB and a 60s timeout are plenty — the old function averaged 200ms and its
90+
long tail was the classifier ping this one does not make.
91+
2. Give its execution role `s3:PutObject` on `arn:aws:s3:::ossci-raw-job-status/log/*`
92+
plus the usual CloudWatch Logs permissions.
93+
3. Set the env vars above. Prefer fresh credentials over copying
94+
`github-status-test`'s, whose PATs sit in plaintext env vars and are due for
95+
rotation.
96+
4. Configure an on-failure destination or DLQ, and alarm on it. That queue is the
97+
only signal that a trunk-only job lost its log.
98+
5. Add the invoke grant for torchci, and nothing else:
99+
```
100+
aws lambda add-permission --function-name gha-log-uploader \
101+
--statement-id torchci-invoke --action lambda:InvokeFunction \
102+
--principal arn:aws:iam::308535385114:user/pytorch_hud_bot
103+
```
104+
Confirm that user really is the principal behind torchci's
105+
`OUR_AWS_ACCESS_KEY_ID` before granting.
106+
6. Create the `gha_workflow_gha-log-uploader-lambda` IAM role the deploy workflow
107+
assumes, mirroring `gha_workflow_github-status-test-lambda`.
108+
7. Wire the classifier notification — see `../call-log-classifier/README.md`.
109+
110+
## Deployment
111+
112+
`make deploy` publishes to `$LATEST` and is live immediately; the deploy job in
113+
`.github/workflows/gha-log-uploader-lambda.yml` runs it on every push to main that
114+
touches this directory. `make prepare` verifies the zip contains every vendored
115+
module and `make deploy` refuses to publish a package built for a different python
116+
than the function runs, but there is no staged rollout behind either.
117+
118+
`PYTHON_VERSION` in the Makefile must match the function's runtime. Changing one
119+
without the other breaks every invocation.
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
# Copyright (c) 2019-present, Facebook, Inc.
2+
3+
"""Download a completed GitHub Actions job log and archive it to S3.
4+
5+
Invoked asynchronously (``InvocationType: "Event"``) by the PyTorch bot's
6+
``workflow_job`` handler in torchci, and by torchci's backfill route. There is no
7+
API Gateway integration and no Lambda function URL: the only way in is
8+
``lambda:InvokeFunction``, which is IAM-authenticated.
9+
10+
Classification is *not* triggered from here. An S3 ObjectCreated notification on
11+
the ``log/`` prefix drives it, so any path that lands a log gets classified and
12+
this function never blocks on the classifier finishing.
13+
"""
14+
15+
import base64
16+
import contextlib
17+
import gzip
18+
import os
19+
import random
20+
import time
21+
22+
import boto3
23+
import requests
24+
from github import Auth, GithubIntegration
25+
from github.GithubException import UnknownObjectException
26+
27+
28+
s3 = boto3.resource("s3")
29+
GITHUB_TOKENS = os.environ.get("GITHUB_TOKENS")
30+
GITHUB_APP_ID = os.environ.get("GITHUB_APP_ID")
31+
# Base64-encoded PEM, the same encoding torchci uses for its app key
32+
GITHUB_APP_PRIVATE_KEY = os.environ.get("GITHUB_APP_PRIVATE_KEY")
33+
BUCKET_NAME = "ossci-raw-job-status"
34+
35+
GITHUB_API_URL = "https://api.github.com"
36+
# Installation tokens last an hour. Refresh early so a warm invocation never
37+
# signs a request with a token that expires mid-flight.
38+
TOKEN_EXPIRY_MARGIN = 300
39+
# How long to remember that a repo has no app installation, so repos outside
40+
# the installation don't trigger a lookup for every job.
41+
NO_INSTALLATION_TTL = 900
42+
# Used when a credential is rejected without telling us when it recovers.
43+
DEFAULT_COOL_OFF = 60
44+
# Statuses meaning "this credential can't do it, try the next one": rate limited
45+
# (403 or 429) or rejected outright (401).
46+
FALLBACK_STATUSES = (401, 403, 429)
47+
48+
# Keyed by "owner/repo", not owner: get_repo_installation() resolves per repo,
49+
# so a "Selected repositories" install can cover one repo of an owner and not
50+
# its sibling. Sharing an owner's entry would hand a repo a token minted for a
51+
# different one, or let one repo's "not installed" result mask another's.
52+
# full_name -> (installation token or None, epoch seconds the entry goes stale)
53+
_token_cache = {}
54+
55+
56+
def app_private_key():
57+
key = GITHUB_APP_PRIVATE_KEY
58+
if "PRIVATE KEY" not in key:
59+
key = base64.b64decode(key).decode("utf-8")
60+
return key
61+
62+
63+
def cool_off_until(response):
64+
reset = response.headers.get("x-ratelimit-reset")
65+
if reset:
66+
with contextlib.suppress(ValueError):
67+
return float(reset)
68+
return time.time() + DEFAULT_COOL_OFF
69+
70+
71+
def fetch_installation_token(full_name):
72+
"""Mint an installation token for the app installation covering full_name.
73+
74+
Returns (None, expiry) when the app isn't installed on that repo, so the
75+
caller falls back to a PAT instead of retrying the lookup for every job.
76+
"""
77+
owner, repo = full_name.split("/", 1)
78+
integration = GithubIntegration(
79+
auth=Auth.AppAuth(int(GITHUB_APP_ID), app_private_key())
80+
)
81+
82+
try:
83+
installation = integration.get_repo_installation(owner, repo)
84+
except UnknownObjectException:
85+
return None, time.time() + NO_INSTALLATION_TTL
86+
87+
token = integration.get_access_token(installation.id)
88+
return token.token, token.expires_at.timestamp() - TOKEN_EXPIRY_MARGIN
89+
90+
91+
def installation_token(full_name):
92+
"""Cached installation token for full_name, or None if unavailable."""
93+
if not GITHUB_APP_ID or not GITHUB_APP_PRIVATE_KEY:
94+
return None
95+
96+
cached = _token_cache.get(full_name)
97+
if cached and time.time() < cached[1]:
98+
return cached[0]
99+
100+
try:
101+
token, expires_at = fetch_installation_token(full_name)
102+
except Exception as err:
103+
# Deliberately broad: a bad app id, an unparseable private key or a
104+
# GitHub blip must degrade to the PAT pool rather than raise. Not cached
105+
# either, so the next invocation retries.
106+
print(f"ERROR minting installation token for {full_name}: {err}")
107+
return None
108+
109+
_token_cache[full_name] = (token, expires_at)
110+
return token
111+
112+
113+
def fetch_log(full_name, job_id, token):
114+
url = f"{GITHUB_API_URL}/repos/{full_name}/actions/jobs/{job_id}/logs"
115+
headers = {
116+
"Accept": "application/vnd.github.v3+json",
117+
"Authorization": "token " + token,
118+
}
119+
return requests.get(url, headers=headers, timeout=30)
120+
121+
122+
def log_object_path(full_name, job_id):
123+
"""S3 key for a job's log.
124+
125+
pytorch/pytorch is unprefixed for historical reasons and must stay that way:
126+
default.workflow_job's `log_url` ALIAS in ClickHouse derives the URL from
127+
this exact scheme.
128+
"""
129+
if full_name == "pytorch/pytorch":
130+
return f"log/{job_id}"
131+
return f"log/{full_name}/{job_id}"
132+
133+
134+
def download_log(full_name, conclusion, job_id):
135+
"""Fetch a job log from GitHub and archive it. Returns True when stored."""
136+
response = None
137+
138+
app_token = installation_token(full_name)
139+
if app_token:
140+
response = fetch_log(full_name, job_id, app_token)
141+
if response.status_code in FALLBACK_STATUSES:
142+
# Stop using the app until its window resets, otherwise every job
143+
# for the rest of the hour pays for a doomed request first.
144+
_token_cache[full_name] = (None, cool_off_until(response))
145+
print(
146+
f"App auth returned {response.status_code} for {full_name} "
147+
f"job {job_id}, falling back to a PAT"
148+
)
149+
response = None
150+
151+
if response is None:
152+
if not GITHUB_TOKENS:
153+
print(f"ERROR no usable credential for {full_name} job {job_id}")
154+
return False
155+
response = fetch_log(full_name, job_id, random.choice(GITHUB_TOKENS.split(",")))
156+
157+
if not response.ok:
158+
# Bail out rather than archive the API error body as if it were the log
159+
print(
160+
f"ERROR {response.status_code} downloading log for {full_name} job {job_id}"
161+
)
162+
return False
163+
164+
# Note: brotli would compress better, but is annoying to add as a dep
165+
# If space becomes a problem it's roughly ~2x better in TEXT_MODE
166+
s3.Object(BUCKET_NAME, log_object_path(full_name, job_id)).put(
167+
Body=gzip.compress(response.content),
168+
ContentType="text/plain",
169+
ContentEncoding="gzip",
170+
Metadata={"conclusion": conclusion or ""},
171+
)
172+
return True
173+
174+
175+
def parse_event(event):
176+
"""Validate the invoke payload, returning (full_name, conclusion, job_id).
177+
178+
Raises ValueError on anything malformed. The caller is an async invoke, so a
179+
raised error is retried twice by Lambda and then lands in the DLQ, which is
180+
what we want for a payload we can't interpret.
181+
"""
182+
if not isinstance(event, dict):
183+
raise ValueError(f"expected a JSON object, got {type(event).__name__}")
184+
185+
full_name = event.get("repo")
186+
if not full_name or "/" not in full_name:
187+
raise ValueError(f"missing or malformed 'repo': {full_name!r}")
188+
189+
try:
190+
job_id = int(event["job_id"])
191+
except (KeyError, TypeError, ValueError):
192+
raise ValueError(f"missing or non-numeric 'job_id': {event.get('job_id')!r}")
193+
194+
return full_name, event.get("conclusion"), job_id
195+
196+
197+
def lambda_handler(event, context):
198+
full_name, conclusion, job_id = parse_event(event)
199+
200+
try:
201+
stored = download_log(full_name, conclusion, job_id)
202+
except requests.RequestException as err:
203+
# A GitHub blip is worth a Lambda retry, so let it propagate.
204+
print(f"ERROR downloading log for {full_name} job {job_id}: {err}")
205+
raise
206+
207+
return {"repo": full_name, "job_id": job_id, "stored": stored}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
boto3==1.24.59
2+
requests==2.32.2
3+
# Mints the app installation token. Same version cross_repo_ci_relay uses.
4+
PyGithub==2.9.0

0 commit comments

Comments
 (0)