Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

222 lines · 10.0 KBRaw History
1import { mkdtempSync } from "node:fs";
2import { createServer, type IncomingHttpHeaders, type Server, type ServerResponse } from "node:http";
3import type { AddressInfo } from "node:net";
4import { tmpdir } from "node:os";
5import path from "node:path";
6import type { JobCompletion, JobSpec, LogLine, StepReport, StepSpec } from "@diffinery/shared";
7
8export interface FakeApi {
9 url: string;
10 token: string;
11 logs: LogLine[];
12 steps: Array<{ index: number; report: StepReport }>;
13 completion: JobCompletion | null;
14 /** Runtime token the fake results service accepts (run "1", job "job-1"). */
15 runtimeToken: string;
16 /** Cache entries by key (version in `cacheVersions`). */
17 cache: Map<string, Buffer>;
18 cacheVersions: Map<string, string>;
19 /** Finalized artifacts of the run, by name. */
20 artifacts: Map<string, { id: number; body: Buffer; mimeType: string; hash?: string }>;
21 requests: Array<{ method: string; path: string; auth: string | undefined }>;
22 /** Fail the next N log POSTs with 503 (to test retries). */
23 failLogs: number;
24 specs: Map<string, JobSpec>;
25 close(): Promise<void>;
26 logText(step?: number): string;
27 lastReport(index: number): StepReport | undefined;
28}
29
30export async function startFakeApi(token = "job-token-123"): Promise<FakeApi> {
31 const api: FakeApi = {
32 url: "",
33 token,
34 logs: [],
35 steps: [],
36 completion: null,
37 runtimeToken: fakeRuntimeToken(),
38 cache: new Map(),
39 cacheVersions: new Map(),
40 artifacts: new Map(),
41 requests: [],
42 failLogs: 0,
43 specs: new Map(),
44 close: async () => {},
45 logText(step?: number) {
46 return this.logs
47 .filter((l) => step === undefined || l.step === step)
48 .map((l) => l.text)
49 .join("\n");
50 },
51 lastReport(index: number) {
52 return [...this.steps].reverse().find((s) => s.index === index)?.report;
53 },
54 };
55 // A minimal Actions results service: the Twirp cache/artifact services and their blob URLs.
56 const pending = new Map<string, { kind: "cache" | "artifact"; name: string; version?: string; mimeType?: string; body?: Buffer }>();
57 let nextId = 1;
58 const runtime = (method: string, u: URL, headers: IncomingHttpHeaders, body: Buffer, res: ServerResponse, json: (s: number, v: unknown) => void) => {
59 const blob = /^\/_runtime\/blob\/([^/]+)$/.exec(u.pathname);
60 if (blob) {
61 const id = decodeURIComponent(blob[1]!);
62 if (method === "PUT") {
63 const p = pending.get(id);
64 if (!p || headers["x-ms-blob-type"] !== "BlockBlob") return json(400, {});
65 p.body = body;
66 res.writeHead(201);
67 return res.end();
68 }
69 const art = [...api.artifacts.values()].find((a) => String(a.id) === id);
70 const b = art?.body ?? api.cache.get(id);
71 if (!b) return json(404, {});
72 res.writeHead(200, { "content-type": art?.mimeType ?? "application/octet-stream" });
73 return res.end(b);
74 }
75 if (headers.authorization !== `Bearer ${api.runtimeToken}`) return json(401, { code: "unauthenticated", msg: "bad token" });
76 const req = JSON.parse(body.toString() || "{}") as Record<string, string | string[] | undefined>;
77 const blobUrl = (id: string) => `${api.url}/_runtime/blob/${encodeURIComponent(id)}?sig=x`;
78 switch (u.pathname.replace("/twirp/github.actions.results.api.v1.", "")) {
79 case "CacheService/GetCacheEntryDownloadURL": {
80 const keys = [...api.cache.keys()].filter((k) => api.cacheVersions.get(k) === req.version);
81 const hit = keys.find((k) => k === req.key) ?? ((req.restore_keys ?? []) as string[]).map((p) => keys.find((k) => k.startsWith(p))).find(Boolean);
82 return json(200, hit ? { ok: true, matched_key: hit, signed_download_url: blobUrl(hit) } : { ok: false });
83 }
84 case "CacheService/CreateCacheEntry": {
85 if (api.cache.has(String(req.key))) return json(409, { code: "already_exists", msg: "cache entry already exists" });
86 const id = `upload-${nextId++}`;
87 pending.set(id, { kind: "cache", name: String(req.key), version: String(req.version) });
88 return json(200, { ok: true, signed_upload_url: blobUrl(id) });
89 }
90 case "CacheService/FinalizeCacheEntryUpload": {
91 const [id, p] = [...pending].find(([, p]) => p.kind === "cache" && p.name === req.key) ?? [];
92 if (!id || !p?.body) return json(404, { code: "not_found", msg: "no upload" });
93 pending.delete(id);
94 api.cache.set(p.name, p.body);
95 api.cacheVersions.set(p.name, p.version!);
96 return json(200, { ok: true, entry_id: String(nextId++) });
97 }
98 case "ArtifactService/CreateArtifact": {
99 if (api.artifacts.has(String(req.name))) return json(409, { code: "already_exists", msg: "artifact exists" });
100 const id = `upload-${nextId++}`;
101 pending.set(id, { kind: "artifact", name: String(req.name), mimeType: String(req.mime_type ?? "application/zip") });
102 return json(200, { ok: true, signed_upload_url: blobUrl(id) });
103 }
104 case "ArtifactService/FinalizeArtifact": {
105 const [id, p] = [...pending].find(([, p]) => p.kind === "artifact" && p.name === req.name) ?? [];
106 if (!id || !p?.body) return json(404, { code: "not_found", msg: "no upload" });
107 pending.delete(id);
108 const artifactId = nextId++;
109 api.artifacts.set(p.name, { id: artifactId, body: p.body, mimeType: p.mimeType!, hash: req.hash as string | undefined });
110 return json(200, { ok: true, artifact_id: String(artifactId) });
111 }
112 case "ArtifactService/ListArtifacts":
113 return json(200, {
114 artifacts: [...api.artifacts]
115 .filter(([n]) => !req.name_filter || n === req.name_filter)
116 .map(([name, a]) => ({
117 workflow_run_backend_id: "1",
118 workflow_job_run_backend_id: "job-1",
119 database_id: String(a.id),
120 name,
121 size: String(a.body.length),
122 digest: a.hash?.replace(/^sha256:/, ""),
123 created_at: new Date().toISOString(),
124 })),
125 });
126 case "ArtifactService/GetSignedArtifactURL": {
127 const a = api.artifacts.get(String(req.name));
128 return a ? json(200, { signed_url: blobUrl(String(a.id)) }) : json(404, { code: "not_found", msg: "no artifact" });
129 }
130 case "ArtifactService/DeleteArtifact": {
131 const a = api.artifacts.get(String(req.name));
132 if (!a) return json(404, { code: "not_found", msg: "no artifact" });
133 api.artifacts.delete(String(req.name));
134 return json(200, { ok: true, artifact_id: String(a.id) });
135 }
136 }
137 return json(404, { code: "bad_route", msg: "no such method" });
138 };
139 const server: Server = createServer(async (req, res) => {
140 const chunks: Buffer[] = [];
141 for await (const c of req) chunks.push(c as Buffer);
142 const body = Buffer.concat(chunks);
143 const u = new URL(req.url ?? "/", "http://x");
144 api.requests.push({ method: req.method!, path: u.pathname + u.search, auth: req.headers.authorization });
145 const json = (status: number, v: unknown) => {
146 res.writeHead(status, { "content-type": "application/json" });
147 res.end(JSON.stringify(v));
148 };
149 if (u.pathname.startsWith("/twirp/") || u.pathname.startsWith("/_runtime/blob/")) return runtime(req.method!, u, req.headers, body, res, json);
150 if (req.headers.authorization !== `Bearer ${token}`) return json(401, { error: "unauthorized" });
151 const m = /^\/runner\/jobs\/([^/]+)(\/.*)?$/.exec(u.pathname);
152 if (!m) return json(404, {});
153 const sub = m[2] ?? "";
154 if (sub === "" && req.method === "GET") {
155 const s = api.specs.get(decodeURIComponent(m[1]!));
156 return s ? json(200, s) : json(404, {});
157 }
158 if (sub === "/logs" && req.method === "POST") {
159 if (api.failLogs > 0) {
160 api.failLogs--;
161 return json(503, { error: "try later" });
162 }
163 api.logs.push(...(JSON.parse(body.toString()) as { lines: LogLine[] }).lines);
164 return json(200, {});
165 }
166 const sm = /^\/steps\/(\d+)$/.exec(sub);
167 if (sm && req.method === "POST") {
168 api.steps.push({ index: Number(sm[1]), report: JSON.parse(body.toString()) as StepReport });
169 return json(200, {});
170 }
171 if (sub === "/complete" && req.method === "POST") {
172 api.completion = JSON.parse(body.toString()) as JobCompletion;
173 return json(200, {});
174 }
175 if (sub === "/oidc") return json(200, { value: `oidc-for-${u.searchParams.get("audience")}` });
176 return json(404, {});
177 });
178 await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
179 api.url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
180 api.close = () => new Promise<void>((r) => server.close(() => r()));
181 return api;
182}
183
184/** A runtime token shaped like the server's: its scope names run "1" and job "job-1". */
185function fakeRuntimeToken(): string {
186 const b64 = (v: unknown) => Buffer.from(JSON.stringify(v)).toString("base64url");
187 return `${b64({ alg: "HS256", typ: "JWT" })}.${b64({ scp: "Actions.GenericRead:1 Actions.Results:1:job-1", exp: Math.floor(Date.now() / 1000) + 3600 })}.sig`;
188}
189
190export function tmp(prefix = "runner-test-"): string {
191 return mkdtempSync(path.join(tmpdir(), prefix));
192}
193
194export function makeSpec(api: FakeApi, steps: StepSpec[], over: Partial<JobSpec> = {}): JobSpec {
195 return {
196 runId: "run-1",
197 jobId: "job-1",
198 jobKey: "build",
199 name: "build",
200 repo: { owner: "acme", name: "widgets", cloneUrl: "http://invalid.test/acme/widgets.git", defaultBranch: "main" },
201 sha: "0123456789abcdef0123456789abcdef01234567",
202 ref: "refs/heads/main",
203 workflowPath: ".github/workflows/ci.yml",
204 github: { event_name: "push", actor: "octo", event: { ref: "refs/heads/main", pusher: { name: "octo" } } },
205 env: {},
206 matrix: {},
207 strategy: { "fail-fast": true, "job-index": 0, "job-total": 1 },
208 needs: {},
209 inputs: {},
210 vars: {},
211 secrets: {},
212 outputs: {},
213 steps,
214 timeoutMinutes: 10,
215 continueOnError: false,
216 idToken: false,
217 apiUrl: api.url,
218 token: api.token,
219 runtime: { token: api.runtimeToken, resultsUrl: `${api.url}/`, cacheUrl: `${api.url}/_runtime/`, runtimeUrl: `${api.url}/_runtime/` },
220 ...over,
221 };
222}