flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/scripts/deploy/image.mjs

299 lines14,056 bytesCodeBlame
1// The runner's Containers images, as the deploy tool builds and ships them
2// (docs/DEPLOYING.md, "The runner's images"):
3//
4// base g1t-runner:base-<date>-<inputs> services/runner/base/Dockerfile:
5// the OS, toolchains and the Claude Code CLI. Rebuilt only when
6// its folder changes, or weekly (.g1t/workflows/runner-base.yml),
7// and recorded in services/runner/base.json.
8// runner g1t-runner:<content> services/runner/Dockerfile: the
9// base plus the runner binary (scripts/build-runner.mjs). Its tag
10// is a hash of everything it is built from, so the same source
11// always names the same image, and an image already in the
12// registry is never built again.
13//
14// Both live in one repository of Cloudflare's registry, so pushing the
15// runner uploads only its one new layer. `wrangler deploy` is given the
16// runner's registry reference (a generated config), so a deploy never
17// builds an image itself.
18
19import { createHash } from "node:crypto";
20import { execFileSync } from "node:child_process";
21import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
22import { dirname, join } from "node:path";
23
24import { ACCOUNT_ID, exec, jsonFrom, lastLines, wrangler } from "./cloudflare.mjs";
25import { parseCargoLock } from "./lockfiles.mjs";
26import { ROOT, parseJsonc } from "./stack.mjs";
27
28export const REGISTRY = "registry.cloudflare.com";
29
30/** The generated config a runner deploy uses, beside its wrangler.jsonc. */
31export const DEPLOY_CONFIG = "wrangler.deploy.json";
32
33const posix = (path) => path.replaceAll("\\", "/");
34
35/** Tracked and untracked (not ignored) files under `paths`, sorted. */
36export function filesUnder(paths, root = ROOT) {
37 if (!paths.length) return [];
38 const out = execFileSync("git", ["ls-files", "-co", "--exclude-standard", "-z", "--", ...paths], { cwd: root, encoding: "utf8", maxBuffer: 64 << 20 });
39 return [...new Set(out.split("\0").filter(Boolean).map(posix))].filter((file) => existsSync(join(root, file))).sort();
40}
41
42/**
43 * A hash of files' paths and contents, line endings made LF so a Windows
44 * checkout and a Linux one agree.
45 */
46export function contentHash(files, root = ROOT, read = (file) => readFileSync(join(root, file))) {
47 const hash = createHash("sha256");
48 for (const file of [...files].sort()) {
49 const bytes = read(file);
50 const text = bytes.includes(0) ? bytes : Buffer.from(bytes.toString("utf8").replaceAll("\r\n", "\n"));
51 hash.update(`${file}\0${text.length}\0`);
52 hash.update(text);
53 }
54 return hash.digest("hex");
55}
56
57/** The repository both images are pushed to, for an account. */
58export function repositoryOf(unit, account = ACCOUNT_ID) {
59 return `${REGISTRY}/${account}/${unit.image.repository}`;
60}
61
62/** What services/runner/base.json says, or null. */
63export function readBaseLock(unit, root = ROOT) {
64 const file = join(root, unit.image.base.lock);
65 return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : null;
66}
67
68/** A hash of everything the base is built from: its folder. */
69export function baseInputs(unit, root = ROOT) {
70 return contentHash(filesUnder([unit.image.base.context], root), root);
71}
72
73/** The base's tag for its inputs, built on `date`. */
74export function baseTag(inputs, date = new Date()) {
75 return `base-${date.toISOString().slice(0, 10).replaceAll("-", "")}-${inputs.slice(0, 12)}`;
76}
77
78/**
79 * Whether the base recorded in base.json is the one its folder builds:
80 * { lock, inputs, current, pushed, ref } where `ref` is what the runner's
81 * image is built FROM (by digest, once pushed).
82 */
83export function baseState(unit, root = ROOT) {
84 const lock = readBaseLock(unit, root);
85 const inputs = baseInputs(unit, root);
86 // Pinned by digest once pushed, so the tag moving cannot change what is built.
87 const ref = lock ? (lock.pushed && lock.digest ? `${lock.image}@${lock.digest}` : lock.image) : null;
88 return { lock, inputs, current: Boolean(lock && lock.inputs === inputs), pushed: Boolean(lock?.pushed), ref };
89}
90
91/**
92 * The runner image's tag: a hash of its Dockerfile, the base it is built
93 * on, and every file the binary is built from (the crates in
94 * `unit.image.dirs`, the workspace's Cargo files, the build script).
95 */
96export function runnerTag(unit, root = ROOT) {
97 const base = readBaseLock(unit, root);
98 // Cargo.lock counts only for the packages the binary is built from, so a
99 // dependency bumped for another service names the same image.
100 const files = filesUnder([...unit.image.dirs, ...unit.image.files.filter((f) => f !== unit.image.base.lock && f !== "Cargo.lock")], root);
101 const lock = existsSync(join(root, "Cargo.lock")) ? readFileSync(join(root, "Cargo.lock"), "utf8") : "";
102 const hash = createHash("sha256");
103 hash.update(contentHash(files, root));
104 hash.update(`\0lock\0${lockFor(lock, unit.image.crate)}`);
105 hash.update(`\0base\0${base?.image ?? ""}\0${base?.digest ?? ""}`);
106 return hash.digest("hex").slice(0, 16);
107}
108
109/** The Cargo.lock entries `crate` is built from, in a stable order. */
110export function lockFor(text, crate) {
111 const packages = parseCargoLock(text);
112 const seen = new Set();
113 const stack = [crate];
114 while (stack.length) {
115 const name = stack.pop();
116 if (seen.has(name)) continue;
117 seen.add(name);
118 for (const entry of packages.get(name) ?? []) stack.push(...entry.deps);
119 }
120 return [...seen]
121 .sort()
122 .map((name) => `${name} ${JSON.stringify((packages.get(name) ?? []).map((e) => [e.version, e.source, e.checksum]))}`)
123 .join("\n");
124}
125
126/** The runner image's full reference for this checkout. */
127export function runnerRef(unit, root = ROOT, account = ACCOUNT_ID) {
128 return `${repositoryOf(unit, account)}:${runnerTag(unit, root)}`;
129}
130
131// ── The registry ──────────────────────────────────────────────────────────
132
133let creds = null;
134
135/** Short-lived registry credentials, from Wrangler (your login or the token). */
136export async function registryCredentials({ push = false } = {}) {
137 if (creds && creds.until > Date.now() && (!push || creds.push)) return creds;
138 const args = ["containers", "registries", "credentials", REGISTRY, "--pull", "--json", "--expiration-minutes", "60"];
139 if (push) args.push("--push");
140 // In a unit's folder, not the root, whose .env may hold a token for something else.
141 const got = await wrangler(args, { cwd: join(ROOT, "services/runner") });
142 if (got.code !== 0) throw new Error(`could not get registry credentials:\n${lastLines(got.out)}`);
143 const { username, password } = jsonFrom(got.out);
144 creds = { username, password, push, until: Date.now() + 50 * 60 * 1000 };
145 return creds;
146}
147
148/** Logs Docker in to Cloudflare's registry. */
149export async function dockerLogin({ push = false } = {}) {
150 const { username, password } = await registryCredentials({ push });
151 const done = await exec("docker", ["login", "--username", username, "--password-stdin", REGISTRY], { input: password });
152 if (done.code !== 0) throw new Error(`docker login ${REGISTRY} failed:\n${lastLines(done.out)}`);
153}
154
155const MANIFEST_TYPES = [
156 "application/vnd.oci.image.index.v1+json",
157 "application/vnd.oci.image.manifest.v1+json",
158 "application/vnd.docker.distribution.manifest.list.v2+json",
159 "application/vnd.docker.distribution.manifest.v2+json",
160].join(", ");
161
162/** Whether `ref` (registry/account/repo:tag) is in the registry. Needs no Docker. */
163export async function registryHas(ref, { fetchImpl = fetch, credentials = registryCredentials } = {}) {
164 const match = /^([^/]+)\/(.+):([^:/]+)$/.exec(ref);
165 if (!match) throw new Error(`not an image reference with a tag: ${ref}`);
166 const [, host, name, tag] = match;
167 const { username, password } = await credentials();
168 const response = await fetchImpl(`https://${host}/v2/${name}/manifests/${tag}`, {
169 method: "HEAD",
170 headers: { authorization: `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`, accept: MANIFEST_TYPES },
171 });
172 if (response.status === 404) return false;
173 if (!response.ok) throw new Error(`the registry answered ${response.status} for ${ref}`);
174 return true;
175}
176
177// ── Docker ────────────────────────────────────────────────────────────────
178
179/** Whether Docker has `ref` locally. */
180export async function dockerHas(ref) {
181 return (await exec("docker", ["image", "inspect", ref, "--format", "{{.Id}}"])).code === 0;
182}
183
184/** Bytes of an image as Docker keeps it unpacked, and its layers' compressed total when known. */
185export async function imageSize(ref) {
186 const found = await exec("docker", ["image", "inspect", ref, "--format", "{{.Size}}"]);
187 return found.code === 0 ? Number(found.out.trim()) : null;
188}
189
190/**
191 * Builds the base. Its cache comes from the base it replaces: the image
192 * carries its own build cache (BUILDKIT_INLINE_CACHE), so a machine with
193 * none, or one just pruned, pulls the layers that did not change instead
194 * of building them again. `--cache-from` an image that cannot be pulled
195 * (no login, a first build) is skipped with a warning.
196 */
197export async function buildBase(unit, { tag, previous, onLine, account = ACCOUNT_ID, noCache = false }) {
198 const ref = `${repositoryOf(unit, account)}:${tag}`;
199 const args = ["buildx", "build", ...PLAIN_IMAGE, "--progress", "plain", "--load", "--build-arg", "BUILDKIT_INLINE_CACHE=1", "-t", ref];
200 if (previous && !noCache) args.push("--cache-from", `type=registry,ref=${previous}`);
201 if (noCache) args.push("--no-cache");
202 args.push(join(ROOT, unit.image.base.context));
203 const built = await exec("docker", args, { onLine });
204 if (built.code !== 0) throw new Error(`docker build of the base failed:\n${lastLines(built.out, 30)}`);
205 return ref;
206}
207
208/** What the base has, for base.json: each toolchain's version. */
209export async function baseVersions(ref) {
210 const script = [
211 'echo "node=$(node --version)"',
212 'echo "npm=$(npm --version)"',
213 'echo "python=$(python3 --version | cut -d" " -f2)"',
214 'echo "go=$(go version | cut -d" " -f3)"',
215 'echo "rust=$(rustc --version | cut -d" " -f2)"',
216 'echo "git=$(git --version | cut -d" " -f3)"',
217 'echo "claude_code=$(claude --version | cut -d" " -f1)"',
218 'echo "debian=$(cat /etc/debian_version)"',
219 ].join("; ");
220 const found = await exec("docker", ["run", "--rm", "--platform", "linux/amd64", "--entrypoint", "bash", ref, "-c", script]);
221 if (found.code !== 0) return {};
222 return Object.fromEntries(found.out.trim().split(/\r?\n/).map((line) => line.split("=")).filter((pair) => pair.length === 2));
223}
224
225/** Pushes `ref`; returns its digest in the registry. */
226export async function pushImage(ref, { onLine = () => {}, attempts = 3, run = exec } = {}) {
227 let last = "";
228 for (let attempt = 1; attempt <= attempts; attempt++) {
229 const pushed = await run("docker", ["push", ref], { onLine });
230 last = pushed.out;
231 const digest = pushedDigest(pushed);
232 if (digest) return digest;
233 // Cloudflare's registry can answer "blob unknown" for a layer it has
234 // just taken; pushing again finds the layers there and finishes.
235 if (attempt < attempts) onLine(`docker push ${ref} did not finish (attempt ${attempt} of ${attempts}); trying again`);
236 }
237 throw new Error(`docker push ${ref} failed:\n${lastLines(last)}`);
238}
239
240/**
241 * The digest a `docker push` ended with, or null if it did not end with
242 * one, whatever its exit code: an error from the registry is a failure
243 * even when Docker exits 0.
244 */
245export function pushedDigest({ code, out }) {
246 if (code !== 0 || /error from registry|blob unknown|unknown blob|denied|unauthorized/i.test(out)) return null;
247 return /digest: (sha256:[0-9a-f]{64})/.exec(out)?.[1] ?? null;
248}
249
250/**
251 * Build flags for an image Cloudflare Containers takes as Wrangler builds
252 * it: one manifest for linux/amd64, with no provenance or SBOM attestation,
253 * which would make it an index with an `unknown/unknown` manifest.
254 */
255export const PLAIN_IMAGE = ["--platform", "linux/amd64", "--provenance=false", "--sbom=false"];
256
257/**
258 * Builds the runner's image: the base and the binary, from a build
259 * context holding only the binary (target/runner-image).
260 */
261export async function buildRunnerImage(unit, { ref, base, binaryDir, onLine }) {
262 const built = await exec(
263 "docker",
264 ["buildx", "build", ...PLAIN_IMAGE, "--progress", "plain", "--load", "--build-arg", `BASE=${base}`, "-f", join(ROOT, unit.image.dockerfile), "-t", ref, binaryDir],
265 { onLine },
266 );
267 if (built.code !== 0) throw new Error(`docker build of the runner's image failed:\n${lastLines(built.out, 30)}`);
268 return ref;
269}
270
271// ── The Worker's config ───────────────────────────────────────────────────
272
273/**
274 * Writes the config a runner deploy uses: its wrangler.jsonc with every
275 * container's image set to `ref`, beside it so its relative paths hold.
276 * Returns the file's name (pass it as --config). Remove it after.
277 */
278export function writeDeployConfig(unit, ref, root = ROOT) {
279 const config = parseJsonc(readFileSync(join(root, unit.path, "wrangler.jsonc"), "utf8"));
280 delete config.$schema;
281 for (const container of config.containers ?? []) {
282 container.image = ref;
283 delete container.image_build_context;
284 delete container.image_vars;
285 }
286 const file = join(root, unit.path, DEPLOY_CONFIG);
287 mkdirSync(dirname(file), { recursive: true });
288 writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
289 return DEPLOY_CONFIG;
290}
291
292export function removeDeployConfig(unit, root = ROOT) {
293 rmSync(join(root, unit.path, DEPLOY_CONFIG), { force: true });
294}
295
296/** Writes services/runner/base.json. */
297export function writeBaseLock(unit, lock, root = ROOT) {
298 writeFileSync(join(root, unit.image.base.lock), `${JSON.stringify(lock, null, 2)}\n`);
299}