|
1 | 1 | # Copyright (c) 2019-present, Facebook, Inc. |
2 | 2 |
|
3 | | -"""Download a completed GitHub Actions job log and archive it to S3. |
| 3 | +"""Download a completed GitHub Actions job log, archive it to S3, and classify it. |
4 | 4 |
|
5 | 5 | Invoked asynchronously (``InvocationType: "Event"``) by the PyTorch bot's |
6 | 6 | ``workflow_job`` handler in torchci, and by torchci's backfill route. There is no |
7 | 7 | API Gateway integration and no Lambda function URL: the only way in is |
8 | 8 | ``lambda:InvokeFunction``, which is IAM-authenticated. |
9 | 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. |
| 10 | +The classifier is invoked the same way, asynchronously, so this function never |
| 11 | +blocks on classification finishing. ``github-status-test`` called it with an |
| 12 | +untimed ``urlopen`` and waited, which is what produced its 274s/344s/400s/900s |
| 13 | +duration tails -- the fix is the invocation type, not a separate function. |
13 | 14 | """ |
14 | 15 |
|
15 | 16 | import base64 |
16 | 17 | import contextlib |
17 | 18 | import gzip |
| 19 | +import json |
18 | 20 | import os |
19 | 21 | import random |
20 | 22 | import time |
|
26 | 28 |
|
27 | 29 |
|
28 | 30 | s3 = boto3.resource("s3") |
| 31 | +lambda_client = boto3.client("lambda") |
29 | 32 | GITHUB_TOKENS = os.environ.get("GITHUB_TOKENS") |
30 | 33 | GITHUB_APP_ID = os.environ.get("GITHUB_APP_ID") |
31 | 34 | # Base64-encoded PEM, the same encoding torchci uses for its app key |
32 | 35 | GITHUB_APP_PRIVATE_KEY = os.environ.get("GITHUB_APP_PRIVATE_KEY") |
33 | 36 | BUCKET_NAME = "ossci-raw-job-status" |
| 37 | +LOG_CLASSIFIER_FUNCTION = "log_classifier" |
34 | 38 |
|
35 | 39 | GITHUB_API_URL = "https://api.github.com" |
36 | 40 | # Installation tokens last an hour. Refresh early so a warm invocation never |
@@ -131,6 +135,66 @@ def log_object_path(full_name, job_id): |
131 | 135 | return f"log/{full_name}/{job_id}" |
132 | 136 |
|
133 | 137 |
|
| 138 | +def classifier_payload(full_name, job_id): |
| 139 | + """An API Gateway HTTP API v2.0 request, which is what lambda_http parses. |
| 140 | +
|
| 141 | + log_classifier is built on lambda_http with only the `apigw_http` feature, so |
| 142 | + it expects this envelope even on a direct invoke. Verified against the |
| 143 | + deployed function: a payload with no `job_id` returns its 400 "no job id |
| 144 | + provided" branch, and a non-numeric one fails in its `parse::<usize>()`, |
| 145 | + which together show both the envelope and the query string are read. |
| 146 | + """ |
| 147 | + return { |
| 148 | + "version": "2.0", |
| 149 | + "routeKey": "$default", |
| 150 | + "rawPath": "/", |
| 151 | + "rawQueryString": f"job_id={job_id}&repo={full_name}", |
| 152 | + "headers": {}, |
| 153 | + "queryStringParameters": {"job_id": str(job_id), "repo": full_name}, |
| 154 | + "requestContext": { |
| 155 | + "accountId": "308535385114", |
| 156 | + "apiId": "gha-log-uploader", |
| 157 | + "domainName": "lambda-invoke", |
| 158 | + "domainPrefix": "lambda-invoke", |
| 159 | + "http": { |
| 160 | + "method": "GET", |
| 161 | + "path": "/", |
| 162 | + "protocol": "HTTP/1.1", |
| 163 | + "sourceIp": "127.0.0.1", |
| 164 | + "userAgent": "gha-log-uploader", |
| 165 | + }, |
| 166 | + "requestId": f"gha-log-uploader-{job_id}", |
| 167 | + "routeKey": "$default", |
| 168 | + "stage": "$default", |
| 169 | + "time": "01/Jan/1970:00:00:00 +0000", |
| 170 | + "timeEpoch": 0, |
| 171 | + }, |
| 172 | + "isBase64Encoded": False, |
| 173 | + } |
| 174 | + |
| 175 | + |
| 176 | +def classify_log(full_name, job_id): |
| 177 | + """Kick off classification for a log we just stored. Returns True on handoff. |
| 178 | +
|
| 179 | + Asynchronous, and reached through `lambda:InvokeFunction` rather than |
| 180 | + log_classifier's public function URL, so the path from here to classification |
| 181 | + never crosses a public endpoint. |
| 182 | + """ |
| 183 | + try: |
| 184 | + lambda_client.invoke( |
| 185 | + FunctionName=LOG_CLASSIFIER_FUNCTION, |
| 186 | + InvocationType="Event", |
| 187 | + Payload=json.dumps(classifier_payload(full_name, job_id)).encode(), |
| 188 | + ) |
| 189 | + return True |
| 190 | + except Exception as err: |
| 191 | + # Best effort, deliberately. Raising would make Lambda retry the whole |
| 192 | + # function, re-downloading a multi-megabyte log from GitHub to retry a |
| 193 | + # handoff that takes milliseconds. The log itself is already safe in S3. |
| 194 | + print(f"ERROR invoking the classifier for {full_name} job {job_id}: {err}") |
| 195 | + return False |
| 196 | + |
| 197 | + |
134 | 198 | def download_log(full_name, conclusion, job_id): |
135 | 199 | """Fetch a job log from GitHub and archive it. Returns True when stored.""" |
136 | 200 | response = None |
@@ -204,4 +268,11 @@ def lambda_handler(event, context): |
204 | 268 | print(f"ERROR downloading log for {full_name} job {job_id}: {err}") |
205 | 269 | raise |
206 | 270 |
|
207 | | - return {"repo": full_name, "job_id": job_id, "stored": stored} |
| 271 | + classified = classify_log(full_name, job_id) if stored else False |
| 272 | + |
| 273 | + return { |
| 274 | + "repo": full_name, |
| 275 | + "job_id": job_id, |
| 276 | + "stored": stored, |
| 277 | + "classified": classified, |
| 278 | + } |
0 commit comments