Skip to content

Commit 4f07028

Browse files
committed
Add the gha-log-uploader lambda
**Impact:** none yet -- nothing invokes this function **Risk:** low ## What A new lambda that downloads a completed GitHub Actions job log and archives it to `s3://ossci-raw-job-status/log/[<repo>/]<job_id>`. It takes a direct invoke payload (`{repo, job_id, conclusion}`) and has no API Gateway integration and no function URL, so `lambda:InvokeFunction` is the only way in. The log-download logic is copied from `github-status-test`: App installation token minting, the PAT pool fallback, the per-repo cool-off cache, gzip, and the S3 key scheme. Not copied: the raw event archive, the API Gateway event parsing, the synthetic `backfill` action branch, and the classifier ping. ghstack-source-id: 2a587ef Pull-Request: #8591
1 parent 936e72e commit 4f07028

5 files changed

Lines changed: 804 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: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
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+
## Classification
35+
36+
After a log is stored, `log_classifier` is invoked with `InvocationType: "Event"`
37+
and the result is not awaited. `github-status-test` called it with an untimed
38+
`urlopen` and blocked until classification finished, which is what produced its
39+
274s/344s/400s/900s duration tails — the fix is the invocation type, not a
40+
separate function.
41+
42+
It is reached through `lambda:InvokeFunction` rather than its public function URL
43+
(`AuthType: NONE`), so the path from here to classification never crosses a
44+
public endpoint. `log_classifier` builds on `lambda_http` with only the
45+
`apigw_http` feature, so `classifier_payload()` reproduces an API Gateway HTTP API
46+
v2.0 request. Verified against the deployed function: a payload with no `job_id`
47+
returns its 400 "no job id provided" branch, and a non-numeric one fails inside
48+
its `parse::<usize>()`, which together show both the envelope and the query
49+
string are read from a direct invoke.
50+
51+
A failed handoff is logged and reported as `classified: false`, not raised.
52+
Raising would make Lambda retry the whole function, re-downloading a
53+
multi-megabyte log from GitHub to retry something that takes milliseconds; the
54+
log itself is already safe in S3.
55+
56+
## What it does not do
57+
58+
It does not archive raw webhook payloads. Nothing read them —
59+
`clickhouse-replicator-s3` has no `SUPPORTED_PATHS` entry for `workflow_job/`,
60+
`workflow_run/`, or `full_workflow_*/`, and ClickHouse gets jobs from DynamoDB via
61+
`clickhouse-replicator-dynamo`.
62+
63+
## S3 key scheme
64+
65+
`log/<job_id>` for `pytorch/pytorch`, `log/<owner>/<repo>/<job_id>` for everything
66+
else. The asymmetry is historical but load-bearing: the `log_url` ALIAS in
67+
`clickhouse_db_schema/default.workflow_job/schema.sql` derives URLs from exactly
68+
this shape, so changing it silently breaks every log link in the HUD.
69+
70+
## GitHub credentials
71+
72+
Job logs are downloaded with a GitHub App installation token, falling back to the
73+
`GITHUB_TOKENS` PAT pool when the app is rate limited, rejected, or not installed
74+
on the repo.
75+
76+
| Env var | Required | Purpose |
77+
| --- | --- | --- |
78+
| `GITHUB_APP_ID` | no | App id used to mint installation tokens (e.g. `4550824`, `pytorch-bot-preview`) |
79+
| `GITHUB_APP_PRIVATE_KEY` | no | The app's private key, base64-encoded PEM (same encoding torchci uses) |
80+
| `GITHUB_TOKENS` | yes | Comma-separated PAT pool, used as the fallback and when no app is configured |
81+
82+
With both app vars unset the function only uses `GITHUB_TOKENS`, so the app can be
83+
rolled back by clearing the env vars — no code change or redeploy needed.
84+
85+
Notes on the app path:
86+
87+
- Installation tokens last an hour and are cached per repo in module scope, so a
88+
warm invocation reuses one rather than minting a token per job.
89+
- The app's rate limit is per installation. `pytorch` is enterprise-owned, so its
90+
installation gets 15,000 requests/hour, independent of any other app's quota.
91+
Use a dedicated app rather than the shared `pytorch-bot` installation, whose
92+
quota Dr. CI and the HUD already draw on.
93+
- Repos outside the installation (e.g. `vllm-project/vllm`) resolve to no
94+
installation and go straight to the PAT pool; that negative result is cached
95+
briefly to avoid a lookup per job.
96+
- Downloading job logs is documented as needing `actions: read`. It currently
97+
works without it because pytorch repos are public, but the permission should be
98+
granted before any private repo is onboarded.
99+
100+
## One-time AWS setup
101+
102+
Not done by CI. Needed before the deploy workflow can run.
103+
104+
1. Create the function: python3.12, x86_64, handler `lambda_function.lambda_handler`.
105+
512 MB and a 60s timeout are plenty — the old function averaged 200ms and its
106+
long tail was the classifier ping this one does not make.
107+
2. Give its execution role `s3:PutObject` on `arn:aws:s3:::ossci-raw-job-status/log/*`,
108+
`lambda:InvokeFunction` on
109+
`arn:aws:lambda:us-east-1:308535385114:function:log_classifier`, plus the
110+
usual CloudWatch Logs permissions.
111+
3. Set the env vars above. Prefer fresh credentials over copying
112+
`github-status-test`'s, whose PATs sit in plaintext env vars and are due for
113+
rotation.
114+
4. Configure an on-failure destination or DLQ, and alarm on it. That queue is the
115+
only signal that a trunk-only job lost its log.
116+
5. Add the invoke grant for torchci, and nothing else:
117+
```
118+
aws lambda add-permission --function-name gha-log-uploader \
119+
--statement-id torchci-invoke --action lambda:InvokeFunction \
120+
--principal arn:aws:iam::308535385114:user/pytorch_hud_bot
121+
```
122+
Confirm that user really is the principal behind torchci's
123+
`OUR_AWS_ACCESS_KEY_ID` before granting.
124+
6. Create the `gha_workflow_gha-log-uploader-lambda` IAM role the deploy workflow
125+
assumes, mirroring `gha_workflow_github-status-test-lambda`.
126+
7. Nothing to wire for classification: this function invokes `log_classifier`
127+
directly, so there is no S3 notification to add. `keep-going-call-log-classifier`
128+
still covers the separate `temp_logs/` prefix on `gha-artifacts`.
129+
130+
## Deployment
131+
132+
`make deploy` publishes to `$LATEST` and is live immediately; the deploy job in
133+
`.github/workflows/gha-log-uploader-lambda.yml` runs it on every push to main that
134+
touches this directory. `make prepare` verifies the zip contains every vendored
135+
module and `make deploy` refuses to publish a package built for a different python
136+
than the function runs, but there is no staged rollout behind either.
137+
138+
`PYTHON_VERSION` in the Makefile must match the function's runtime. Changing one
139+
without the other breaks every invocation.

0 commit comments

Comments
 (0)