Skip to content

Commit e8965b4

Browse files
committed
Update
[ghstack-poisoned]
2 parents 8028d9d + 0522f8a commit e8965b4

6 files changed

Lines changed: 167 additions & 14 deletions

File tree

.github/workflows/linux_job_v2.yml

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ jobs:
137137
# Will be blank outside of this
138138
PR_NUMBER: ${{ github.event.pull_request.number }}
139139
SCRIPT: ${{ inputs.script }}
140+
ALPINE_IMAGE: ${{ startsWith(inputs.runner, 'linux.arm64') && 'arm64v8/alpine' || startsWith(inputs.runner, 'linux.idc') && 'alpine' || '308535385114.dkr.ecr.us-east-1.amazonaws.com/tool/alpine' }}
140141
filter: ${{ inputs.checkout-mode == 'blobless' && 'blob:none' || inputs.checkout-mode == 'treeless' && 'tree:0' || null }}
141142
runs-on: ${{ inputs.runner }}
142143
# TODO: Eventually this should run in a container, we need to make a container that matches up
@@ -311,13 +312,27 @@ jobs:
311312
uses: ./test-infra/.github/actions/chown-directory
312313
with:
313314
directory: ${{ github.workspace }}/${{ env.repository }}
314-
ALPINE_IMAGE: ${{ startsWith(inputs.runner, 'linux.arm64') && 'arm64v8/alpine' || startsWith(inputs.runner, 'linux.idc') && 'alpine' || '308535385114.dkr.ecr.us-east-1.amazonaws.com/tool/alpine' }}
315+
ALPINE_IMAGE: ${{ env.ALPINE_IMAGE }}
315316

316-
- name: Chown runner temp
317+
# Not all of ${RUNNER_TEMP} — a container-issued chown lands on a subordinate
318+
# UID under rootless docker, locking the runner out of _github_workflow/.
319+
- name: Chown runner artifacts dir
317320
uses: ./test-infra/.github/actions/chown-directory
318321
with:
319-
directory: ${{ runner.temp }}
320-
ALPINE_IMAGE: ${{ startsWith(inputs.runner, 'linux.arm64') && 'arm64v8/alpine' || startsWith(inputs.runner, 'linux.idc') && 'alpine' || '308535385114.dkr.ecr.us-east-1.amazonaws.com/tool/alpine' }}
322+
directory: ${{ env.RUNNER_ARTIFACT_DIR }}
323+
ALPINE_IMAGE: ${{ env.ALPINE_IMAGE }}
324+
325+
- name: Chown runner docs dir
326+
uses: ./test-infra/.github/actions/chown-directory
327+
with:
328+
directory: ${{ env.RUNNER_DOCS_DIR }}
329+
ALPINE_IMAGE: ${{ env.ALPINE_IMAGE }}
330+
331+
- name: Chown runner test results dir
332+
uses: ./test-infra/.github/actions/chown-directory
333+
with:
334+
directory: ${{ env.RUNNER_TEST_RESULTS_DIR }}
335+
ALPINE_IMAGE: ${{ env.ALPINE_IMAGE }}
321336

322337
- name: Prepare artifacts for upload
323338
if: always()

torchci/lib/lambda.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,36 @@ import {
66

77
export const GHA_LOG_UPLOADER_FUNCTION = "gha-log-uploader";
88

9+
export class MissingAwsCredentialsError extends Error {
10+
constructor() {
11+
// The SDK's own failure here is "Resolved credential object is not valid",
12+
// which says nothing about which variable to go set.
13+
super(
14+
"OUR_AWS_ACCESS_KEY_ID / OUR_AWS_SECRET_ACCESS_KEY are not set, " +
15+
"cannot reach the log uploader"
16+
);
17+
this.name = "MissingAwsCredentialsError";
18+
}
19+
}
20+
921
export function getLambdaClient(): LambdaClient {
22+
const accessKeyId = process.env.OUR_AWS_ACCESS_KEY_ID;
23+
const secretAccessKey = process.env.OUR_AWS_SECRET_ACCESS_KEY;
24+
if (!accessKeyId || !secretAccessKey) {
25+
throw new MissingAwsCredentialsError();
26+
}
27+
1028
return new LambdaClient({
1129
region: "us-east-1",
12-
credentials: {
13-
accessKeyId: process.env.OUR_AWS_ACCESS_KEY_ID!,
14-
secretAccessKey: process.env.OUR_AWS_SECRET_ACCESS_KEY!,
15-
},
30+
credentials: { accessKeyId, secretAccessKey },
31+
// This call sits on the webhook ack path, so it has to fail fast rather than
32+
// fail well. The SDK defaults to 3 attempts and no socket timeout, which on a
33+
// black-holed endpoint hangs for the OS default (~75s) per attempt; measured,
34+
// a connectionTimeout of 1s aborts at ~1.5s instead. Worst case here is
35+
// roughly 4s, against a 10s GitHub webhook timeout that every other handler
36+
// also has to fit inside.
37+
maxAttempts: 2,
38+
requestHandler: { connectionTimeout: 1000, requestTimeout: 2000 },
1639
});
1740
}
1841

@@ -32,8 +55,9 @@ export interface LogUploadRequest {
3255
* handoff has to be both awaited and bounded.
3356
*
3457
* Delivery failures are Lambda's problem from here -- it retries twice and then
35-
* DLQs. Failures to hand off at all are the caller's, and are non-fatal: Dr.CI
36-
* re-requests a missing log through backfillMissingLog on its next run.
58+
* DLQs. Failures to hand off at all reject, and every caller treats that as
59+
* non-fatal: Dr.CI re-requests a missing log through backfillMissingLog on its
60+
* next run, so losing a handoff costs a log, never a webhook.
3761
*/
3862
export async function invokeLogUploader(
3963
request: LogUploadRequest
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { backfillMissingLog } from "lib/jobUtils";
2+
import * as lambda from "lib/lambda";
3+
import { RecentWorkflowsData } from "lib/types";
4+
5+
const job = { id: 999, conclusion: "failure" } as RecentWorkflowsData;
6+
7+
describe("backfillMissingLog", () => {
8+
afterEach(() => jest.restoreAllMocks());
9+
10+
test("queues an upload and reports success", async () => {
11+
const invoke = jest.spyOn(lambda, "invokeLogUploader").mockResolvedValue();
12+
13+
await expect(
14+
backfillMissingLog("pytorch", "executorch", job)
15+
).resolves.toBe(true);
16+
expect(invoke).toHaveBeenCalledWith({
17+
repo: "pytorch/executorch",
18+
job_id: 999,
19+
conclusion: "failure",
20+
});
21+
});
22+
23+
test.each([
24+
["credentials are missing", new lambda.MissingAwsCredentialsError()],
25+
["the Lambda API is unreachable", new Error("TimeoutError")],
26+
["the role cannot invoke", new Error("AccessDeniedException")],
27+
])("returns false rather than throwing when %s", async (_label, error) => {
28+
// Dr.CI calls this mid-comment-render for every failed job with no log. An
29+
// exception here would take down the whole Dr.CI run over one missing log.
30+
jest.spyOn(lambda, "invokeLogUploader").mockRejectedValue(error);
31+
jest.spyOn(console, "error").mockImplementation(() => {});
32+
33+
await expect(backfillMissingLog("pytorch", "pytorch", job)).resolves.toBe(
34+
false
35+
);
36+
});
37+
});

torchci/test/lambdaClient.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import {
2+
getLambdaClient,
3+
invokeLogUploader,
4+
MissingAwsCredentialsError,
5+
} from "lib/lambda";
6+
7+
describe("getLambdaClient", () => {
8+
const saved = {
9+
id: process.env.OUR_AWS_ACCESS_KEY_ID,
10+
secret: process.env.OUR_AWS_SECRET_ACCESS_KEY,
11+
};
12+
13+
afterEach(() => {
14+
process.env.OUR_AWS_ACCESS_KEY_ID = saved.id;
15+
process.env.OUR_AWS_SECRET_ACCESS_KEY = saved.secret;
16+
});
17+
18+
function setCredentials(id?: string, secret?: string) {
19+
if (id === undefined) {
20+
delete process.env.OUR_AWS_ACCESS_KEY_ID;
21+
} else {
22+
process.env.OUR_AWS_ACCESS_KEY_ID = id;
23+
}
24+
if (secret === undefined) {
25+
delete process.env.OUR_AWS_SECRET_ACCESS_KEY;
26+
} else {
27+
process.env.OUR_AWS_SECRET_ACCESS_KEY = secret;
28+
}
29+
}
30+
31+
test.each([
32+
["neither is set", undefined, undefined],
33+
["only the key id is set", "AKIA", undefined],
34+
["only the secret is set", undefined, "shh"],
35+
["the key id is empty", "", "shh"],
36+
])("throws a named error when %s", (_label, id, secret) => {
37+
setCredentials(id, secret);
38+
expect(() => getLambdaClient()).toThrow(MissingAwsCredentialsError);
39+
});
40+
41+
test("the error names the variables to set", () => {
42+
setCredentials(undefined, undefined);
43+
// The SDK's own message is "Resolved credential object is not valid", which
44+
// gives whoever is paged nothing to act on.
45+
expect(() => getLambdaClient()).toThrow(/OUR_AWS_ACCESS_KEY_ID/);
46+
});
47+
48+
test("builds a client when both are set", () => {
49+
setCredentials("AKIA", "shh");
50+
expect(getLambdaClient()).toBeDefined();
51+
});
52+
53+
test("bounds retries and socket waits", async () => {
54+
setCredentials("AKIA", "shh");
55+
const config = getLambdaClient().config;
56+
expect(await config.maxAttempts()).toBe(2);
57+
});
58+
59+
test("invokeLogUploader rejects rather than hanging with no credentials", async () => {
60+
setCredentials(undefined, undefined);
61+
await expect(
62+
invokeLogUploader({ repo: "pytorch/pytorch", job_id: 1 })
63+
).rejects.toThrow(MissingAwsCredentialsError);
64+
});
65+
});

torchci/test/logUploader.test.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,10 +138,17 @@ describe("logUploader", () => {
138138
);
139139
});
140140

141-
test("a failed invoke does not fail the webhook", async () => {
141+
test.each([
142+
["the role cannot invoke the function", "AccessDeniedException"],
143+
["credentials are missing entirely", "MissingAwsCredentialsError"],
144+
["the Lambda API is unreachable", "TimeoutError"],
145+
["Lambda throttles us", "TooManyRequestsException"],
146+
])("a failed invoke does not fail the webhook when %s", async (_l, name) => {
142147
// Throwing here would make GitHub redeliver the event and re-run every other
143148
// handler, to retry something Dr.CI repairs on its own.
144-
invoke.mockRejectedValue(new Error("AccessDeniedException"));
149+
const error = new Error(name);
150+
error.name = name;
151+
invoke.mockRejectedValue(error);
145152

146153
await expect(receive(workflowJobPayload())).resolves.not.toThrow();
147154
});

torchci/test/logUploaderBackfill.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,13 @@ describe("/api/log-uploader/backfill", () => {
114114
expect(res.status).toHaveBeenCalledWith(400);
115115
});
116116

117-
test("reports a failure to reach the uploader", async () => {
118-
invoke.mockRejectedValue(new Error("AccessDeniedException"));
117+
test.each([
118+
["credentials are missing", new lambda.MissingAwsCredentialsError()],
119+
["the role cannot invoke", new Error("AccessDeniedException")],
120+
["the Lambda API is unreachable", new Error("TimeoutError")],
121+
])("reports 502 rather than throwing when %s", async (_label, error) => {
122+
invoke.mockRejectedValue(error);
123+
jest.spyOn(console, "error").mockImplementation(() => {});
119124
const res = mockRes();
120125
await handler(mockReq(), res);
121126

0 commit comments

Comments
 (0)