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.

175 lines · 8.5 KBRaw History
1import { Hono } from "hono";
2import type { JobCompletion, LogLine, StepReport } from "@diffinery/shared";
3import type { Env } from "../env.js";
4import type { AppContext, AppVars } from "../auth/principal.js";
5import { credentialFrom } from "../auth/principal.js";
6import { signEs256 } from "../lib/jwt.js";
7import { BodyTooLarge, putStream, sized, type BlobBucket } from "../lib/blob-store.js";
8import { getFirst, headFirst } from "../lib/storage-keys.js";
9import { HttpError } from "../lib/util.js";
10import { parseRunnerInfo } from "../repo/actions.js";
11import { CACHE_LIMIT_BYTES } from "../repo/runtime.js";
12import { repoStub } from "./common.js";
13
14/** Endpoints the runner agent inside a job container calls. Auth: the job token. */
15export const runner = new Hono<{ Bindings: Env; Variables: AppVars }>();
16
17/** The job token's shape only: which repository to ask. Every handler must still have the repository verify the token. */
18function jobAuth(req: Request, jobId: string): { token: string; repoId: string } {
19 const token = credentialFrom(req);
20 if (!token?.startsWith("djt_")) throw new HttpError(401, "job token required");
21 const [, repoId, tokJob] = token.split("_");
22 if (!repoId || tokJob !== jobId) throw new HttpError(401, "token does not match job");
23 return { token, repoId };
24}
25
26/** Stores a request body as it streams in, refusing more than `max` bytes (older runners' cache and artifact uploads). */
27async function storeBody(c: AppContext, key: string, max: number): Promise<number> {
28 const body = c.req.raw.body;
29 if (!body) throw new HttpError(400, "empty body");
30 const options = { httpMetadata: { contentType: "application/gzip" } };
31 const len = c.req.header("content-length");
32 if (len !== undefined && /^\d{1,15}$/.test(len)) {
33 if (Number(len) > max) throw new HttpError(413, `uploads are limited to ${max} bytes`);
34 return (await c.env.BUCKET.put(key, sized(body, Number(len)), options)).size;
35 }
36 try {
37 return await putStream(c.env.BUCKET as unknown as BlobBucket, key, body, max, options);
38 } catch (e) {
39 if (e instanceof BodyTooLarge) throw new HttpError(413, `uploads are limited to ${max} bytes`);
40 throw e;
41 }
42}
43
44runner.get("/runner/jobs/:jobId", async (c) => {
45 const jobId = c.req.param("jobId");
46 const { token, repoId } = jobAuth(c.req.raw, jobId);
47 // This Worker's runner image, not the repository object's: after a deploy the object can run the previous version for minutes.
48 return c.json(await repoStub(c.env, repoId).runnerSpec(jobId, token, parseRunnerInfo(c.req.header("x-diffinery-runner")), c.env.RUNNER_IMAGE ?? null));
49});
50
51runner.post("/runner/jobs/:jobId/logs", async (c) => {
52 const jobId = c.req.param("jobId");
53 const { token, repoId } = jobAuth(c.req.raw, jobId);
54 const { lines } = (await c.req.json()) as { lines: LogLine[] };
55 await repoStub(c.env, repoId).runnerLogs(jobId, token, lines ?? []);
56 return c.json({ ok: true });
57});
58
59runner.post("/runner/jobs/:jobId/steps/:index", async (c) => {
60 const jobId = c.req.param("jobId");
61 const { token, repoId } = jobAuth(c.req.raw, jobId);
62 await repoStub(c.env, repoId).runnerStep(jobId, token, Number(c.req.param("index")), (await c.req.json()) as StepReport);
63 return c.json({ ok: true });
64});
65
66runner.post("/runner/jobs/:jobId/complete", async (c) => {
67 const jobId = c.req.param("jobId");
68 const { token, repoId } = jobAuth(c.req.raw, jobId);
69 await repoStub(c.env, repoId).runnerComplete(jobId, token, (await c.req.json()) as JobCompletion, c.env.RUNNER_IMAGE ?? null);
70 return c.json({ ok: true });
71});
72
73// ---- cache (actions/cache compatible semantics, backed by R2)
74
75runner.get("/runner/jobs/:jobId/cache", async (c) => {
76 const jobId = c.req.param("jobId");
77 const { token, repoId } = jobAuth(c.req.raw, jobId);
78 const key = c.req.query("key") ?? "";
79 const restore = (c.req.query("restore") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
80 const hit = await repoStub(c.env, repoId).cacheLookup(jobId, token, key, restore);
81 if (!hit) return c.json({ message: "cache miss" }, 404);
82 return c.json({ key: hit.key, url: `${new URL(c.req.url).origin}/runner/jobs/${jobId}/cache/blob/${encodeURIComponent(hit.stored)}` });
83});
84
85runner.get("/runner/jobs/:jobId/cache/blob/:key", async (c) => {
86 const jobId = c.req.param("jobId");
87 const { token, repoId } = jobAuth(c.req.raw, jobId);
88 // Only entries of a scope the job may restore from (its ref, the PR base, the default branch).
89 const keys = await repoStub(c.env, repoId).cacheReadKeys(jobId, token, c.req.param("key"));
90 const obj = keys && (await getFirst(c.env.BUCKET, keys));
91 if (!obj) throw new HttpError(404, "cache entry missing");
92 return new Response(obj.body, { headers: { "content-type": "application/gzip", "content-length": String(obj.size) } });
93});
94
95runner.put("/runner/jobs/:jobId/cache/:key", async (c) => {
96 const jobId = c.req.param("jobId");
97 const { token, repoId } = jobAuth(c.req.raw, jobId);
98 const key = c.req.param("key");
99 if (key.length > 512) throw new HttpError(422, "cache key too long");
100 const stub = repoStub(c.env, repoId);
101 const { r2Keys: keys } = await stub.cacheSaveKeys(jobId, token, key);
102 if (await headFirst(c.env.BUCKET, keys)) return c.json({ message: "cache entry already exists" }, 409);
103 const size = await storeBody(c, keys[0]!, CACHE_LIMIT_BYTES);
104 const evicted = await stub.cacheSaved(jobId, token, key, size);
105 if (evicted.length) await c.env.BUCKET.delete(evicted);
106 return c.json({ ok: true });
107});
108
109// ---- artifacts
110
111runner.put("/runner/jobs/:jobId/artifacts/:name", async (c) => {
112 const jobId = c.req.param("jobId");
113 const { token, repoId } = jobAuth(c.req.raw, jobId);
114 const stub = repoStub(c.env, repoId);
115 // Authenticates the job before anything is written: jobAuth only reads the token's shape.
116 // The artifact is recorded with the artifact service's (numeric ids, retention, the REST API)
117 // but stored at its legacy key; `room` is what is left of the repository's artifact quota.
118 const { r2Keys: keys, room } = await stub.artifactUploadKeys(jobId, token, c.req.param("name"));
119 if (room === 0) throw new HttpError(413, "the repository's artifact storage quota is used up; delete artifacts or wait for them to expire");
120 const size = await storeBody(c, keys[0]!, room);
121 await stub.artifactSaved(jobId, token, c.req.param("name"), size);
122 return c.json({ ok: true });
123});
124
125runner.get("/runner/jobs/:jobId/artifacts/:name", async (c) => {
126 const jobId = c.req.param("jobId");
127 const { token, repoId } = jobAuth(c.req.raw, jobId);
128 const obj = await getFirst(c.env.BUCKET, await repoStub(c.env, repoId).artifactKeysFor(jobId, token, c.req.param("name")));
129 if (!obj) throw new HttpError(404, "artifact not found");
130 return new Response(obj.body, { headers: { "content-type": "application/gzip" } });
131});
132
133// ---- OIDC id tokens (actions/core getIDToken compatible: GET ?audience= → { value })
134
135const oidc = async (c: AppContext) => {
136 const jobId = c.req.param("jobId")!;
137 const { token, repoId } = jobAuth(c.req.raw, jobId);
138 if (!c.env.OIDC_PRIVATE_KEY) throw new HttpError(501, "OIDC is not configured on this Diffinery instance");
139 const claims = await repoStub(c.env, repoId).oidcClaims(jobId, token);
140 if (!claims.allowed) throw new HttpError(403, "this job lacks permissions: id-token: write");
141 const g = claims.github;
142 const nowS = Math.floor(Date.now() / 1000);
143 const value = await signEs256(JSON.parse(c.env.OIDC_PRIVATE_KEY), {
144 iss: c.env.PUBLIC_URL,
145 aud: c.req.query("audience") ?? c.env.PUBLIC_URL,
146 // GitHub's forms: a job in an environment, a pull request, or a branch or tag.
147 sub: g.environment ? `repo:${g.repository}:environment:${g.environment}` : g.event_name === "pull_request" ? `repo:${g.repository}:pull_request` : `repo:${g.repository}:ref:${g.ref}`,
148 iat: nowS,
149 nbf: nowS,
150 exp: nowS + 600,
151 repository: g.repository,
152 repository_id: g.repository_id,
153 repository_owner: g.repository_owner,
154 repository_owner_id: g.repository_owner_id,
155 actor_id: g.actor_id,
156 environment: g.environment,
157 // Diffinery's own claim: whether the code that runs came from an agent.
158 agent_origin: g.agent_origin === "true",
159 ref_type: g.ref_type,
160 head_ref: g.head_ref || undefined,
161 base_ref: g.base_ref || undefined,
162 run_number: g.run_number,
163 run_attempt: g.run_attempt,
164 ref: g.ref,
165 sha: g.sha,
166 workflow: g.workflow,
167 event_name: g.event_name,
168 run_id: g.run_id,
169 actor: g.actor,
170 job_workflow_ref: g.workflow_ref,
171 });
172 return c.json({ value });
173};
174runner.get("/runner/jobs/:jobId/oidc", oidc);
175runner.post("/runner/jobs/:jobId/oidc", oidc);