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.

159 lines · 9.2 KBRaw History
1import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
2import path from "node:path";
3import { afterEach, beforeEach, describe, expect, it } from "vitest";
4import { fakeServer, isolatedEnv, run, tempDir, type FakeServer } from "./helpers.js";
5
6/** A stand-in for containers/runner/dist/agent.mjs: records argv, env and cwd, then exits. */
7function fakeAgent(exitCode = 0): { agent: string; record: string } {
8 const dir = tempDir("diffinery-agent-");
9 const record = path.join(dir, "record.json");
10 const agent = path.join(dir, "agent.mjs");
11 writeFileSync(
12 agent,
13 `import { writeFileSync } from "node:fs";
14writeFileSync(${JSON.stringify(record)}, JSON.stringify({ argv: process.argv.slice(2), cwd: process.cwd(), env: process.env }));
15console.log("hello from the agent");
16console.error("agent stderr line");
17process.exit(${exitCode});
18`,
19 );
20 return { agent, record };
21}
22
23describe("runner", () => {
24 let srv: FakeServer;
25 let env: NodeJS.ProcessEnv;
26 beforeEach(async () => {
27 srv = await fakeServer();
28 env = isolatedEnv({ DIFFINERY_URL: srv.url, DIFFINERY_TOKEN: "dfy_user_token" });
29 });
30 afterEach(() => srv.close());
31
32 it("--once claims a job, runs the agent with the job's env, and returns its exit code", async () => {
33 let claims = 0;
34 srv.on("POST", "/api/v3/repos/acme/app/actions/runners/claim", () =>
35 ++claims === 1 ? { body: { job_id: "job42", run_id: "run7", name: "build (linux)", token: "djt_repo_job42_secret", api_url: srv.url } } : { status: 204 },
36 );
37 const { agent, record } = fakeAgent(3);
38 const work = tempDir("diffinery-work-");
39 const r = await run(["runner", "-R", "acme/app", "--once", "--agent", agent, "--labels", "gpu, linux", "--name", "box-1", "--work-dir", work], { env });
40
41 expect(r.code).toBe(3);
42 const claimReq = srv.find("POST", "/api/v3/repos/acme/app/actions/runners/claim");
43 expect(claimReq).toHaveLength(1);
44 expect(claimReq[0]!.body).toEqual({ name: "box-1", labels: ["gpu", "linux"] });
45 expect(claimReq[0]!.headers.authorization).toBe("Bearer dfy_user_token");
46
47 const rec = JSON.parse(readFileSync(record, "utf8")) as { argv: string[]; cwd: string; env: Record<string, string> };
48 expect(rec.argv).toEqual([]);
49 expect(rec.env.DIFFINERY_API_URL).toBe(srv.url);
50 expect(rec.env.DIFFINERY_JOB_ID).toBe("job42");
51 expect(rec.env.DIFFINERY_JOB_TOKEN).toBe("djt_repo_job42_secret");
52 expect(rec.env.DIFFINERY_WORK_ROOT).toBe(work);
53 expect(rec.env.DIFFINERY_TOKEN).toBeUndefined();
54 expect(rec.env.DIFFINERY_URL).toBeUndefined();
55
56 expect(r.stdout).toContain("[build (linux)] hello from the agent");
57 expect(r.stderr).toContain("[build (linux)] agent stderr line");
58 expect(r.stdout).not.toContain("djt_repo_job42_secret");
59 });
60
61 it("--once with no waiting job exits 0 without running the agent", async () => {
62 srv.on("POST", /\/runners\/claim$/, { status: 204 });
63 const { agent, record } = fakeAgent(0);
64 const r = await run(["runner", "-R", "acme/app", "-R", "acme/other", "--once", "--agent", agent], { env });
65 expect(r.code).toBe(0);
66 expect(r.stdout).toContain("No jobs waiting");
67 expect(existsSync(record)).toBe(false);
68 expect(srv.find("POST", /runners\/claim$/).map((q) => q.path)).toEqual(["/api/v3/repos/acme/app/actions/runners/claim", "/api/v3/repos/acme/other/actions/runners/claim"]);
69 });
70
71 it("uses DIFFINERY_RUNNER_AGENT and tries the next repository", async () => {
72 srv.on("POST", "/api/v3/repos/acme/app/actions/runners/claim", { status: 204 });
73 srv.on("POST", "/api/v3/repos/acme/other/actions/runners/claim", { body: { job_id: "j1", run_id: "r1", name: "test", token: "djt_x", api_url: srv.url } });
74 const { agent, record } = fakeAgent(0);
75 const r = await run(["runner", "-R", "acme/app", "-R", "acme/other", "--once", "--work-dir", tempDir()], { env: { ...env, DIFFINERY_RUNNER_AGENT: agent } });
76 expect(r.code).toBe(0);
77 expect(JSON.parse(readFileSync(record, "utf8")).env.DIFFINERY_JOB_ID).toBe("j1");
78 });
79
80 it("explains how to build the agent when none is configured", async () => {
81 const r = await run(["runner", "-R", "acme/app", "--once"], { env });
82 expect(r.code).toBe(1);
83 expect(r.stderr).toContain("pnpm --filter @diffinery/runner build");
84 expect(r.stderr).toContain("containers/runner/dist/agent.mjs");
85 });
86
87 it("fails fast on permission errors", async () => {
88 srv.on("POST", "/api/v3/repos/acme/app/actions/runners/claim", { status: 403, body: { message: "admin access required" } });
89 const { agent } = fakeAgent(0);
90 const r = await run(["runner", "-R", "acme/app", "--agent", agent, "--interval", "0"], { env });
91 expect(r.code).toBe(1);
92 expect(r.stderr).toContain("admin access required");
93 });
94
95 describe("registered runners", () => {
96 const job = { body: { job_id: "j9", run_id: "r9", name: "build", token: "djt_repo_j9_secret", api_url: "" } };
97 const registered = { status: 201, body: { id: 7, name: "box", labels: ["linux"], token: "drn_repo_7_runnersecret" } };
98
99 it("registers with a registration token, keeps no personal token, and claims with its own", async () => {
100 const bare = isolatedEnv({ DIFFINERY_URL: srv.url });
101 srv.on("POST", "/api/v3/repos/acme/app/actions/runners/register", registered);
102 let claims = 0;
103 srv.on("POST", "/api/v3/repos/acme/app/actions/runners/claim", () => (++claims === 1 ? job : { status: 204 }));
104 const { agent, record } = fakeAgent(0);
105 const r = await run(["runner", "-R", "acme/app", "--registration-token", "drg_abc", "--labels", "linux", "--name", "box", "--once", "--agent", agent, "--work-dir", tempDir()], { env: bare });
106 expect(r.code).toBe(0);
107 const reg = srv.find("POST", "/api/v3/repos/acme/app/actions/runners/register");
108 expect(reg[0]!.body).toEqual({ registration_token: "drg_abc", name: "box", labels: ["linux"], ephemeral: false });
109 expect(reg[0]!.headers.authorization).toBeUndefined();
110 const claimReq = srv.find("POST", "/api/v3/repos/acme/app/actions/runners/claim");
111 expect(claimReq[0]!.headers.authorization).toBe("Bearer drn_repo_7_runnersecret");
112 expect(claimReq[0]!.body).toEqual({});
113 // The job never sees the runner's token.
114 expect(JSON.stringify(JSON.parse(readFileSync(record, "utf8")).env)).not.toContain("drn_");
115 expect(r.stdout).toContain("Registered runner");
116 expect(r.stdout).not.toContain("drn_");
117 const file = path.join(bare.XDG_CONFIG_HOME ?? path.join(bare.HOME!, ".config"), "diffinery", "runners", srv.host.replace(":", "_"), "acme", "app", "box.json");
118 expect(JSON.parse(readFileSync(file, "utf8")).token).toBe("drn_repo_7_runnersecret");
119 expect(statSync(file).mode & 0o777).toBe(0o600);
120
121 // The next start uses the saved registration.
122 const again = await run(["runner", "-R", "acme/app", "--name", "box", "--once", "--agent", agent], { env: bare });
123 expect(again.code).toBe(0);
124 expect(srv.find("POST", "/api/v3/repos/acme/app/actions/runners/register")).toHaveLength(1);
125 expect(srv.find("POST", "/api/v3/repos/acme/app/actions/runners/claim").at(-1)!.headers.authorization).toBe("Bearer drn_repo_7_runnersecret");
126
127 // A removed runner says so.
128 srv.on("POST", "/api/v3/repos/acme/app/actions/runners/claim", { status: 401, body: { message: "this runner was removed" } });
129 const gone = await run(["runner", "-R", "acme/app", "--name", "box", "--agent", agent, "--interval", "0"], { env: bare });
130 expect(gone.code).toBe(1);
131 expect(gone.stderr).toContain("was removed from acme/app");
132 expect(gone.stderr).toContain(file);
133 });
134
135 it("signed in as an admin, registers itself and claims with the runner's token", async () => {
136 srv.on("POST", "/api/v3/repos/acme/app/actions/runners/registration-token", { status: 201, body: { token: "drg_auto", expires_at: "2099-01-01T00:00:00Z" } });
137 srv.on("POST", "/api/v3/repos/acme/app/actions/runners/register", registered);
138 srv.on("POST", "/api/v3/repos/acme/app/actions/runners/claim", { status: 204 });
139 const { agent } = fakeAgent(0);
140 const r = await run(["runner", "-R", "acme/app", "--name", "box", "--once", "--agent", agent], { env });
141 expect(r.code).toBe(0);
142 expect(srv.find("POST", /registration-token$/)[0]!.headers.authorization).toBe("Bearer dfy_user_token");
143 expect(srv.find("POST", /register$/)[0]!.body).toMatchObject({ registration_token: "drg_auto" });
144 expect(srv.find("POST", /claim$/)[0]!.headers.authorization).toBe("Bearer drn_repo_7_runnersecret");
145 });
146
147 it("--ephemeral takes one job, saves nothing and exits with its code", async () => {
148 srv.on("POST", "/api/v3/repos/acme/app/actions/runners/register", registered);
149 srv.on("POST", "/api/v3/repos/acme/app/actions/runners/claim", job);
150 const { agent } = fakeAgent(4);
151 const bare = isolatedEnv({ DIFFINERY_URL: srv.url });
152 const r = await run(["runner", "-R", "acme/app", "--registration-token", "drg_abc", "--ephemeral", "--name", "box", "--agent", agent, "--work-dir", tempDir()], { env: bare });
153 expect(r.code).toBe(4);
154 expect(srv.find("POST", /register$/)[0]!.body).toMatchObject({ ephemeral: true });
155 expect(srv.find("POST", /claim$/)).toHaveLength(1);
156 expect(r.stdout).not.toContain("Registered runner");
157 });
158 });
159});