-
Notifications
You must be signed in to change notification settings - Fork 142
Move log backfill off the public API Gateway endpoint #8595
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
huydhn
wants to merge
11
commits into
gh/huydhn/6/base
Choose a base branch
from
gh/huydhn/6/head
base: gh/huydhn/6/base
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 10 commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
7523061
Update
huydhn 7730d3e
Update
huydhn 84b278e
Update
huydhn 86a9421
Update
huydhn eeccfba
Update
huydhn 5099e87
Update
huydhn d36906d
Update
huydhn 28fab71
Update
huydhn 4a08ec5
Update
huydhn f992465
Update
huydhn 0ed5e92
Update
huydhn File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| import { invokeLogUploader } from "lib/lambda"; | ||
| import type { NextApiRequest, NextApiResponse } from "next"; | ||
|
|
||
| /** | ||
| * Re-upload a job's log to S3. | ||
| * | ||
| * Replaces the synthetic `action: "backfill"` event that used to be POSTed at | ||
| * github-status-test's API Gateway. That endpoint was public and unauthenticated; | ||
| * gha-log-uploader has no public endpoint at all, so this route is the way in for | ||
| * callers outside HUD (tools/scripts/backfill_events.py, manual ops). | ||
| * | ||
| * Code inside HUD should call invokeLogUploader directly rather than looping back | ||
| * through here -- see backfillMissingLog in lib/jobUtils. | ||
| */ | ||
| interface BackfillRequest { | ||
| repo?: string; | ||
| job_id?: number | string; | ||
| conclusion?: string | null; | ||
| } | ||
|
|
||
| export default async function handler( | ||
| req: NextApiRequest, | ||
| res: NextApiResponse<{ error: string } | { queued: true }> | ||
| ) { | ||
| if (req.method !== "POST") { | ||
| return res.status(405).json({ error: "POST only" }); | ||
| } | ||
|
|
||
| const key = process.env.LOG_UPLOADER_BOT_KEY; | ||
| // An unset key must not turn into an open endpoint. | ||
| if (!key || req.headers.authorization !== key) { | ||
| return res.status(403).json({ error: "Forbidden" }); | ||
| } | ||
|
|
||
| const { | ||
| repo, | ||
| job_id: rawJobId, | ||
| conclusion, | ||
| }: BackfillRequest = req.body ?? {}; | ||
|
|
||
| if (typeof repo !== "string" || !repo.includes("/")) { | ||
| return res.status(400).json({ error: "'repo' must be 'owner/name'" }); | ||
| } | ||
|
|
||
| const jobId = Number(rawJobId); | ||
| if (!Number.isSafeInteger(jobId) || jobId <= 0) { | ||
| return res | ||
| .status(400) | ||
| .json({ error: "'job_id' must be a positive integer" }); | ||
| } | ||
|
|
||
| try { | ||
| await invokeLogUploader({ repo, job_id: jobId, conclusion }); | ||
| } catch (error) { | ||
| console.error( | ||
| `Failed to queue a log upload for ${repo} job ${jobId}`, | ||
| error | ||
| ); | ||
| return res.status(502).json({ error: "Failed to reach the log uploader" }); | ||
| } | ||
|
|
||
| return res.status(200).json({ queued: true }); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| import { backfillMissingLog } from "lib/jobUtils"; | ||
| import * as lambda from "lib/lambda"; | ||
| import { RecentWorkflowsData } from "lib/types"; | ||
|
|
||
| const job = { id: 999, conclusion: "failure" } as RecentWorkflowsData; | ||
|
|
||
| describe("backfillMissingLog", () => { | ||
| afterEach(() => jest.restoreAllMocks()); | ||
|
|
||
| test("queues an upload and reports success", async () => { | ||
| const invoke = jest.spyOn(lambda, "invokeLogUploader").mockResolvedValue(); | ||
|
|
||
| await expect( | ||
| backfillMissingLog("pytorch", "executorch", job) | ||
| ).resolves.toBe(true); | ||
| expect(invoke).toHaveBeenCalledWith({ | ||
| repo: "pytorch/executorch", | ||
| job_id: 999, | ||
| conclusion: "failure", | ||
| }); | ||
| }); | ||
|
|
||
| test.each([ | ||
| ["credentials are missing", new lambda.MissingAwsCredentialsError()], | ||
| ["the Lambda API is unreachable", new Error("TimeoutError")], | ||
| ["the role cannot invoke", new Error("AccessDeniedException")], | ||
| ])("returns false rather than throwing when %s", async (_label, error) => { | ||
| // Dr.CI calls this mid-comment-render for every failed job with no log. An | ||
| // exception here would take down the whole Dr.CI run over one missing log. | ||
| jest.spyOn(lambda, "invokeLogUploader").mockRejectedValue(error); | ||
| jest.spyOn(console, "error").mockImplementation(() => {}); | ||
|
|
||
| await expect(backfillMissingLog("pytorch", "pytorch", job)).resolves.toBe( | ||
| false | ||
| ); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,129 @@ | ||
| import * as lambda from "lib/lambda"; | ||
| import type { NextApiRequest, NextApiResponse } from "next"; | ||
| import handler from "pages/api/log-uploader/backfill"; | ||
|
|
||
| function mockRes() { | ||
| const res: any = {}; | ||
| res.status = jest.fn().mockReturnValue(res); | ||
| res.json = jest.fn().mockReturnValue(res); | ||
| return res as NextApiResponse & { | ||
| status: jest.Mock; | ||
| json: jest.Mock; | ||
| }; | ||
| } | ||
|
|
||
| function mockReq(overrides: Partial<NextApiRequest> = {}): NextApiRequest { | ||
| return { | ||
| method: "POST", | ||
| headers: { authorization: "secret" }, | ||
| body: { repo: "pytorch/executorch", job_id: 999, conclusion: "failure" }, | ||
| ...overrides, | ||
| } as NextApiRequest; | ||
| } | ||
|
|
||
| describe("/api/log-uploader/backfill", () => { | ||
| let invoke: jest.SpyInstance; | ||
|
|
||
| beforeEach(() => { | ||
| invoke = jest.spyOn(lambda, "invokeLogUploader").mockResolvedValue(); | ||
| process.env.LOG_UPLOADER_BOT_KEY = "secret"; | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| jest.restoreAllMocks(); | ||
| delete process.env.LOG_UPLOADER_BOT_KEY; | ||
| }); | ||
|
|
||
| test("queues an upload for an authorized request", async () => { | ||
| const res = mockRes(); | ||
| await handler(mockReq(), res); | ||
|
|
||
| expect(invoke).toHaveBeenCalledWith({ | ||
| repo: "pytorch/executorch", | ||
| job_id: 999, | ||
| conclusion: "failure", | ||
| }); | ||
| expect(res.status).toHaveBeenCalledWith(200); | ||
| }); | ||
|
|
||
| test("accepts a job_id that arrived as a string", async () => { | ||
| const res = mockRes(); | ||
| await handler( | ||
| mockReq({ body: { repo: "pytorch/pytorch", job_id: "42" } }), | ||
| res | ||
| ); | ||
|
|
||
| expect(invoke).toHaveBeenCalledWith( | ||
| expect.objectContaining({ job_id: 42 }) | ||
| ); | ||
| }); | ||
|
|
||
| test("rejects a request with no credentials", async () => { | ||
| const res = mockRes(); | ||
| await handler(mockReq({ headers: {} }), res); | ||
|
|
||
| expect(invoke).not.toHaveBeenCalled(); | ||
| expect(res.status).toHaveBeenCalledWith(403); | ||
| }); | ||
|
|
||
| test("rejects a request with the wrong credentials", async () => { | ||
| const res = mockRes(); | ||
| await handler(mockReq({ headers: { authorization: "guess" } }), res); | ||
|
|
||
| expect(invoke).not.toHaveBeenCalled(); | ||
| expect(res.status).toHaveBeenCalledWith(403); | ||
| }); | ||
|
|
||
| test("an unset key does not become an open endpoint", async () => { | ||
| // Otherwise a missing env var would silently reproduce the unauthenticated | ||
| // API Gateway endpoint this route exists to replace. | ||
| delete process.env.LOG_UPLOADER_BOT_KEY; | ||
| const res = mockRes(); | ||
| await handler(mockReq({ headers: {} }), res); | ||
|
|
||
| expect(invoke).not.toHaveBeenCalled(); | ||
| expect(res.status).toHaveBeenCalledWith(403); | ||
| }); | ||
|
|
||
| test("rejects anything but POST", async () => { | ||
| const res = mockRes(); | ||
| await handler(mockReq({ method: "GET" }), res); | ||
|
|
||
| expect(invoke).not.toHaveBeenCalled(); | ||
| expect(res.status).toHaveBeenCalledWith(405); | ||
| }); | ||
|
|
||
| test.each([ | ||
| ["a missing repo", { job_id: 1 }], | ||
| ["a repo with no owner", { repo: "pytorch", job_id: 1 }], | ||
| ["a missing job_id", { repo: "pytorch/pytorch" }], | ||
| ["a non-numeric job_id", { repo: "pytorch/pytorch", job_id: "abc" }], | ||
| ["a negative job_id", { repo: "pytorch/pytorch", job_id: -1 }], | ||
| ])("rejects %s", async (_label, body) => { | ||
| const res = mockRes(); | ||
| await handler(mockReq({ body }), res); | ||
|
|
||
| expect(invoke).not.toHaveBeenCalled(); | ||
| expect(res.status).toHaveBeenCalledWith(400); | ||
| }); | ||
|
|
||
| test("an empty body does not throw", async () => { | ||
| const res = mockRes(); | ||
| await handler(mockReq({ body: undefined }), res); | ||
|
|
||
| expect(res.status).toHaveBeenCalledWith(400); | ||
| }); | ||
|
|
||
| test.each([ | ||
| ["credentials are missing", new lambda.MissingAwsCredentialsError()], | ||
| ["the role cannot invoke", new Error("AccessDeniedException")], | ||
| ["the Lambda API is unreachable", new Error("TimeoutError")], | ||
| ])("reports 502 rather than throwing when %s", async (_label, error) => { | ||
| invoke.mockRejectedValue(error); | ||
| jest.spyOn(console, "error").mockImplementation(() => {}); | ||
| const res = mockRes(); | ||
| await handler(mockReq(), res); | ||
|
|
||
| expect(res.status).toHaveBeenCalledWith(502); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
let's not forget about the manual setup, as otherwise the failures would be silent
🟡 Confirm the uploader's invoke grant is in place before this deploys — without it every Dr.CI backfill fails and nothing downstream sees it. (ai-generated section)
The uploader is reachable only through an IAM-authenticated invoke, and the grant for the principal behind the HUD's
OUR_AWS_ACCESS_KEY_IDis documented as one-time manual AWS setup rather than something the deploy performs. If it is not in place when this ships, the invoke throws on every call; the new catch logs the error and returnsfalse, and the sole caller ofbackfillMissingLogignores that value — so Dr.CI finishes normally and log backfill just stops.Reviewed by codex gpt-5.6-sol at xhigh effort, against 5099e87.