| 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 | |
| 19 | import { createHash } from "node:crypto"; |
| 20 | import { execFileSync } from "node:child_process"; |
| 21 | import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; |
| 22 | import { dirname, join } from "node:path"; |
| 23 | |
| 24 | import { ACCOUNT_ID, exec, jsonFrom, lastLines, wrangler } from "./cloudflare.mjs"; |
| 25 | import { parseCargoLock } from "./lockfiles.mjs"; |
| 26 | import { ROOT, parseJsonc } from "./stack.mjs"; |
| 27 | |
| 28 | export const REGISTRY = "registry.cloudflare.com"; |
| 29 | |
| 30 | /** The generated config a runner deploy uses, beside its wrangler.jsonc. */ |
| 31 | export const DEPLOY_CONFIG = "wrangler.deploy.json"; |
| 32 | |
| 33 | const posix = (path) => path.replaceAll("\\", "/"); |
| 34 | |
| 35 | /** Tracked and untracked (not ignored) files under `paths`, sorted. */ |
| 36 | export 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 | */ |
| 46 | export 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. */ |
| 58 | export function repositoryOf(unit, account = ACCOUNT_ID) { |
| 59 | return `${REGISTRY}/${account}/${unit.image.repository}`; |
| 60 | } |
| 61 | |
| 62 | /** What services/runner/base.json says, or null. */ |
| 63 | export 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. */ |
| 69 | export 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`. */ |
| 74 | export 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 | */ |
| 83 | export 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 | */ |
| 96 | export 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. */ |
| 110 | export 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. */ |
| 127 | export function runnerRef(unit, root = ROOT, account = ACCOUNT_ID) { |
| 128 | return `${repositoryOf(unit, account)}:${runnerTag(unit, root)}`; |
| 129 | } |
| 130 | |
| 131 | // ── The registry ────────────────────────────────────────────────────────── |
| 132 | |
| 133 | let creds = null; |
| 134 | |
| 135 | /** Short-lived registry credentials, from Wrangler (your login or the token). */ |
| 136 | export 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. */ |
| 149 | export 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 | |
| 155 | const 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. */ |
| 163 | export 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. */ |
| 180 | export 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. */ |
| 185 | export 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 | */ |
| 197 | export 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. */ |
| 209 | export 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. */ |
| 226 | export 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 | */ |
| 245 | export 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 | */ |
| 255 | export 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 | */ |
| 261 | export 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 | */ |
| 278 | export 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 | |
| 292 | export function removeDeployConfig(unit, root = ROOT) { |
| 293 | rmSync(join(root, unit.path, DEPLOY_CONFIG), { force: true }); |
| 294 | } |
| 295 | |
| 296 | /** Writes services/runner/base.json. */ |
| 297 | export function writeBaseLock(unit, lock, root = ROOT) { |
| 298 | writeFileSync(join(root, unit.image.base.lock), `${JSON.stringify(lock, null, 2)}\n`); |
| 299 | } |