diff --git a/aws/lambda/clickhouse-replicator-dynamo/lambda_function.py b/aws/lambda/clickhouse-replicator-dynamo/lambda_function.py index 792c2da148..37fc0bb755 100644 --- a/aws/lambda/clickhouse-replicator-dynamo/lambda_function.py +++ b/aws/lambda/clickhouse-replicator-dynamo/lambda_function.py @@ -33,6 +33,7 @@ "vllm-buildkite-agent-events": "vllm.vllm_buildkite_agents", "vllm-buildkite-build-events": "vllm.vllm_buildkite_builds", "vllm-buildkite-job-events": "vllm.vllm_buildkite_jobs", + "torchci-oot-workflow-job": "default.oot_workflow_job", } diff --git a/torchci/pages/[repoOwner]/[repoName]/pull/[prNumber].tsx b/torchci/pages/[repoOwner]/[repoName]/pull/[prNumber].tsx index b1830bcd15..79dfcf08ef 100644 --- a/torchci/pages/[repoOwner]/[repoName]/pull/[prNumber].tsx +++ b/torchci/pages/[repoOwner]/[repoName]/pull/[prNumber].tsx @@ -3,6 +3,7 @@ import { CommitInfo } from "components/commit/CommitInfo"; import DrCIButton from "components/common/DrCIButton"; import ErrorBoundary from "components/common/ErrorBoundary"; import { useSetTitle } from "components/layout/DynamicTitle"; +import OotPrSection from "components/oot/OotPrSection"; import { fetcher } from "lib/GeneralUtils"; import { PRData } from "lib/types"; import { useRouter } from "next/router"; @@ -122,6 +123,11 @@ function Page() { /> )} + + {prNumber && repoOwner === "pytorch" && repoName === "pytorch" && ( + + )} + ); } diff --git a/torchci/pages/api/oot/results.ts b/torchci/pages/api/oot/results.ts new file mode 100644 index 0000000000..8bcfa6f62b --- /dev/null +++ b/torchci/pages/api/oot/results.ts @@ -0,0 +1,67 @@ +import { timingSafeEqual } from "crypto"; +import { + ApiError, + extractDynamoRecord, + validatePayloadSize, + writeToDynamo, +} from "lib/oot/ootUtils"; +import type { NextApiRequest, NextApiResponse } from "next"; + +export const config = { + api: { + bodyParser: { + sizeLimit: "2mb", + }, + }, +}; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== "POST") { + return res.status(405).json({ error: "Method not allowed" }); + } + + try { + // 1. Auth: dedicated X-OOT-Relay-Token header (timing-safe comparison) + const expected = process.env.OOT_RELAY_TOKEN; + if (!expected) { + return res.status(500).json({ error: "Server misconfigured" }); + } + const raw = req.headers["x-oot-relay-token"]; + if (typeof raw !== "string") { + return res.status(401).json({ error: "Unauthorized" }); + } + const a = new Uint8Array(Buffer.from(raw)); + const b = new Uint8Array(Buffer.from(expected)); + if (a.length !== b.length || !timingSafeEqual(a, b)) { + return res.status(401).json({ error: "Unauthorized" }); + } + + // 2. Payload size cap (safety net — relay should also enforce this) + const rawBody = + typeof req.body === "string" ? req.body : JSON.stringify(req.body); + validatePayloadSize(rawBody); + + // 3. Extract and write to DynamoDB via UpdateItem + // Schema validation is done by the relay before forwarding. + const body = typeof req.body === "string" ? JSON.parse(req.body) : req.body; + const record = extractDynamoRecord(body); + await writeToDynamo(record); + + return res.status(200).json({ + ok: true, + status: record.status, + dynamoKey: record.dynamoKey, + }); + } catch (err: any) { + if (err instanceof ApiError) { + return res.status(err.statusCode).json({ error: err.message }); + } + console.error("OOT results handler error:", err); + return res + .status(500) + .json({ error: "Internal error writing to DynamoDB" }); + } +} diff --git a/torchci/test/ootResults.test.ts b/torchci/test/ootResults.test.ts new file mode 100644 index 0000000000..968277aaf5 --- /dev/null +++ b/torchci/test/ootResults.test.ts @@ -0,0 +1,167 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import * as ootUtils from "../lib/oot/ootUtils"; +import handler from "../pages/api/oot/results"; + +jest.mock("../lib/oot/ootUtils", () => { + const actual = jest.requireActual("../lib/oot/ootUtils"); + return { + ...actual, + writeToDynamo: jest.fn().mockResolvedValue(undefined), + }; +}); + +const VALID_TOKEN = "test-relay-token-abc123"; + +function mockReq(overrides: Partial = {}): NextApiRequest { + return { + method: "POST", + headers: { "x-oot-relay-token": VALID_TOKEN }, + body: { + trusted: { + verified_repo: "Ascend/pytorch", + downstream_repo_level: "L2", + ci_metrics: { queue_time: 5.0, execution_time: null }, + }, + untrusted: { + callback_payload: { + event_type: "workflow_job", + delivery_id: "del-001", + payload: { + pull_request: { number: 100, head: { sha: "abc123" } }, + repository: { full_name: "pytorch/pytorch" }, + }, + workflow: { + status: "in_progress", + name: "npu-ci", + url: "https://github.com/Ascend/pytorch/actions/runs/1", + job_name: "build", + check_run_id: "9001", + run_id: "555", + run_attempt: 1, + started_at: "2026-05-20T10:00:00Z", + }, + }, + }, + }, + ...overrides, + } as unknown as NextApiRequest; +} + +function mockRes(): NextApiResponse & { _status: number; _json: any } { + const res: any = { + _status: 0, + _json: null, + status(code: number) { + res._status = code; + return res; + }, + json(data: any) { + res._json = data; + return res; + }, + }; + return res; +} + +describe("POST /api/oot/results", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv, OOT_RELAY_TOKEN: VALID_TOKEN }; + jest.clearAllMocks(); + }); + + afterAll(() => { + process.env = originalEnv; + }); + + test("rejects non-POST methods with 405", async () => { + const res = mockRes(); + await handler(mockReq({ method: "GET" }), res); + expect(res._status).toBe(405); + expect(res._json.error).toBe("Method not allowed"); + }); + + test("returns 500 when OOT_RELAY_TOKEN env is not set", async () => { + delete process.env.OOT_RELAY_TOKEN; + const res = mockRes(); + await handler(mockReq(), res); + expect(res._status).toBe(500); + expect(res._json.error).toBe("Server misconfigured"); + }); + + test("returns 401 when token header is missing", async () => { + const res = mockRes(); + await handler(mockReq({ headers: {} }), res); + expect(res._status).toBe(401); + expect(res._json.error).toBe("Unauthorized"); + }); + + test("returns 401 when token header is wrong", async () => { + const res = mockRes(); + await handler( + mockReq({ headers: { "x-oot-relay-token": "wrong-token" } }), + res + ); + expect(res._status).toBe(401); + expect(res._json.error).toBe("Unauthorized"); + }); + + test("returns 401 when token has different length", async () => { + const res = mockRes(); + await handler(mockReq({ headers: { "x-oot-relay-token": "short" } }), res); + expect(res._status).toBe(401); + expect(res._json.error).toBe("Unauthorized"); + }); + + test("returns 200 and writes to DynamoDB on valid request", async () => { + const res = mockRes(); + await handler(mockReq(), res); + expect(res._status).toBe(200); + expect(res._json.ok).toBe(true); + expect(res._json.status).toBe("in_progress"); + expect(res._json.dynamoKey).toContain("Ascend/pytorch"); + expect(ootUtils.writeToDynamo).toHaveBeenCalledTimes(1); + }); + + test("returns 400 when required fields are missing", async () => { + const body = { + trusted: { verified_repo: "test/repo" }, + untrusted: { + callback_payload: { + event_type: "workflow_job", + delivery_id: "del-002", + payload: {}, + workflow: { + status: "in_progress", + name: "ci", + url: "https://example.com", + // job_name intentionally missing + }, + }, + }, + }; + const res = mockRes(); + await handler(mockReq({ body }), res); + expect(res._status).toBe(400); + expect(res._json.error).toContain("job_name"); + }); + + test("returns 500 when DynamoDB write fails", async () => { + (ootUtils.writeToDynamo as jest.Mock).mockRejectedValueOnce( + new Error("DynamoDB connection failed") + ); + const res = mockRes(); + await handler(mockReq(), res); + expect(res._status).toBe(500); + expect(res._json.error).toContain("Internal error"); + }); + + test("handles string body by parsing JSON", async () => { + const body = JSON.stringify(mockReq().body); + const res = mockRes(); + await handler(mockReq({ body }), res); + expect(res._status).toBe(200); + expect(res._json.ok).toBe(true); + }); +});