| 1 | import { spawnSync } from "node:child_process"; |
| 2 | import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; |
| 3 | import path from "node:path"; |
| 4 | import { fileURLToPath } from "node:url"; |
| 5 | import { afterEach, beforeEach, describe, expect, it } from "vitest"; |
| 6 | import { apiBaseFor, runAgentJob } from "../src/agent.js"; |
| 7 | import { 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. |
| 11 | const WRAPPER = path.join(path.dirname(fileURLToPath(import.meta.url)), "../image/docker"); |
| 12 | const tools = ["flock", "setsid"].every((t) => spawnSync("sh", ["-c", `command -v ${t}`]).status === 0); |
| 13 | |
| 14 | let api: FakeApi; |
| 15 | let dir: string; |
| 16 | |
| 17 | /** Writes the wrapper to `dir/bin/docker`, with its paths moved into `dir`, and the stand-ins next to it. */ |
| 18 | function 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 | |
| 47 | beforeEach(async () => { |
| 48 | api = await startFakeApi(); |
| 49 | dir = tmp("docker-wrapper-"); |
| 50 | }); |
| 51 | afterEach(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 | |
| 59 | describe.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 | }); |