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.

295 lines · 15.4 KBRaw History
1import { createHash } from "node:crypto";
2import { existsSync } from "node:fs";
3import { mkdir } from "node:fs/promises";
4import path from "node:path";
5import type { ContainerSpec } from "@diffinery/shared";
6import { failure, type JobRunner, type StepIO } from "./job.js";
7import { shellWords, sleep, which } from "./util.js";
8
9/**
10 * Docker support for self-hosted runners: job containers (`jobs.<id>.container`),
11 * service containers (`jobs.<id>.services`) and Docker container actions.
12 *
13 * Like GitHub's runner, every container of a job joins one bridge network, the job
14 * container mounts the work directory at the same path it has on the host (so file
15 * commands, scripts and the tool cache need no path mapping), and Docker actions run
16 * as sibling containers with the workspace at /github/workspace.
17 */
18
19/** Env vars that would change how the docker CLI itself behaves, so they are passed inline. */
20const INLINE_ENV = /^(PATH|HOME|TMPDIR|DOCKER_\w*|LD_\w*|SSL_CERT_\w*)$/;
21const SOCKET = "/var/run/docker.sock";
22/** Inside a job container, the runner's own node binary (for JavaScript actions). */
23export const CONTAINER_NODE = "/__e/node";
24const CONTAINER_HOME = "/github/home";
25
26export interface RunningContainer {
27 id: string;
28 name: string;
29 network: string;
30 ports: Record<string, string>;
31}
32
33export interface JobContainer extends RunningContainer {
34 /** PATH from the image, used when the workflow doesn't set one. */
35 path: string;
36 /** Shells and interpreters found in the image. */
37 tools: Set<string>;
38}
39
40export type Wrap = (argv: string[], env: Record<string, string>, cwd: string) => { argv: string[]; env: NodeJS.ProcessEnv; cwd: string };
41
42const sanitize = (s: string) => s.replace(/[^A-Za-z0-9_.-]/g, "_").replace(/^[^A-Za-z0-9]+/, "") || "x";
43const hash = (s: string) => createHash("sha256").update(s).digest("hex").slice(0, 12);
44
45export class Docker {
46 readonly label: string;
47 network: string | null = null;
48 container: JobContainer | null = null;
49 readonly services: Record<string, RunningContainer> = {};
50 private bin: string | null = null;
51 private configDir: string | null = null;
52 private seq = 0;
53
54 constructor(private readonly job: JobRunner) {
55 this.label = `diffinery.job=${sanitize(job.spec.jobId)}`;
56 }
57
58 // ------------------------------------------------------------ CLI
59
60 /** Fails with a clear message when the runner has no usable Docker. */
61 async ensure(io: StepIO, what: string): Promise<string> {
62 if (this.bin) return this.bin;
63 const bin = which("docker", this.job.hostEnv().PATH);
64 if (!bin) throw failure(`${what} need Docker, and this runner has no docker CLI. Use runs-on: [self-hosted, ...] on a machine with Docker installed.`);
65 const r = await this.job.tool(bin, ["version", "--format", "{{.Server.Version}}"], { io, capture: true, silent: true, env: this.job.hostEnv() });
66 if (r.exitCode !== 0) throw failure(`${what} need Docker, and the Docker daemon on this runner is not reachable: ${(r.stderr || r.stdout).trim().split("\n")[0]}`);
67 this.job.logger.line(io.logIndex, `Docker ${r.stdout.trim()}`);
68 this.bin = bin;
69 return bin;
70 }
71
72 private async docker(io: StepIO, args: string[], o: { capture?: boolean; silent?: boolean; input?: string; env?: NodeJS.ProcessEnv; config?: boolean } = {}) {
73 const bin = this.bin!;
74 const full = o.config && this.configDir ? ["--config", this.configDir, ...args] : args;
75 return this.job.tool(bin, full, { io, capture: o.capture, silent: o.silent, input: o.input, env: o.env ?? this.job.hostEnv() });
76 }
77
78 private async must(io: StepIO, args: string[], o: Parameters<Docker["docker"]>[2] = {}): Promise<string> {
79 const r = await this.docker(io, args, { capture: true, ...o });
80 if (r.exitCode !== 0) throw failure(`docker ${args[0]} failed (exit code ${r.exitCode}): ${(r.stderr || r.stdout).trim().split("\n").slice(-3).join(" ")}`);
81 return r.stdout.trim();
82 }
83
84 /** `-e NAME` flags with the values carried in the docker CLI's own environment (keeps secrets out of argv). */
85 envArgs(env: Record<string, string>): { args: string[]; env: NodeJS.ProcessEnv } {
86 const args: string[] = [];
87 const cli: NodeJS.ProcessEnv = this.job.hostEnv();
88 for (const [k, v] of Object.entries(env)) {
89 if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) continue;
90 if (INLINE_ENV.test(k)) args.push("-e", `${k}=${v}`);
91 else {
92 args.push("-e", k);
93 cli[k] = v;
94 }
95 }
96 return { args, env: cli };
97 }
98
99 async ensureNetwork(io: StepIO): Promise<string> {
100 if (this.network) return this.network;
101 const name = `diffinery_${sanitize(this.job.spec.jobId)}_${hash(`${Date.now()}${Math.random()}`).slice(0, 6)}`;
102 await this.must(io, ["network", "create", "--label", this.label, name]);
103 this.network = name;
104 return name;
105 }
106
107 private async login(io: StepIO, image: string, creds: ContainerSpec["credentials"]): Promise<void> {
108 if (!creds?.username && !creds?.password) return;
109 if (!creds.username || !creds.password) throw failure(`Registry credentials for ${image} need both a username and a password.`);
110 this.configDir ??= path.join(this.job.tempDir, "_docker_config");
111 await mkdir(this.configDir, { recursive: true, mode: 0o700 });
112 this.job.masker.add(creds.password);
113 const first = image.split("/")[0]!;
114 const registry = image.includes("/") && (first.includes(".") || first.includes(":") || first === "localhost") ? first : "";
115 await this.must(io, ["login", "--username", creds.username, "--password-stdin", ...(registry ? [registry] : [])], { input: creds.password, config: true });
116 }
117
118 async pull(io: StepIO, image: string, creds?: ContainerSpec["credentials"]): Promise<void> {
119 await this.login(io, image, creds);
120 this.job.logger.line(io.logIndex, `##[command]docker pull ${image}`);
121 const r = await this.docker(io, ["pull", image], { config: !!creds?.username });
122 if (r.exitCode !== 0) throw failure(`Could not pull ${image} (docker pull exited with ${r.exitCode}).`);
123 }
124
125 // ------------------------------------------------------------ services and the job container
126
127 private specArgs(spec: ContainerSpec, where: string): string[] {
128 const args: string[] = [];
129 for (const p of spec.ports ?? []) args.push("-p", String(p));
130 for (const v of spec.volumes ?? []) args.push("-v", v);
131 const options = spec.options ? shellWords(spec.options) : [];
132 for (const o of options) {
133 if (/^--(network|net)(=|$)/.test(o)) throw failure(`${where}: the --network option is not supported; every container of the job shares one network.`);
134 }
135 return [...args, ...options];
136 }
137
138 private async ports(io: StepIO, id: string): Promise<Record<string, string>> {
139 const out = await this.must(io, ["port", id]).catch(() => "");
140 const ports: Record<string, string> = {};
141 for (const line of out.split("\n")) {
142 const m = /^(\d+)\/\w+\s*->\s*.*:(\d+)$/.exec(line.trim());
143 if (m && !ports[m[1]!]) ports[m[1]!] = m[2]!;
144 }
145 return ports;
146 }
147
148 /** Waits for a container's health check, if it has one. */
149 private async healthy(io: StepIO, id: string, name: string): Promise<void> {
150 const deadline = Date.now() + 15 * 60_000;
151 let delay = 1000;
152 for (;;) {
153 const status = await this.must(io, ["inspect", "--format", "{{if .State.Health}}{{.State.Health.Status}}{{end}}", id]);
154 if (status === "" || status === "healthy") {
155 if (status) this.job.logger.line(io.logIndex, `${name} is healthy`);
156 return;
157 }
158 if (status === "unhealthy") {
159 await this.docker(io, ["logs", "--tail", "50", id]);
160 throw failure(`Service container ${name} failed its health check.`);
161 }
162 const running = await this.must(io, ["inspect", "--format", "{{.State.Status}}", id]);
163 if (running === "exited" || running === "dead") {
164 await this.docker(io, ["logs", "--tail", "50", id]);
165 throw failure(`Service container ${name} exited before it became healthy.`);
166 }
167 if (Date.now() > deadline || this.job.isStopping) throw failure(`Service container ${name} did not become healthy in time (status: ${status}).`);
168 this.job.logger.line(io.logIndex, `Waiting for ${name} to be healthy (${status})...`);
169 await sleep(delay);
170 delay = Math.min(delay * 2, 10_000);
171 }
172 }
173
174 async startService(io: StepIO, key: string, spec: ContainerSpec): Promise<void> {
175 const network = await this.ensureNetwork(io);
176 await this.pull(io, spec.image, spec.credentials);
177 const name = `${sanitize(key)}_${hash(network)}`;
178 const { args: envArgs, env } = this.envArgs(spec.env ?? {});
179 const args = ["run", "-d", "--name", name, "--label", this.label, "--network", network, "--network-alias", key, ...envArgs, ...this.specArgs(spec, `services.${key}`), spec.image];
180 this.job.logger.line(io.logIndex, `##[command]docker ${["run", "-d", "--name", name, "--network", network, "--network-alias", key, ...this.specArgs(spec, `services.${key}`), spec.image].join(" ")}`);
181 const id = await this.must(io, args, { env });
182 await this.healthy(io, id, key);
183 const ports = await this.ports(io, id);
184 this.services[key] = { id, name, network, ports };
185 const shown = Object.entries(ports).map(([c, h]) => `${c} -> localhost:${h}`);
186 this.job.logger.line(io.logIndex, `Service ${key} is running${shown.length ? ` (${shown.join(", ")})` : ""}`);
187 }
188
189 async startJobContainer(io: StepIO, spec: ContainerSpec): Promise<void> {
190 const network = await this.ensureNetwork(io);
191 await this.pull(io, spec.image, spec.credentials);
192 const job = this.job;
193 const name = `job_${hash(`${network}${job.spec.jobId}`)}`;
194 const workRoot = path.resolve(job.opts.workRoot);
195 const home = path.join(job.tempDir, "_github_home");
196 await mkdir(home, { recursive: true });
197 const mounts = ["-v", `${workRoot}:${workRoot}`, "-v", `${home}:${CONTAINER_HOME}`];
198 const toolCache = path.resolve(job.toolCache);
199 if (!toolCache.startsWith(`${workRoot}/`)) mounts.push("-v", `${toolCache}:${toolCache}`);
200 const tempDir = path.resolve(job.tempDir);
201 if (!tempDir.startsWith(`${workRoot}/`)) mounts.push("-v", `${tempDir}:${tempDir}`);
202 mounts.push("-v", `${process.execPath}:${CONTAINER_NODE}:ro`);
203 if (existsSync(SOCKET)) mounts.push("-v", `${SOCKET}:${SOCKET}`);
204 const { args: envArgs, env } = this.envArgs(spec.env ?? {});
205 const extra = this.specArgs(spec, "container");
206 const args = ["run", "-d", "--name", name, "--label", this.label, "--network", network, "--workdir", job.workspace, ...mounts, ...envArgs, ...extra, "--entrypoint", "tail", spec.image, "-f", "/dev/null"];
207 job.logger.line(io.logIndex, `##[command]docker run -d --name ${name} --network ${network} --workdir ${job.workspace} ${[...mounts, ...extra].join(" ")} --entrypoint tail ${spec.image} -f /dev/null`);
208 const id = await this.must(io, args, { env });
209 const running = await this.must(io, ["inspect", "--format", "{{.State.Running}}", id]);
210 if (running !== "true") {
211 await this.docker(io, ["logs", "--tail", "50", id]);
212 throw failure(`The job container (${spec.image}) exited right after starting. The image needs a 'tail' command to stay up.`);
213 }
214 const imageEnv = await this.must(io, ["inspect", "--format", "{{range .Config.Env}}{{println .}}{{end}}", id]);
215 const pathLine = imageEnv.split("\n").find((l) => l.startsWith("PATH="));
216 const probe = await this.docker(io, ["exec", id, "sh", "-c", 'for c in bash sh python python3 pwsh powershell; do command -v "$c" >/dev/null 2>&1 && echo "$c"; done'], { capture: true, silent: true });
217 const tools = new Set(probe.stdout.split("\n").map((s) => s.trim()).filter(Boolean));
218 this.container = {
219 id,
220 name,
221 network,
222 ports: await this.ports(io, id),
223 path: pathLine ? pathLine.slice(5) : "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
224 tools,
225 };
226 job.logger.line(io.logIndex, `Steps run in the job container ${spec.image} (${name})`);
227 }
228
229 /** Runs a step's process inside the job container. */
230 execWrap(): Wrap | undefined {
231 const c = this.container;
232 if (!c) return undefined;
233 return (argv, env, cwd) => {
234 const inner = { ...env, HOME: CONTAINER_HOME, PATH: [...this.job.pathAccum, env.PATH ?? c.path].filter(Boolean).join(":") };
235 const e = this.envArgs(inner);
236 return { argv: [this.bin!, "exec", "-w", cwd, ...e.args, c.id, ...argv], env: e.env, cwd };
237 };
238 }
239
240 // ------------------------------------------------------------ Docker actions
241
242 /** Pulls `docker://image` or builds an action's Dockerfile; returns the image to run. */
243 async prepareAction(io: StepIO, image: string, actionDir: string | null): Promise<string> {
244 await this.ensure(io, "Docker container actions");
245 if (image.startsWith("docker://")) {
246 const ref = image.slice("docker://".length);
247 await this.pull(io, ref);
248 return ref;
249 }
250 if (!actionDir) throw failure(`Invalid Docker image '${image}'`);
251 const dockerfile = path.resolve(actionDir, image);
252 if (!dockerfile.startsWith(`${path.resolve(actionDir)}/`) || !existsSync(dockerfile)) throw failure(`Dockerfile '${image}' not found in the action at ${actionDir}`);
253 const tag = `diffinery-action-${hash(dockerfile)}:latest`;
254 this.job.logger.line(io.logIndex, `##[command]docker build -t ${tag} -f ${dockerfile} ${actionDir}`);
255 const r = await this.docker(io, ["build", "-t", tag, "-f", dockerfile, actionDir]);
256 if (r.exitCode !== 0) throw failure(`Building the action's Dockerfile failed (exit code ${r.exitCode}).`);
257 return tag;
258 }
259
260 /** Runs an action image as a sibling container on the job network. */
261 async runWrap(io: StepIO, image: string, o: { entrypoint?: string; args: string[] }): Promise<Wrap> {
262 const network = await this.ensureNetwork(io);
263 const job = this.job;
264 const home = path.join(job.tempDir, "_github_home");
265 await mkdir(home, { recursive: true });
266 const tempDir = path.resolve(job.tempDir);
267 const name = `action_${hash(`${network}${++this.seq}`)}_${this.seq}`;
268 const mounts = ["-v", `${path.resolve(job.workspace)}:/github/workspace`, "-v", `${tempDir}:${tempDir}`, "-v", `${home}:${CONTAINER_HOME}`];
269 if (existsSync(SOCKET)) mounts.push("-v", `${SOCKET}:${SOCKET}`);
270 return (_argv, env) => {
271 const inner: Record<string, string> = { ...env, GITHUB_WORKSPACE: "/github/workspace", HOME: CONTAINER_HOME };
272 // The image keeps its own PATH unless the workflow set one.
273 if (env.PATH === undefined) delete inner.PATH;
274 const e = this.envArgs(inner);
275 const argv = [this.bin!, "run", "--rm", "--name", name, "--label", this.label, "--network", network, "--workdir", "/github/workspace", ...mounts, ...e.args];
276 if (o.entrypoint) argv.push("--entrypoint", o.entrypoint);
277 argv.push(image, ...o.args);
278 return { argv, env: e.env, cwd: job.workspace };
279 };
280 }
281
282 // ------------------------------------------------------------ cleanup
283
284 async cleanup(io: StepIO): Promise<void> {
285 if (!this.bin) return;
286 const ids = (await this.docker(io, ["ps", "-aq", "--filter", `label=${this.label}`], { capture: true, silent: true })).stdout.split("\n").filter(Boolean);
287 if (ids.length) {
288 this.job.logger.line(io.logIndex, `Stopping ${ids.length} container${ids.length === 1 ? "" : "s"}`);
289 await this.docker(io, ["rm", "-f", "-v", ...ids], { capture: true, silent: true });
290 }
291 if (this.network) await this.docker(io, ["network", "rm", this.network], { capture: true, silent: true });
292 this.network = null;
293 this.container = null;
294 }
295}