Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions aws/lambda/clickhouse-replicator-dynamo/lambda_function.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}


Expand Down
6 changes: 6 additions & 0 deletions torchci/pages/[repoOwner]/[repoName]/pull/[prNumber].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -122,6 +123,11 @@ function Page() {
/>
)}
</ErrorBoundary>
<ErrorBoundary>
{prNumber && repoOwner === "pytorch" && repoName === "pytorch" && (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we building it exclusive to pytorch/pytorch? It should apply to any repo, shouldn't it?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but for now we are focusing on pytorch/pytorch repo as confirmed by Alban in the "Sync on Cross-Repository CI Relay for PyTorch Out-of-Tree Backends" call on Tuesday,

<OotPrSection prNumber={parseInt(prNumber as string)} />
)}
</ErrorBoundary>
</div>
);
}
Expand Down
67 changes: 67 additions & 0 deletions torchci/pages/api/oot/results.ts
Original file line number Diff line number Diff line change
@@ -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" });
}
}
167 changes: 167 additions & 0 deletions torchci/test/ootResults.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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);
});
});
Loading