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.

96 lines · 4.8 KBRaw History
1import { spawnSync } from "node:child_process";
2import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3import path from "node:path";
4import { fileURLToPath } from "node:url";
5import { afterEach, beforeEach, describe, expect, it } from "vitest";
6import { apiBaseFor, runAgentJob } from "../src/agent.js";
7import { makeSpec, startFakeApi, tmp, type FakeApi } from "./helpers.js";
8
9// The hosted runner image's docker wrapper (image/docker), run as it is apart from its fixed paths,
10// against a stand-in daemon that takes a moment to come up and then keeps running, like dockerd.
11const WRAPPER = path.join(path.dirname(fileURLToPath(import.meta.url)), "../image/docker");
12const tools = ["flock", "setsid"].every((t) => spawnSync("sh", ["-c", `command -v ${t}`]).status === 0);
13
14let api: FakeApi;
15let dir: string;
16
17/** Writes the wrapper to `dir/bin/docker`, with its paths moved into `dir`, and the stand-ins next to it. */
18function install(): string {
19 const bin = path.join(dir, "bin");
20 const sh = (name: string, body: string) => {
21 writeFileSync(path.join(bin, name), `#!/bin/bash\n${body}\n`);
22 chmodSync(path.join(bin, name), 0o755);
23 };
24 spawnSync("mkdir", ["-p", bin, path.join(dir, "log")]);
25 let script = readFileSync(WRAPPER, "utf8");
26 const moves: Array<[string, string]> = [
27 ["real=/usr/bin/docker", `real=${bin}/docker-real`],
28 ["ready=/run/diffinery-dockerd.ready", `ready=${dir}/dockerd.ready`],
29 ["state=/run/diffinery\n", `state=${dir}/state\n`],
30 ["/run/diffinery-dockerd.lock", `${dir}/dockerd.lock`],
31 ["/var/log/", `${dir}/log/`],
32 ["sys_path=/usr/local/sbin:", `sys_path=${bin}:/usr/local/sbin:`],
33 ['"$(id -u)" = 0', "0 = 0"],
34 ];
35 for (const [from, to] of moves) {
36 expect(script, `the wrapper no longer has ${JSON.stringify(from)}`).toContain(from);
37 script = script.split(from).join(to);
38 }
39 writeFileSync(path.join(bin, "docker"), script);
40 chmodSync(path.join(bin, "docker"), 0o755);
41 // The daemon answers a second after it starts and keeps running (holding whatever it inherited).
42 sh("dockerd", `echo $$ >> ${dir}/dockerd.pids\nsleep 1\ntouch ${dir}/dockerd.up\nexec sleep 60`);
43 sh("docker-real", `[ -e ${dir}/dockerd.up ] || exit 1\necho "docker $*"`);
44 return bin;
45}
46
47beforeEach(async () => {
48 api = await startFakeApi();
49 dir = tmp("docker-wrapper-");
50});
51afterEach(async () => {
52 await api.close();
53 // Stand-in daemons started with setsid outlive the job, like the real one.
54 const pids = path.join(dir, "dockerd.pids");
55 if (existsSync(pids)) spawnSync("kill", readFileSync(pids, "utf8").split("\n").filter(Boolean));
56 rmSync(dir, { recursive: true, force: true });
57});
58
59describe.skipIf(!tools)("docker wrapper", () => {
60 it("lets docker commands that arrive while the daemon is starting through, with the head start on", async () => {
61 const bin = install();
62 // The head start runs `docker version` as the job begins; the step's commands arrive while it is
63 // still starting the daemon, and must not wait on its lock for good.
64 const spec = makeSpec(api, [{ run: "docker info & docker ps & wait\ndocker version" }], { timeoutMinutes: 1 });
65 const started = Date.now();
66 const done = await runAgentJob(spec, {
67 apiBase: apiBaseFor(spec.apiUrl, spec.jobId),
68 workRoot: path.join(dir, "work"),
69 toolCache: path.join(dir, "tool"),
70 logIntervalMs: 50,
71 echoLogs: false,
72 prewarmDocker: true,
73 baseEnv: { ...process.env, PATH: `${bin}:${process.env.PATH}` },
74 });
75 expect(done.conclusion, api.logText()).toBe("success");
76 expect(Date.now() - started).toBeLessThan(15_000);
77 expect(api.logText()).toContain("docker info");
78 expect(api.logText()).toContain("docker ps");
79 expect(api.logText()).toContain("docker version");
80 expect(readFileSync(path.join(dir, "log", "dockerd.log"), "utf8")).toBe("");
81 }, 30_000);
82
83 it("gives up with a message, not a hang, when the daemon never comes up", () => {
84 const bin = install();
85 writeFileSync(path.join(bin, "dockerd"), `#!/bin/bash\necho $$ >> ${dir}/dockerd.pids\necho "failed to start daemon: no cgroups" >&2\nexec sleep 60\n`);
86 // Shorten the waits for the test: the start loop and the lock wait.
87 const script = readFileSync(path.join(bin, "docker"), "utf8").replace("seq 1 240", "seq 1 4").replace("flock -w 150 9", "flock -w 2 9");
88 writeFileSync(path.join(bin, "docker"), script);
89 const env = { ...process.env, PATH: `${bin}:${process.env.PATH}` };
90 const r = spawnSync("bash", ["-c", "docker version & sleep 0.2; docker info; echo \"second exited $?\"; wait"], { env, encoding: "utf8", timeout: 20_000 });
91 expect(r.error).toBeUndefined();
92 expect(r.stdout).toContain("second exited 1");
93 expect(r.stderr).toContain("The Docker daemon did not start");
94 expect(r.stderr).toContain("failed to start daemon: no cgroups");
95 }, 30_000);
96});