A deploy makes the runner's image in the registry, without Docker: it reads the base's manifest and config from Cloudflare's registry, uploads one gzipped layer holding the runner binary at /usr/local/bin/g1t-runner with mode 0755, and writes a config (the entrypoint g1t-runner, the base's command cleared, as the Dockerfile's ENTRYPOINT does) and a manifest that name the base's layers and that one, under the runner's tag, for linux/amd64 (scripts/deploy/registry.mjs, the registry's own upload calls with the credentials Wrangler gives). Deploy #256 ran out of disk because docker build pulled the 4.5 GB base into the job's own Docker Engine, compressed and unpacked, on a 20 GB sandbox whose own filesystem is that same base; now nothing of the base is downloaded and the job's Docker Engine is never started. Docker is asked for only when it is needed: a dry run (build, or image --no-push) still builds services/runner/Dockerfile with it, and so does a deploy whose registry step fails, logging df -h and docker system df before and after. The image job removes npm's downloads, which it only restores, once its install has read them, and logs the disk before and after the deploy. Tests cover the layer's tar, the config and manifest, the calls against a registry kept in memory (the base's layers are never fetched), the token flow, and that the Dockerfile says the same path, mode and entrypoint; the image was also checked against a local registry, where it runs and has the same config as a docker build of the Dockerfile.
7 files+727−310/7 viewed
| 22 | 22 | # Containers write), the variable CLOUDFLARE_ACCOUNT_ID, and | |
| 23 | 23 | # api.cloudflare.com among the project's workflow-only domains for | |
| 24 | 24 | # deploy.yml in production (Settings, Guardrails), and | |
| 25 | − | # registry.cloudflare.com there too, to find, pull and push the runner's | |
| 26 | − | # image. A job that must build that image (the `runner-image` group) does | |
| 27 | − | # so with its own Docker Engine, on a larger machine. Optionally, the | |
| 25 | + | # registry.cloudflare.com there too, to find the runner's image and make | |
| 26 | + | # it. A job that must make that image (the `runner-image` group) builds the | |
| 27 | + | # runner's binary on a larger machine and makes the image in the registry | |
| 28 | + | # with no Docker: one layer with the binary on the base's layers, pulling | |
| 29 | + | # none of the base (scripts/deploy/registry.mjs). Optionally, the | |
| 28 | 30 | # secret STATUS_DEPLOY_TOKEN (the status Worker's secret of the same name) | |
| 29 | 31 | # and status.g1t.sh among the same workflow-only domains, so status.g1t.sh | |
| 30 | 32 | # hears each deploy start and finish. | |
| ⋯ | |||
| 260 | 262 | cargo-target-${{ runner.os }}- | |
| 261 | 263 | # The runner's image: its binary, built natively for musl (the base | |
| 262 | 264 | # has musl-gcc; the target is added here), with its Cargo target kept | |
| 263 | − | # between runs. The image itself is built and pushed with the job's | |
| 264 | − | # own Docker Engine (scripts/deploy/image.mjs). | |
| 265 | + | # between runs. The image itself is made in the registry, without | |
| 266 | + | # Docker: the base's layers and one with the binary | |
| 267 | + | # (scripts/deploy/registry.mjs). Building it with the job's Docker | |
| 268 | + | # Engine, only if that fails, pulls the 4.5 GB base onto the job's | |
| 269 | + | # disk. | |
| 265 | 270 | - name: Rust for the runner | |
| 266 | 271 | if: ${{ matrix.image }} | |
| 267 | 272 | run: rustup target add x86_64-unknown-linux-musl | |
| ⋯ | |||
| 299 | 304 | restore-keys: npm-full-${{ runner.os }}- | |
| 300 | 305 | - name: Install | |
| 301 | 306 | run: node scripts/deploy.mjs install --only "${{ matrix.units }}" | |
| 307 | + | # The runner's image job: npm's downloads are only restored here, and | |
| 308 | + | # the install has read them, so they go, leaving the disk to the | |
| 309 | + | # runner's build (and to Docker, should the image need it). What the | |
| 310 | + | # disk holds is logged before and after, so a job that runs out of | |
| 311 | + | # room says what took it. | |
| 312 | + | - name: Room for the runner's image | |
| 313 | + | if: ${{ matrix.image }} | |
| 314 | + | run: | | |
| 315 | + | rm -rf ~/.npm | |
| 316 | + | df -h / "$HOME" /tmp | |
| 317 | + | du -sh "$HOME"/.cargo target node_modules 2>/dev/null || true | |
| 302 | 318 | - name: Deploy ${{ matrix.units }} | |
| 303 | 319 | env: | |
| 304 | 320 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | |
| ⋯ | |||
| 306 | 322 | # are not drafted as incidents. Optional: without it, nothing is sent. | |
| 307 | 323 | STATUS_DEPLOY_TOKEN: ${{ secrets.STATUS_DEPLOY_TOKEN }} | |
| 308 | 324 | run: node scripts/deploy.mjs deploy --only "${{ matrix.units }}" --force --no-migrations --concurrency 2 | |
| 325 | + | - name: The disk after the runner's image | |
| 326 | + | if: ${{ always() && matrix.image }} | |
| 327 | + | run: | | |
| 328 | + | df -h / "$HOME" /tmp | |
| 329 | + | du -sh "$HOME"/.cargo target node_modules 2>/dev/null || true | |
| 309 | 330 | # Hits and misses, on the log and in the run's summary. | |
| 310 | 331 | - name: sccache's hits and misses | |
| 311 | 332 | if: ${{ always() && (matrix.rust || matrix.image) }} | |
| 80 | 80 | node scripts/deploy.mjs manifest --check # the manifest's problems | |
| 81 | 81 | node scripts/deploy.mjs doctor # secrets each unit lacks | |
| 82 | 82 | node scripts/deploy.mjs build-base # build and push the runner's base image (Docker) | |
| 83 | − | node scripts/deploy.mjs image # build and push the runner's image (Docker) | |
| 83 | + | node scripts/deploy.mjs image # make the runner's image in the registry (no Docker) | |
| 84 | 84 | ``` | |
| 85 | 85 | ||
| 86 | 86 | | Flag | What it does | | |
| ⋯ | |||
| 288 | 288 | `services/runner/base.json` records the base that was pushed; | |
| 289 | 289 | `npm run test:deploy` fails while it and the base's folder disagree. The | |
| 290 | 290 | runner's image is tagged with a hash of what it is built from, so a deploy | |
| 291 | − | builds it only when the registry does not already have it. When nothing | |
| 291 | + | makes it only when the registry does not already have it. It is made in | |
| 292 | + | the registry, without Docker: the deploy reads the base's manifest, | |
| 293 | + | uploads one layer holding the binary, and writes a manifest that names the | |
| 294 | + | base's layers and that one, with the entrypoint `services/runner/Dockerfile` | |
| 295 | + | gives it. Nothing of the base is downloaded, so the deploy job needs no | |
| 296 | + | room for it. If that fails and Docker runs where the deploy does, the | |
| 297 | + | image is built from `services/runner/Dockerfile` and pushed instead, and | |
| 298 | + | the log shows how full the disk was before and after. A dry run | |
| 299 | + | (`build`, or `image --no-push`) builds it with Docker. When nothing | |
| 292 | 300 | the image is built from changed, the deploy leaves running sandboxes | |
| 293 | 301 | alone. When it did, Cloudflare replaces sandboxes on the new image but | |
| 294 | 302 | leaves any that started within the last two hours | |
| 12 | 12 | // node scripts/deploy.mjs doctor which units lack their secrets | |
| 13 | 13 | // node scripts/deploy.mjs install --only a,b npm ci of just what those units need (CI) | |
| 14 | 14 | // node scripts/deploy.mjs build-base build and push the runner's base image (Docker) | |
| 15 | − | // node scripts/deploy.mjs image build and push the runner's image for this checkout (Docker) | |
| 15 | + | // node scripts/deploy.mjs image make the runner's image for this checkout in the registry (--no-push: build it with Docker) | |
| 16 | 16 | // | |
| 17 | 17 | // Flags: --all (every unit, changed or not), --only a,b, --skip a,b, | |
| 18 | 18 | // --force, --concurrency N (default 4), --stage core (one stage), | |
| ⋯ | |||
| 25 | 25 | import { tmpdir } from "node:os"; | |
| 26 | 26 | import { join } from "node:path"; | |
| 27 | 27 | ||
| 28 | − | import { OUT_DIR } from "./build-runner.mjs"; | |
| 28 | + | import { BINARY, OUT_DIR } from "./build-runner.mjs"; | |
| 29 | 29 | import { ensureWorkerBuild } from "./build-rust-worker.mjs"; | |
| 30 | 30 | import { | |
| 31 | 31 | annotation, | |
| ⋯ | |||
| 51 | 51 | baseVersions, | |
| 52 | 52 | buildBase, | |
| 53 | 53 | buildRunnerImage, | |
| 54 | + | diskReport, | |
| 54 | 55 | dockerHas, | |
| 55 | 56 | dockerLogin, | |
| 56 | 57 | imageSize, | |
| 58 | + | makeRunnerImage, | |
| 57 | 59 | pushImage, | |
| 58 | 60 | readBaseLock, | |
| 59 | 61 | registryHas, | |
| ⋯ | |||
| 149 | 151 | ||
| 150 | 152 | const seconds = (ms) => `${(ms / 1000).toFixed(1)}s`; | |
| 151 | 153 | ||
| 154 | + | /** `fn`, called the first time it is wanted and then remembered. */ | |
| 155 | + | const once = (fn) => { | |
| 156 | + | let answer = null; | |
| 157 | + | return () => (answer ??= fn()); | |
| 158 | + | }; | |
| 159 | + | ||
| 152 | 160 | /** Reads what each unit runs and what its database is waiting for. */ | |
| 153 | 161 | async function survey(units, opts) { | |
| 154 | 162 | const live = {}; | |
| ⋯ | |||
| 242 | 250 | /** | |
| 243 | 251 | * The runner's image for this checkout, by registry reference: already in | |
| 244 | 252 | * the registry (built by an earlier deploy, `deploy.mjs image`, or on | |
| 245 | − | * another machine), or built and pushed here, which needs Docker. A dry | |
| 246 | − | * run builds it locally and pushes nothing. Returns { ref, note }. | |
| 253 | + | * another machine), or made here. Pushing, it is made in the registry | |
| 254 | + | * (scripts/deploy/registry.mjs: the base's layers and one with the | |
| 255 | + | * binary, nothing pulled), which needs no Docker and no room for the | |
| 256 | + | * base; if that fails and Docker answers, it is built and pushed with | |
| 257 | + | * Docker. A dry run builds it with Docker and pushes nothing. `docker` is | |
| 258 | + | * asked only when needed: asking starts a job's Docker Engine. Returns | |
| 259 | + | * { ref, note }. | |
| 247 | 260 | */ | |
| 248 | 261 | async function runnerImage(unit, { dryRun, docker, rebuildImage, rebuildBase }, out) { | |
| 249 | 262 | let base = baseState(unit); | |
| ⋯ | |||
| 253 | 266 | `${unit.image.base.context} has changed since ${unit.image.base.lock} was written${base.lock ? "" : " (the base has never been built)"}. Build and push the base, and commit ${unit.image.base.lock}: node scripts/deploy.mjs build-base`, | |
| 254 | 267 | ); | |
| 255 | 268 | } | |
| 256 | − | if (!docker) throw new Error("--rebuild-base needs Docker."); | |
| 269 | + | if (!(await docker())) throw new Error("--rebuild-base needs Docker."); | |
| 257 | 270 | await newBase(unit, { push: !dryRun, onLine: out.line }); | |
| 258 | 271 | base = baseState(unit); | |
| 259 | 272 | } | |
| ⋯ | |||
| 261 | 274 | const ref = runnerRef(unit); | |
| 262 | 275 | const tag = ref.split(":").pop(); | |
| 263 | 276 | if (!rebuildImage) { | |
| 264 | − | if (dryRun && docker && (await dockerHas(ref))) return { ref, note: `image ${tag} already built here` }; | |
| 277 | + | if (dryRun && (await docker()) && (await dockerHas(ref))) return { ref, note: `image ${tag} already built here` }; | |
| 265 | 278 | if (!dryRun) { | |
| 266 | 279 | try { | |
| 267 | 280 | if (await registryHas(ref)) return { ref, note: `image ${tag} already in the registry` }; | |
| ⋯ | |||
| 270 | 283 | } | |
| 271 | 284 | } | |
| 272 | 285 | } | |
| 273 | − | if (!docker) { | |
| 274 | − | throw new Error( | |
| 275 | − | `its image ${tag} is not in the registry, and Docker does not answer here. Build and push it where Docker runs (a g1t Actions job, or a machine with Docker: node scripts/deploy.mjs image), then run this again.`, | |
| 276 | − | ); | |
| 286 | + | if (dryRun && !(await docker())) { | |
| 287 | + | throw new Error(`its image ${tag} is not built here, and Docker does not answer here: a dry run builds it with Docker.`); | |
| 277 | 288 | } | |
| 278 | 289 | const started = Date.now(); | |
| 279 | 290 | const binary = await exec(process.execPath, [join(ROOT, "scripts/build-runner.mjs")], { onLine: out.line }); | |
| 280 | 291 | if (binary.code !== 0) throw new Error(`the runner binary did not build:\n${lastLines(binary.out)}`); | |
| 281 | − | if (!dryRun) await dockerLogin({ push: true }); | |
| 282 | − | await buildRunnerImage(unit, { ref, base: base.ref, binaryDir: OUT_DIR, onLine: out.line }); | |
| 283 | − | if (!dryRun) await pushImage(ref, { onLine: out.line }); | |
| 284 | − | return { ref, note: `image ${tag} ${dryRun ? "built" : "built and pushed"} in ${seconds(Date.now() - started)}` }; | |
| 292 | + | if (!dryRun) { | |
| 293 | + | try { | |
| 294 | + | await makeRunnerImage({ ref, base: base.ref, binary: BINARY, onLine: out.line }); | |
| 295 | + | return { ref, note: `image ${tag} made in the registry in ${seconds(Date.now() - started)}` }; | |
| 296 | + | } catch (error) { | |
| 297 | + | if (!(await docker())) { | |
| 298 | + | throw new Error(`its image ${tag} could not be made in the registry (${error.message ?? error}), and Docker does not answer here to build it instead.`); | |
| 299 | + | } | |
| 300 | + | out.line(`could not make ${tag} in the registry (${error.message ?? error}); building it with Docker instead`); | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | // With Docker: the base is pulled and unpacked on this disk, so what the | |
| 304 | + | // disk held is logged before and after, and when it fails. | |
| 305 | + | await diskReport(out.line, "before the build"); | |
| 306 | + | try { | |
| 307 | + | if (!dryRun) await dockerLogin({ push: true }); | |
| 308 | + | await buildRunnerImage(unit, { ref, base: base.ref, binaryDir: OUT_DIR, onLine: out.line }); | |
| 309 | + | if (!dryRun) await pushImage(ref, { onLine: out.line }); | |
| 310 | + | } finally { | |
| 311 | + | await diskReport(out.line, "after the build"); | |
| 312 | + | } | |
| 313 | + | return { ref, note: `image ${tag} ${dryRun ? "built" : "built and pushed"} with Docker in ${seconds(Date.now() - started)}` }; | |
| 285 | 314 | } | |
| 286 | 315 | ||
| 287 | 316 | /** Builds the base (and pushes it unless `push` is false), and writes its lock. */ | |
| ⋯ | |||
| 423 | 452 | } | |
| 424 | 453 | ||
| 425 | 454 | if (touched.some((u) => u.kind === "rust-worker")) ensureWorkerBuild(); | |
| 426 | − | const docker = touched.some((u) => u.image) ? await dockerAvailable() : false; | |
| 455 | + | const docker = once(dockerAvailable); | |
| 427 | 456 | const context = { head, subject: git.subject(), dirty, dryRun, docker, rebuildImage: opts.rebuildImage, rebuildBase: opts.rebuildBase }; | |
| 428 | 457 | // A deploy run by hand shows on the repository's Deployments page; a | |
| 429 | 458 | // dirty tree is not a commit anyone can look at, so it is left out. | |
| ⋯ | |||
| 564 | 593 | case "image": { | |
| 565 | 594 | const unit = imageUnit(stack, opts); | |
| 566 | 595 | const out = unitLogger(`${unit.id}-image`); | |
| 567 | − | const docker = await dockerAvailable(); | |
| 596 | + | const docker = once(dockerAvailable); | |
| 568 | 597 | const image = await runnerImage(unit, { dryRun: Boolean(opts.noPush), docker, rebuildImage: opts.rebuildImage, rebuildBase: opts.rebuildBase }, out); | |
| 569 | 598 | out.save(); | |
| 570 | 599 | console.log(`${image.ref}\n${image.note}`); | |
| 9 | 9 | // base plus the runner binary (scripts/build-runner.mjs). Its tag | |
| 10 | 10 | // is a hash of everything it is built from, so the same source | |
| 11 | 11 | // always names the same image, and an image already in the | |
| 12 | − | // registry is never built again. | |
| 12 | + | // registry is never made again. | |
| 13 | 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. | |
| 14 | + | // Both live in one repository of Cloudflare's registry. A deploy makes the | |
| 15 | + | // runner's image there (registry.mjs): it uploads one layer with the | |
| 16 | + | // binary and a manifest naming the base's layers and that one, and pulls | |
| 17 | + | // nothing, so it needs neither Docker nor room for the base. Building it | |
| 18 | + | // with Docker is for dry runs, and the fallback. `wrangler deploy` is | |
| 19 | + | // given the runner's registry reference (a generated config), so a deploy | |
| 20 | + | // never builds an image itself. | |
| 18 | 21 | ||
| 19 | 22 | import { createHash } from "node:crypto"; | |
| 20 | 23 | import { execFileSync } from "node:child_process"; | |
| ⋯ | |||
| 23 | 26 | ||
| 24 | 27 | import { ACCOUNT_ID, exec, jsonFrom, lastLines, wrangler } from "./cloudflare.mjs"; | |
| 25 | 28 | import { parseCargoLock } from "./lockfiles.mjs"; | |
| 29 | + | import { appendRunnerImage } from "./registry.mjs"; | |
| 26 | 30 | import { ROOT, parseJsonc } from "./stack.mjs"; | |
| 27 | 31 | ||
| 28 | 32 | export const REGISTRY = "registry.cloudflare.com"; | |
| ⋯ | |||
| 259 | 263 | export const PLAIN_IMAGE = ["--platform", "linux/amd64", "--provenance=false", "--sbom=false"]; | |
| 260 | 264 | ||
| 261 | 265 | /** | |
| 266 | + | * Makes the runner's image in the registry, without Docker: the base's | |
| 267 | + | * layers, named by its manifest, and one layer with the binary at `binary` | |
| 268 | + | * (scripts/deploy/registry.mjs). What deploys use; the Docker build below | |
| 269 | + | * is for a dry run, and the fallback. Returns its digest. | |
| 270 | + | */ | |
| 271 | + | export async function makeRunnerImage({ ref, base, binary, onLine = () => {}, credentials = () => registryCredentials({ push: true }) }) { | |
| 272 | + | return appendRunnerImage({ ref, base, binary: readFileSync(binary), credentials, onLine }); | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | /** | |
| 276 | + | * Logs how full the disk is, and what Docker keeps on it: before and after | |
| 277 | + | * a Docker build, so a build that runs out of room says what took it. | |
| 278 | + | * Never fails; on a machine without `df`, it logs what it can. | |
| 279 | + | */ | |
| 280 | + | export async function diskReport(onLine, when, { run = exec } = {}) { | |
| 281 | + | if (process.platform === "win32") return; | |
| 282 | + | onLine(`disk ${when}:`); | |
| 283 | + | for (const [command, args] of [ | |
| 284 | + | ["df", ["-h", "/", "/tmp", process.env.HOME ?? "/root"]], | |
| 285 | + | ["docker", ["system", "df"]], | |
| 286 | + | ]) { | |
| 287 | + | try { | |
| 288 | + | const done = await run(command, args); | |
| 289 | + | for (const line of done.out.trim().split(/\r?\n/)) onLine(` ${line}`); | |
| 290 | + | } catch (error) { | |
| 291 | + | onLine(` ${command}: ${error.message ?? error}`); | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | /** | |
| 262 | 297 | * Builds the runner's image: the base and the binary, from a build | |
| 263 | 298 | * context holding only the binary (target/runner-image). | |
| 264 | 299 | */ | |
| 1 | + | // The runner's image made in the registry, with no Docker: the base's | |
| 2 | + | // manifest and config are read from Cloudflare's registry, one layer | |
| 3 | + | // holding the runner binary is uploaded beside the base's layers, and a | |
| 4 | + | // new config and manifest that name the base's layers and that one are | |
| 5 | + | // written under the runner's tag. Nothing of the base is downloaded, so a | |
| 6 | + | // deploy job needs no room for the 4.5 GB base, which a `docker build` | |
| 7 | + | // pulls and unpacks on the same disk the job runs on. | |
| 8 | + | // | |
| 9 | + | // What it makes is what services/runner/Dockerfile builds on the same | |
| 10 | + | // base: the binary at /usr/local/bin/g1t-runner with mode 0755 (COPY | |
| 11 | + | // --chmod=0755), ENTRYPOINT ["g1t-runner"] with the base's CMD cleared | |
| 12 | + | // (as a Dockerfile's ENTRYPOINT clears an inherited one), for linux/amd64. | |
| 13 | + | // A test holds the Dockerfile to these. | |
| 14 | + | // | |
| 15 | + | // The calls are the OCI distribution API (docs.docker.com/registry/spec/api/), | |
| 16 | + | // as `docker push` makes them: HEAD a blob to skip one already there; POST | |
| 17 | + | // to start an upload, PATCH its bytes, PUT with its digest to finish; PUT | |
| 18 | + | // the manifest by tag. | |
| 19 | + | ||
| 20 | + | import { createHash } from "node:crypto"; | |
| 21 | + | import { gzipSync } from "node:zlib"; | |
| 22 | + | ||
| 23 | + | export const RUNNER_BINARY_PATH = "usr/local/bin/g1t-runner"; | |
| 24 | + | export const RUNNER_BINARY_MODE = 0o755; | |
| 25 | + | export const RUNNER_ENTRYPOINT = ["g1t-runner"]; | |
| 26 | + | ||
| 27 | + | export const OCI_MANIFEST = "application/vnd.oci.image.manifest.v1+json"; | |
| 28 | + | export const DOCKER_MANIFEST = "application/vnd.docker.distribution.manifest.v2+json"; | |
| 29 | + | const OCI_INDEX = "application/vnd.oci.image.index.v1+json"; | |
| 30 | + | const DOCKER_LIST = "application/vnd.docker.distribution.manifest.list.v2+json"; | |
| 31 | + | const ACCEPT = [OCI_MANIFEST, DOCKER_MANIFEST, OCI_INDEX, DOCKER_LIST].join(", "); | |
| 32 | + | ||
| 33 | + | /** Each manifest type's gzipped layer type. */ | |
| 34 | + | const LAYER_TYPE = { | |
| 35 | + | [OCI_MANIFEST]: "application/vnd.oci.image.layer.v1.tar+gzip", | |
| 36 | + | [DOCKER_MANIFEST]: "application/vnd.docker.image.rootfs.diff.tar.gzip", | |
| 37 | + | }; | |
| 38 | + | ||
| 39 | + | export const sha256 = (bytes) => `sha256:${createHash("sha256").update(bytes).digest("hex")}`; | |
| 40 | + | ||
| 41 | + | /** A tar header field: octal, zero-padded, ending in NUL. */ | |
| 42 | + | function octal(value, width) { | |
| 43 | + | return `${value.toString(8).padStart(width - 1, "0")}\0`; | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /** | |
| 47 | + | * A tar archive (ustar) of one regular file, owned by root. Only the file: | |
| 48 | + | * its folders are the base's, so a layer that names them would replace | |
| 49 | + | * their owner, mode and time with its own. | |
| 50 | + | */ | |
| 51 | + | export function tarOf(path, data, { mode = 0o644, mtime = 0 } = {}) { | |
| 52 | + | if (Buffer.byteLength(path) > 100) throw new Error(`a path this layer cannot hold: ${path}`); | |
| 53 | + | const header = Buffer.alloc(512); | |
| 54 | + | header.write(path, 0, 100, "utf8"); | |
| 55 | + | header.write(octal(mode, 8), 100, 8, "ascii"); | |
| 56 | + | header.write(octal(0, 8), 108, 8, "ascii"); // uid | |
| 57 | + | header.write(octal(0, 8), 116, 8, "ascii"); // gid | |
| 58 | + | header.write(octal(data.length, 12), 124, 12, "ascii"); | |
| 59 | + | header.write(octal(Math.floor(mtime), 12), 136, 12, "ascii"); | |
| 60 | + | header.write(" ", 148, 8, "ascii"); // the checksum, as spaces while it is summed | |
| 61 | + | header.write("0", 156, 1, "ascii"); // a regular file | |
| 62 | + | header.write("ustar\0", 257, 6, "ascii"); | |
| 63 | + | header.write("00", 263, 2, "ascii"); | |
| 64 | + | header.write("root", 265, 32, "ascii"); | |
| 65 | + | header.write("root", 297, 32, "ascii"); | |
| 66 | + | let sum = 0; | |
| 67 | + | for (const byte of header) sum += byte; | |
| 68 | + | header.write(`${sum.toString(8).padStart(6, "0")}\0 `, 148, 8, "ascii"); | |
| 69 | + | const padding = Buffer.alloc((512 - (data.length % 512)) % 512); | |
| 70 | + | return Buffer.concat([header, data, padding, Buffer.alloc(1024)]); | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | /** | |
| 74 | + | * The runner's layer: the binary at its path and mode, gzipped. Returns | |
| 75 | + | * the compressed bytes with their digest and size, and the uncompressed | |
| 76 | + | * tar's digest (the config's diff_id). | |
| 77 | + | */ | |
| 78 | + | export function runnerLayer(binary, { mtime = 0 } = {}) { | |
| 79 | + | const tar = tarOf(RUNNER_BINARY_PATH, binary, { mode: RUNNER_BINARY_MODE, mtime }); | |
| 80 | + | const bytes = gzipSync(tar, { level: 9 }); | |
| 81 | + | return { bytes, digest: sha256(bytes), size: bytes.length, diffId: sha256(tar) }; | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | /** | |
| 85 | + | * The runner image's config from the base's: one more layer, the | |
| 86 | + | * entrypoint, and the history a Dockerfile build would add. | |
| 87 | + | */ | |
| 88 | + | export function runnerConfig(base, { diffId, created }) { | |
| 89 | + | if (base.os !== "linux" || base.architecture !== "amd64") { | |
| 90 | + | throw new Error(`the base is ${base.os}/${base.architecture}, not linux/amd64`); | |
| 91 | + | } | |
| 92 | + | const config = structuredClone(base); | |
| 93 | + | config.created = created; | |
| 94 | + | config.config = { ...(config.config ?? {}), Entrypoint: [...RUNNER_ENTRYPOINT] }; | |
| 95 | + | // An ENTRYPOINT clears the CMD a Dockerfile inherited. | |
| 96 | + | delete config.config.Cmd; | |
| 97 | + | config.rootfs = { type: "layers", diff_ids: [...(base.rootfs?.diff_ids ?? []), diffId] }; | |
| 98 | + | config.history = [ | |
| 99 | + | ...(base.history ?? []), | |
| 100 | + | { created, created_by: `COPY --chmod=0755 g1t-runner /${RUNNER_BINARY_PATH} # g1t deploy`, comment: "scripts/deploy/registry.mjs" }, | |
| 101 | + | { created, created_by: `ENTRYPOINT ${JSON.stringify(RUNNER_ENTRYPOINT)}`, comment: "scripts/deploy/registry.mjs", empty_layer: true }, | |
| 102 | + | ]; | |
| 103 | + | return config; | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | /** The runner image's manifest: the base's layers, then the runner's. */ | |
| 107 | + | export function runnerManifest(base, { config, layer }) { | |
| 108 | + | const type = manifestType(base); | |
| 109 | + | if (!LAYER_TYPE[type]) throw new Error(`a manifest of a type this cannot extend: ${type}`); | |
| 110 | + | const manifest = { | |
| 111 | + | schemaVersion: 2, | |
| 112 | + | mediaType: type, | |
| 113 | + | config: { mediaType: base.config.mediaType, digest: config.digest, size: config.size }, | |
| 114 | + | layers: [...base.layers, { mediaType: LAYER_TYPE[type], digest: layer.digest, size: layer.size }], | |
| 115 | + | }; | |
| 116 | + | return manifest; | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | /** A manifest's type: its own, or (OCI lets it go unsaid) its config's. */ | |
| 120 | + | export function manifestType(manifest) { | |
| 121 | + | return manifest.mediaType ?? (manifest.config?.mediaType === "application/vnd.oci.image.config.v1+json" ? OCI_MANIFEST : DOCKER_MANIFEST); | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | /** A `WWW-Authenticate: Bearer realm=...,service=...,scope=...` challenge, or null. */ | |
| 125 | + | export function bearerChallenge(header) { | |
| 126 | + | if (!header || !/^bearer\s/i.test(header)) return null; | |
| 127 | + | const params = Object.fromEntries([...header.slice(7).matchAll(/(\w+)="([^"]*)"/g)].map((m) => [m[1].toLowerCase(), m[2]])); | |
| 128 | + | return params.realm ? params : null; | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | /** `host/name:tag`, `host/name@digest` or `host/name:tag@digest`, split. */ | |
| 132 | + | export function parseRef(ref) { | |
| 133 | + | const match = /^([^/]+)\/(.+?)(?::([^:/@]+))?(?:@(sha256:[0-9a-f]{64}))?$/.exec(ref); | |
| 134 | + | if (!match || (!match[3] && !match[4])) throw new Error(`not an image reference with a tag or digest: ${ref}`); | |
| 135 | + | const [, host, name, tag, digest] = match; | |
| 136 | + | return { host, name, tag: tag ?? null, digest: digest ?? null }; | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | /** | |
| 140 | + | * A client for one repository: requests carry Basic auth from | |
| 141 | + | * `credentials` (none when it returns null), or the bearer token a | |
| 142 | + | * registry that asks for one gives for them; paths are under /v2/<name>/. | |
| 143 | + | */ | |
| 144 | + | export function repositoryClient(host, name, { credentials, fetchImpl = fetch, scheme = "https" }) { | |
| 145 | + | const root = `${scheme}://${host}`; | |
| 146 | + | let bearer = null; | |
| 147 | + | const basic = async () => { | |
| 148 | + | const auth = await credentials(); | |
| 149 | + | return auth ? `Basic ${Buffer.from(`${auth.username}:${auth.password}`).toString("base64")}` : null; | |
| 150 | + | }; | |
| 151 | + | const once = async (method, url, headers, body) => { | |
| 152 | + | const authorization = bearer ? `Bearer ${bearer}` : await basic(); | |
| 153 | + | return fetchImpl(new URL(url, root).toString(), { method, headers: authorization ? { ...headers, authorization } : headers, body }); | |
| 154 | + | }; | |
| 155 | + | const send = async (method, url, { headers = {}, body } = {}) => { | |
| 156 | + | const response = await once(method, url, headers, body); | |
| 157 | + | const challenge = response.status === 401 ? bearerChallenge(response.headers.get("www-authenticate")) : null; | |
| 158 | + | if (!challenge) return response; | |
| 159 | + | // The token flow: the realm trades the credentials for a token. | |
| 160 | + | const realm = new URL(challenge.realm); | |
| 161 | + | if (challenge.service) realm.searchParams.set("service", challenge.service); | |
| 162 | + | realm.searchParams.set("scope", challenge.scope ?? `repository:${name}:pull,push`); | |
| 163 | + | const authorization = await basic(); | |
| 164 | + | const got = await fetchImpl(realm.toString(), { method: "GET", headers: authorization ? { authorization } : {} }); | |
| 165 | + | if (!got.ok) return response; | |
| 166 | + | const json = await got.json(); | |
| 167 | + | bearer = json.token ?? json.access_token ?? null; | |
| 168 | + | return bearer ? once(method, url, headers, body) : response; | |
| 169 | + | }; | |
| 170 | + | const at = (path) => `${root}/v2/${name}/${path}`; | |
| 171 | + | const fail = async (what, response) => { | |
| 172 | + | let detail = ""; | |
| 173 | + | try { | |
| 174 | + | detail = (await response.text()).slice(0, 500); | |
| 175 | + | } catch {} | |
| 176 | + | return new Error(`${what}: the registry answered ${response.status}${detail ? ` ${detail}` : ""}`); | |
| 177 | + | }; | |
| 178 | + | return { | |
| 179 | + | name, | |
| 180 | + | async manifest(reference) { | |
| 181 | + | const response = await send("GET", at(`manifests/${reference}`), { headers: { accept: ACCEPT } }); | |
| 182 | + | if (!response.ok) throw await fail(`reading ${name} ${reference}`, response); | |
| 183 | + | const bytes = Buffer.from(await response.arrayBuffer()); | |
| 184 | + | return { bytes, digest: sha256(bytes), json: JSON.parse(bytes.toString("utf8")) }; | |
| 185 | + | }, | |
| 186 | + | async blob(digest) { | |
| 187 | + | const response = await send("GET", at(`blobs/${digest}`)); | |
| 188 | + | if (!response.ok) throw await fail(`reading blob ${digest}`, response); | |
| 189 | + | const bytes = Buffer.from(await response.arrayBuffer()); | |
| 190 | + | if (sha256(bytes) !== digest) throw new Error(`blob ${digest} came back as ${sha256(bytes)}`); | |
| 191 | + | return bytes; | |
| 192 | + | }, | |
| 193 | + | async hasBlob(digest) { | |
| 194 | + | const response = await send("HEAD", at(`blobs/${digest}`)); | |
| 195 | + | if (response.status === 404) return false; | |
| 196 | + | if (!response.ok) throw await fail(`asking for blob ${digest}`, response); | |
| 197 | + | return true; | |
| 198 | + | }, | |
| 199 | + | /** Uploads `bytes` unless the repository has them. Returns whether it did. */ | |
| 200 | + | async putBlob(bytes, digest = sha256(bytes)) { | |
| 201 | + | if (await this.hasBlob(digest)) return false; | |
| 202 | + | const started = await send("POST", at("blobs/uploads/")); | |
| 203 | + | if (started.status !== 202) throw await fail(`starting an upload of ${digest}`, started); | |
| 204 | + | const location = started.headers.get("location"); | |
| 205 | + | if (!location) throw new Error(`the registry started an upload of ${digest} without saying where`); | |
| 206 | + | const patched = await send("PATCH", location, { | |
| 207 | + | headers: { "content-type": "application/octet-stream" }, | |
| 208 | + | body: bytes, | |
| 209 | + | }); | |
| 210 | + | if (patched.status !== 202) throw await fail(`uploading ${digest}`, patched); | |
| 211 | + | const done = new URL(patched.headers.get("location") ?? location, root); | |
| 212 | + | done.searchParams.set("digest", digest); | |
| 213 | + | const put = await send("PUT", done.toString()); | |
| 214 | + | if (put.status !== 201) throw await fail(`finishing the upload of ${digest}`, put); | |
| 215 | + | return true; | |
| 216 | + | }, | |
| 217 | + | async putManifest(tag, bytes, type) { | |
| 218 | + | const response = await send("PUT", at(`manifests/${tag}`), { headers: { "content-type": type }, body: bytes }); | |
| 219 | + | if (response.status !== 201) throw await fail(`writing ${name}:${tag}`, response); | |
| 220 | + | return response.headers.get("docker-content-digest"); | |
| 221 | + | }, | |
| 222 | + | }; | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | /** | |
| 226 | + | * Makes the runner's image `ref` in the registry from the base `base` (by | |
| 227 | + | * digest, as base.json records it) and the binary's bytes. Returns the new | |
| 228 | + | * manifest's digest. The base must be in the same registry; its layers | |
| 229 | + | * are named, not copied, so it must be in the same repository too. | |
| 230 | + | */ | |
| 231 | + | export async function appendRunnerImage({ | |
| 232 | + | ref, | |
| 233 | + | base, | |
| 234 | + | binary, | |
| 235 | + | credentials, | |
| 236 | + | fetchImpl = fetch, | |
| 237 | + | scheme = "https", | |
| 238 | + | created = new Date().toISOString(), | |
| 239 | + | onLine = () => {}, | |
| 240 | + | attempts = 3, | |
| 241 | + | wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), | |
| 242 | + | }) { | |
| 243 | + | const target = parseRef(ref); | |
| 244 | + | const from = parseRef(base); | |
| 245 | + | if (!target.tag) throw new Error(`the runner's image needs a tag: ${ref}`); | |
| 246 | + | if (from.host !== target.host || from.name !== target.name) { | |
| 247 | + | throw new Error(`the base ${base} is not in the runner's repository ${target.host}/${target.name}`); | |
| 248 | + | } | |
| 249 | + | const client = repositoryClient(target.host, target.name, { credentials, fetchImpl, scheme }); | |
| 250 | + | ||
| 251 | + | let manifest = await client.manifest(from.digest ?? from.tag); | |
| 252 | + | if (from.digest && manifest.digest !== from.digest) throw new Error(`the base's manifest came back as ${manifest.digest}, not ${from.digest}`); | |
| 253 | + | const listed = manifest.json.mediaType; | |
| 254 | + | if (listed === OCI_INDEX || listed === DOCKER_LIST) { | |
| 255 | + | // An index: its linux/amd64 image, as `docker build --platform linux/amd64` would pick. | |
| 256 | + | const entry = manifest.json.manifests.find((m) => m.platform?.os === "linux" && m.platform?.architecture === "amd64"); | |
| 257 | + | if (!entry) throw new Error(`the base ${base} has no linux/amd64 image`); | |
| 258 | + | manifest = await client.manifest(entry.digest); | |
| 259 | + | } | |
| 260 | + | const baseConfig = JSON.parse((await client.blob(manifest.json.config.digest)).toString("utf8")); | |
| 261 | + | // Every layer the new manifest names must be there. | |
| 262 | + | for (const layer of manifest.json.layers) { | |
| 263 | + | if (!(await client.hasBlob(layer.digest))) throw new Error(`the base's layer ${layer.digest} is not in ${target.name}`); | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | const layer = runnerLayer(binary, { mtime: Date.parse(created) / 1000 }); | |
| 267 | + | const configBytes = Buffer.from(JSON.stringify(runnerConfig(baseConfig, { diffId: layer.diffId, created }))); | |
| 268 | + | const config = { digest: sha256(configBytes), size: configBytes.length }; | |
| 269 | + | const type = manifestType(manifest.json); | |
| 270 | + | const manifestBytes = Buffer.from(JSON.stringify(runnerManifest(manifest.json, { config, layer }))); | |
| 271 | + | const digest = sha256(manifestBytes); | |
| 272 | + | ||
| 273 | + | onLine(`${target.tag}: the binary's layer ${layer.digest.slice(7, 19)}, ${(layer.size / 1e6).toFixed(1)} MB, on ${manifest.json.layers.length} layers of the base`); | |
| 274 | + | for (let attempt = 1; ; attempt++) { | |
| 275 | + | try { | |
| 276 | + | if (await client.putBlob(layer.bytes, layer.digest)) onLine(`uploaded the binary's layer`); | |
| 277 | + | await client.putBlob(configBytes, config.digest); | |
| 278 | + | const answered = await client.putManifest(target.tag, manifestBytes, type); | |
| 279 | + | if (answered && answered !== digest) throw new Error(`the registry kept ${target.tag} as ${answered}, not ${digest}`); | |
| 280 | + | onLine(`${target.tag}: ${digest}`); | |
| 281 | + | return digest; | |
| 282 | + | } catch (error) { | |
| 283 | + | // Cloudflare's registry can answer "blob unknown" for a blob it has | |
| 284 | + | // just taken; asking again finds it there. | |
| 285 | + | if (attempt >= attempts) throw error; | |
| 286 | + | onLine(`making ${target.tag} in the registry did not finish (attempt ${attempt} of ${attempts}): ${error.message ?? error}; trying again`); | |
| 287 | + | await wait(2000 * attempt); | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | } |
| 1 | + | // The runner's image made in the registry (registry.mjs): its layer, | |
| 2 | + | // config and manifest, and the calls it makes, against a registry kept in | |
| 3 | + | // memory. | |
| 4 | + | import assert from "node:assert/strict"; | |
| 5 | + | import { readFileSync } from "node:fs"; | |
| 6 | + | import { join } from "node:path"; | |
| 7 | + | import { test } from "node:test"; | |
| 8 | + | import { gunzipSync } from "node:zlib"; | |
| 9 | + | ||
| 10 | + | import { | |
| 11 | + | DOCKER_MANIFEST, | |
| 12 | + | OCI_MANIFEST, | |
| 13 | + | RUNNER_BINARY_MODE, | |
| 14 | + | RUNNER_BINARY_PATH, | |
| 15 | + | RUNNER_ENTRYPOINT, | |
| 16 | + | appendRunnerImage, | |
| 17 | + | bearerChallenge, | |
| 18 | + | parseRef, | |
| 19 | + | runnerConfig, | |
| 20 | + | runnerLayer, | |
| 21 | + | runnerManifest, | |
| 22 | + | sha256, | |
| 23 | + | tarOf, | |
| 24 | + | } from "./registry.mjs"; | |
| 25 | + | import { ROOT } from "./stack.mjs"; | |
| 26 | + | ||
| 27 | + | /** A tar's entries: name, mode, size, owner, type and bytes, checksums checked. */ | |
| 28 | + | function untar(tar) { | |
| 29 | + | const entries = []; | |
| 30 | + | for (let at = 0; at + 512 <= tar.length; ) { | |
| 31 | + | const header = tar.subarray(at, at + 512); | |
| 32 | + | if (header.every((b) => b === 0)) break; | |
| 33 | + | const field = (start, length) => header.subarray(start, start + length).toString("ascii").replace(/\0.*$/s, "").trim(); | |
| 34 | + | const summed = [...header].reduce((sum, byte, i) => sum + (i >= 148 && i < 156 ? 32 : byte), 0); | |
| 35 | + | assert.equal(Number.parseInt(field(148, 8), 8), summed, "the header's checksum"); | |
| 36 | + | assert.equal(field(257, 6), "ustar"); | |
| 37 | + | const size = Number.parseInt(field(124, 12), 8); | |
| 38 | + | entries.push({ | |
| 39 | + | name: field(0, 100), | |
| 40 | + | mode: Number.parseInt(field(100, 8), 8), | |
| 41 | + | uid: Number.parseInt(field(108, 8), 8), | |
| 42 | + | gid: Number.parseInt(field(116, 8), 8), | |
| 43 | + | mtime: Number.parseInt(field(136, 12), 8), | |
| 44 | + | type: field(156, 1), | |
| 45 | + | data: tar.subarray(at + 512, at + 512 + size), | |
| 46 | + | }); | |
| 47 | + | at += 512 + Math.ceil(size / 512) * 512; | |
| 48 | + | } | |
| 49 | + | return entries; | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | test("the runner's layer is the binary alone, at its path and mode, owned by root", () => { | |
| 53 | + | const binary = Buffer.from("\x7fELF a runner, of a length that is not a whole block"); | |
| 54 | + | const layer = runnerLayer(binary, { mtime: 1_760_000_000 }); | |
| 55 | + | const tar = gunzipSync(layer.bytes); | |
| 56 | + | assert.equal(tar.length % 512, 0); | |
| 57 | + | assert.equal(layer.digest, sha256(layer.bytes)); | |
| 58 | + | assert.equal(layer.size, layer.bytes.length); | |
| 59 | + | assert.equal(layer.diffId, sha256(tar)); | |
| 60 | + | const entries = untar(tar); | |
| 61 | + | assert.equal(entries.length, 1); | |
| 62 | + | const [entry] = entries; | |
| 63 | + | assert.deepEqual( | |
| 64 | + | { name: entry.name, mode: entry.mode, uid: entry.uid, gid: entry.gid, mtime: entry.mtime, type: entry.type }, | |
| 65 | + | { name: "usr/local/bin/g1t-runner", mode: 0o755, uid: 0, gid: 0, mtime: 1_760_000_000, type: "0" }, | |
| 66 | + | ); | |
| 67 | + | assert.ok(entry.data.equals(binary)); | |
| 68 | + | // The same bytes make the same layer. | |
| 69 | + | assert.equal(runnerLayer(binary, { mtime: 1_760_000_000 }).digest, layer.digest); | |
| 70 | + | // An exact number of blocks, and an empty file. | |
| 71 | + | assert.equal(untar(tarOf("a", Buffer.alloc(512, 1)))[0].data.length, 512); | |
| 72 | + | assert.equal(untar(tarOf("a", Buffer.alloc(0)))[0].data.length, 0); | |
| 73 | + | }); | |
| 74 | + | ||
| 75 | + | test("the layer and entrypoint are what services/runner/Dockerfile builds", () => { | |
| 76 | + | const dockerfile = readFileSync(join(ROOT, "services/runner/Dockerfile"), "utf8"); | |
| 77 | + | const copy = /^COPY --chmod=(\d+) g1t-runner (\S+)$/m.exec(dockerfile); | |
| 78 | + | assert.ok(copy, "the Dockerfile copies the binary in"); | |
| 79 | + | assert.equal(Number.parseInt(copy[1], 8), RUNNER_BINARY_MODE); | |
| 80 | + | assert.equal(copy[2], `/${RUNNER_BINARY_PATH}`); | |
| 81 | + | const entrypoint = /^ENTRYPOINT (.+)$/m.exec(dockerfile); | |
| 82 | + | assert.deepEqual(JSON.parse(entrypoint[1]), RUNNER_ENTRYPOINT); | |
| 83 | + | // Nothing else that would change the image. | |
| 84 | + | const instructions = dockerfile.split(/\r?\n/).filter((line) => line.trim() && !line.startsWith("#")).map((line) => line.split(" ")[0]); | |
| 85 | + | assert.deepEqual(instructions, ["ARG", "FROM", "COPY", "ENTRYPOINT"]); | |
| 86 | + | }); | |
| 87 | + | ||
| 88 | + | const BASE_CONFIG = { | |
| 89 | + | architecture: "amd64", | |
| 90 | + | os: "linux", | |
| 91 | + | created: "2026-10-08T19:50:00Z", | |
| 92 | + | config: { Env: ["PATH=/usr/local/bin:/usr/bin"], Cmd: ["bash"], WorkingDir: "/home/runner", User: "root" }, | |
| 93 | + | rootfs: { type: "layers", diff_ids: ["sha256:" + "1".repeat(64), "sha256:" + "2".repeat(64)] }, | |
| 94 | + | history: [{ created_by: "FROM debian" }, { created_by: "RUN apt-get install" }], | |
| 95 | + | }; | |
| 96 | + | ||
| 97 | + | test("the config is the base's, with the layer, the entrypoint and no inherited command", () => { | |
| 98 | + | const before = structuredClone(BASE_CONFIG); | |
| 99 | + | const diffId = "sha256:" + "3".repeat(64); | |
| 100 | + | const config = runnerConfig(BASE_CONFIG, { diffId, created: "2026-10-13T00:00:00.000Z" }); | |
| 101 | + | assert.deepEqual(BASE_CONFIG, before, "the base's config is left as it was"); | |
| 102 | + | assert.deepEqual(config.config.Entrypoint, ["g1t-runner"]); | |
| 103 | + | assert.equal(config.config.Cmd, undefined); | |
| 104 | + | assert.deepEqual(config.config.Env, BASE_CONFIG.config.Env); | |
| 105 | + | assert.equal(config.config.WorkingDir, "/home/runner"); | |
| 106 | + | assert.deepEqual(config.rootfs.diff_ids, [...BASE_CONFIG.rootfs.diff_ids, diffId]); | |
| 107 | + | assert.equal(config.created, "2026-10-13T00:00:00.000Z"); | |
| 108 | + | // History: one entry per layer, plus one that adds none. | |
| 109 | + | assert.equal(config.history.filter((h) => !h.empty_layer).length - BASE_CONFIG.history.length, 1); | |
| 110 | + | assert.equal(config.history.at(-1).empty_layer, true); | |
| 111 | + | assert.throws(() => runnerConfig({ ...BASE_CONFIG, architecture: "arm64" }, { diffId, created: "x" }), /linux\/amd64/); | |
| 112 | + | }); | |
| 113 | + | ||
| 114 | + | test("the manifest names the base's layers, then the binary's, in the base's format", () => { | |
| 115 | + | const layers = [ | |
| 116 | + | { mediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip", digest: "sha256:" + "a".repeat(64), size: 10 }, | |
| 117 | + | { mediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip", digest: "sha256:" + "b".repeat(64), size: 20 }, | |
| 118 | + | ]; | |
| 119 | + | const config = { digest: "sha256:" + "c".repeat(64), size: 5 }; | |
| 120 | + | const layer = { digest: "sha256:" + "d".repeat(64), size: 7 }; | |
| 121 | + | const docker = runnerManifest( | |
| 122 | + | { schemaVersion: 2, mediaType: DOCKER_MANIFEST, config: { mediaType: "application/vnd.docker.container.image.v1+json", digest: "x", size: 1 }, layers }, | |
| 123 | + | { config, layer }, | |
| 124 | + | ); | |
| 125 | + | assert.deepEqual(docker, { | |
| 126 | + | schemaVersion: 2, | |
| 127 | + | mediaType: DOCKER_MANIFEST, | |
| 128 | + | config: { mediaType: "application/vnd.docker.container.image.v1+json", ...config }, | |
| 129 | + | layers: [...layers, { mediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip", ...layer }], | |
| 130 | + | }); | |
| 131 | + | // OCI, with its type left unsaid. | |
| 132 | + | const oci = runnerManifest( | |
| 133 | + | { schemaVersion: 2, config: { mediaType: "application/vnd.oci.image.config.v1+json", digest: "x", size: 1 }, layers: [], annotations: { a: "b" } }, | |
| 134 | + | { config, layer }, | |
| 135 | + | ); | |
| 136 | + | assert.equal(oci.mediaType, OCI_MANIFEST); | |
| 137 | + | assert.deepEqual(oci.layers, [{ mediaType: "application/vnd.oci.image.layer.v1.tar+gzip", ...layer }]); | |
| 138 | + | assert.equal(oci.annotations, undefined); | |
| 139 | + | }); | |
| 140 | + | ||
| 141 | + | test("image references and bearer challenges are read", () => { | |
| 142 | + | const digest = "sha256:" + "e".repeat(64); | |
| 143 | + | assert.deepEqual(parseRef("registry.cloudflare.com/acct/g1t-runner:abc"), { host: "registry.cloudflare.com", name: "acct/g1t-runner", tag: "abc", digest: null }); | |
| 144 | + | assert.deepEqual(parseRef(`localhost:5000/g1t-runner:base-1@${digest}`), { host: "localhost:5000", name: "g1t-runner", tag: "base-1", digest }); | |
| 145 | + | assert.deepEqual(parseRef(`r.example/x@${digest}`), { host: "r.example", name: "x", tag: null, digest }); | |
| 146 | + | assert.throws(() => parseRef("r.example/x")); | |
| 147 | + | assert.deepEqual(bearerChallenge('Bearer realm="https://auth.example/token",service="registry",scope="repository:x:pull"'), { | |
| 148 | + | realm: "https://auth.example/token", | |
| 149 | + | service: "registry", | |
| 150 | + | scope: "repository:x:pull", | |
| 151 | + | }); | |
| 152 | + | assert.equal(bearerChallenge('Basic realm="x"'), null); | |
| 153 | + | assert.equal(bearerChallenge(null), null); | |
| 154 | + | }); | |
| 155 | + | ||
| 156 | + | /** A registry in memory: one repository, Basic auth, uploads by POST, PATCH and PUT. */ | |
| 157 | + | function fakeRegistry({ name, auth = "Basic " + Buffer.from("u:p").toString("base64") }) { | |
| 158 | + | const blobs = new Map(); | |
| 159 | + | const manifests = new Map(); | |
| 160 | + | const uploads = new Map(); | |
| 161 | + | const calls = []; | |
| 162 | + | let next = 0; | |
| 163 | + | const reply = (status, { headers = {}, body = null } = {}) => ({ | |
| 164 | + | status, | |
| 165 | + | ok: status >= 200 && status < 300, | |
| 166 | + | headers: new Headers(headers), | |
| 167 | + | arrayBuffer: async () => (body ? body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength) : new ArrayBuffer(0)), | |
| 168 | + | text: async () => (body ? body.toString("utf8") : ""), | |
| 169 | + | json: async () => JSON.parse(body.toString("utf8")), | |
| 170 | + | }); | |
| 171 | + | const fetchImpl = async (url, init) => { | |
| 172 | + | const { pathname, searchParams } = new URL(url); | |
| 173 | + | calls.push(`${init.method} ${pathname}`); | |
| 174 | + | if (init.headers.authorization !== auth) return reply(401); | |
| 175 | + | const prefix = `/v2/${name}/`; | |
| 176 | + | if (!pathname.startsWith(prefix)) return reply(404); | |
| 177 | + | const path = pathname.slice(prefix.length); | |
| 178 | + | let match; | |
| 179 | + | if ((match = /^manifests\/(.+)$/.exec(path))) { | |
| 180 | + | if (init.method === "PUT") { | |
| 181 | + | const bytes = Buffer.from(init.body); | |
| 182 | + | const digest = sha256(bytes); | |
| 183 | + | for (const ref of [...JSON.parse(bytes).layers, JSON.parse(bytes).config]) { | |
| 184 | + | if (!blobs.has(ref.digest)) return reply(400, { body: Buffer.from('{"errors":[{"code":"MANIFEST_BLOB_UNKNOWN"}]}') }); | |
| 185 | + | } | |
| 186 | + | manifests.set(match[1], { bytes, type: init.headers["content-type"] }); | |
| 187 | + | manifests.set(digest, { bytes, type: init.headers["content-type"] }); | |
| 188 | + | return reply(201, { headers: { "docker-content-digest": digest } }); | |
| 189 | + | } | |
| 190 | + | const found = manifests.get(match[1]); | |
| 191 | + | return found ? reply(200, { headers: { "content-type": found.type }, body: found.bytes }) : reply(404); | |
| 192 | + | } | |
| 193 | + | if ((match = /^blobs\/(sha256:[0-9a-f]{64})$/.exec(path))) { | |
| 194 | + | const found = blobs.get(match[1]); | |
| 195 | + | if (!found) return reply(404); | |
| 196 | + | return init.method === "HEAD" ? reply(200) : reply(200, { body: found }); | |
| 197 | + | } | |
| 198 | + | if (path === "blobs/uploads/" && init.method === "POST") { | |
| 199 | + | const id = `u${next++}`; | |
| 200 | + | uploads.set(id, Buffer.alloc(0)); | |
| 201 | + | return reply(202, { headers: { location: `${prefix}blobs/uploads/${id}` } }); | |
| 202 | + | } | |
| 203 | + | if ((match = /^blobs\/uploads\/(u\d+)$/.exec(path))) { | |
| 204 | + | const id = match[1]; | |
| 205 | + | if (init.method === "PATCH") { | |
| 206 | + | uploads.set(id, Buffer.concat([uploads.get(id), Buffer.from(init.body)])); | |
| 207 | + | return reply(202, { headers: { location: `${prefix}blobs/uploads/${id}?state=1` } }); | |
| 208 | + | } | |
| 209 | + | if (init.method === "PUT") { | |
| 210 | + | const bytes = uploads.get(id); | |
| 211 | + | if (sha256(bytes) !== searchParams.get("digest")) return reply(400); | |
| 212 | + | blobs.set(searchParams.get("digest"), bytes); | |
| 213 | + | return reply(201); | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | return reply(405); | |
| 217 | + | }; | |
| 218 | + | return { blobs, manifests, calls, fetchImpl }; | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | test("the runner's image is made in the registry from the base's manifest and one new layer, pulling none of the base", async () => { | |
| 222 | + | const registry = fakeRegistry({ name: "acct/g1t-runner" }); | |
| 223 | + | // The base, as `docker push` left it. | |
| 224 | + | const layerBlobs = [Buffer.from("base layer one"), Buffer.from("base layer two")]; | |
| 225 | + | for (const blob of layerBlobs) registry.blobs.set(sha256(blob), blob); | |
| 226 | + | const configBytes = Buffer.from(JSON.stringify(BASE_CONFIG)); | |
| 227 | + | registry.blobs.set(sha256(configBytes), configBytes); | |
| 228 | + | const baseManifest = Buffer.from( | |
| 229 | + | JSON.stringify({ | |
| 230 | + | schemaVersion: 2, | |
| 231 | + | mediaType: OCI_MANIFEST, | |
| 232 | + | config: { mediaType: "application/vnd.oci.image.config.v1+json", digest: sha256(configBytes), size: configBytes.length }, | |
| 233 | + | layers: layerBlobs.map((blob) => ({ mediaType: "application/vnd.oci.image.layer.v1.tar+gzip", digest: sha256(blob), size: blob.length })), | |
| 234 | + | }), | |
| 235 | + | ); | |
| 236 | + | registry.manifests.set(sha256(baseManifest), { bytes: baseManifest, type: OCI_MANIFEST }); | |
| 237 | + | const base = `registry.cloudflare.com/acct/g1t-runner:base-20261008-a314499af03b@${sha256(baseManifest)}`; | |
| 238 | + | const ref = "registry.cloudflare.com/acct/g1t-runner:0123456789abcdef"; | |
| 239 | + | const binary = Buffer.from("the runner binary"); | |
| 240 | + | const credentials = async () => ({ username: "u", password: "p" }); | |
| 241 | + | ||
| 242 | + | const digest = await appendRunnerImage({ ref, base, binary, credentials, fetchImpl: registry.fetchImpl, created: "2026-10-13T00:00:00.000Z" }); | |
| 243 | + | const made = registry.manifests.get("0123456789abcdef"); | |
| 244 | + | assert.equal(sha256(made.bytes), digest); | |
| 245 | + | assert.equal(made.type, OCI_MANIFEST); | |
| 246 | + | const manifest = JSON.parse(made.bytes); | |
| 247 | + | assert.deepEqual(manifest.layers.slice(0, 2).map((l) => l.digest), layerBlobs.map(sha256)); | |
| 248 | + | assert.equal(manifest.layers.length, 3); | |
| 249 | + | const config = JSON.parse(registry.blobs.get(manifest.config.digest)); | |
| 250 | + | assert.deepEqual(config.config.Entrypoint, ["g1t-runner"]); | |
| 251 | + | assert.equal(config.rootfs.diff_ids.at(-1), sha256(gunzipSync(registry.blobs.get(manifest.layers[2].digest)))); | |
| 252 | + | assert.ok(untar(gunzipSync(registry.blobs.get(manifest.layers[2].digest)))[0].data.equals(binary)); | |
| 253 | + | // The base's layers were asked about, never downloaded. | |
| 254 | + | for (const blob of layerBlobs) assert.ok(!registry.calls.includes(`GET /v2/acct/g1t-runner/blobs/${sha256(blob)}`)); | |
| 255 | + | ||
| 256 | + | // Made again: the layer is already there, so only the config and manifest are written. | |
| 257 | + | const before = registry.calls.length; | |
| 258 | + | await appendRunnerImage({ ref, base, binary, credentials, fetchImpl: registry.fetchImpl, created: "2026-10-13T00:00:00.000Z" }); | |
| 259 | + | assert.ok(!registry.calls.slice(before).some((call) => call.startsWith("PATCH"))); | |
| 260 | + | }); | |
| 261 | + | ||
| 262 | + | test("a base that is not where it should be, or not what base.json says, stops it", async () => { | |
| 263 | + | const registry = fakeRegistry({ name: "acct/g1t-runner" }); | |
| 264 | + | const credentials = async () => ({ username: "u", password: "p" }); | |
| 265 | + | const digest = "sha256:" + "f".repeat(64); | |
| 266 | + | const options = { binary: Buffer.from("x"), credentials, fetchImpl: registry.fetchImpl, attempts: 1 }; | |
| 267 | + | await assert.rejects( | |
| 268 | + | appendRunnerImage({ ...options, ref: "registry.cloudflare.com/acct/g1t-runner:t", base: `registry.cloudflare.com/acct/other:b@${digest}` }), | |
| 269 | + | /not in the runner's repository/, | |
| 270 | + | ); | |
| 271 | + | await assert.rejects(appendRunnerImage({ ...options, ref: "registry.cloudflare.com/acct/g1t-runner:t", base: `registry.cloudflare.com/acct/g1t-runner:b@${digest}` }), /404/); | |
| 272 | + | // Wrong credentials: the registry's answer is the error. | |
| 273 | + | await assert.rejects( | |
| 274 | + | appendRunnerImage({ ...options, credentials: async () => ({ username: "u", password: "wrong" }), ref: "registry.cloudflare.com/acct/g1t-runner:t", base: `registry.cloudflare.com/acct/g1t-runner:b@${digest}` }), | |
| 275 | + | /401/, | |
| 276 | + | ); | |
| 277 | + | }); | |
| 278 | + | ||
| 279 | + | test("a registry that asks for a bearer token is given the one its realm trades the credentials for", async () => { | |
| 280 | + | const registry = fakeRegistry({ name: "acct/r", auth: "Bearer tok" }); | |
| 281 | + | const inner = registry.fetchImpl; | |
| 282 | + | const asked = []; | |
| 283 | + | const fetchImpl = async (url, init) => { | |
| 284 | + | if (url.startsWith("https://auth.example/")) { | |
| 285 | + | asked.push([url, init.headers.authorization]); | |
| 286 | + | return { ok: true, status: 200, json: async () => ({ token: "tok" }) }; | |
| 287 | + | } | |
| 288 | + | const answer = await inner(url, init); | |
| 289 | + | if (answer.status === 401) answer.headers.set("www-authenticate", 'Bearer realm="https://auth.example/token",service="r.example"'); | |
| 290 | + | return answer; | |
| 291 | + | }; | |
| 292 | + | const config = Buffer.from(JSON.stringify(BASE_CONFIG)); | |
| 293 | + | registry.blobs.set(sha256(config), config); | |
| 294 | + | const manifest = Buffer.from( | |
| 295 | + | JSON.stringify({ schemaVersion: 2, mediaType: DOCKER_MANIFEST, config: { mediaType: "application/vnd.docker.container.image.v1+json", digest: sha256(config), size: config.length }, layers: [] }), | |
| 296 | + | ); | |
| 297 | + | registry.manifests.set("base", { bytes: manifest, type: DOCKER_MANIFEST }); | |
| 298 | + | await appendRunnerImage({ | |
| 299 | + | ref: "r.example/acct/r:new", | |
| 300 | + | base: "r.example/acct/r:base", | |
| 301 | + | binary: Buffer.from("x"), | |
| 302 | + | credentials: async () => ({ username: "u", password: "p" }), | |
| 303 | + | fetchImpl, | |
| 304 | + | }); | |
| 305 | + | assert.ok(registry.manifests.has("new")); | |
| 306 | + | assert.equal(asked.length, 1, "one token, kept"); | |
| 307 | + | assert.equal(asked[0][0], "https://auth.example/token?service=r.example&scope=repository%3Aacct%2Fr%3Apull%2Cpush"); | |
| 308 | + | assert.equal(asked[0][1], `Basic ${Buffer.from("u:p").toString("base64")}`); | |
| 309 | + | }); |
| 6 | 6 | # runner, a static Linux binary built outside Docker by | |
| 7 | 7 | # scripts/build-runner.mjs. | |
| 8 | 8 | # | |
| 9 | − | # Built by scripts/deploy.mjs, which passes BASE (the image recorded in | |
| 10 | − | # services/runner/base.json) and uses a build context holding only the | |
| 11 | − | # binary, so this takes seconds. docs/DEPLOYING.md explains the two images. | |
| 9 | + | # Deploys do not build this file: scripts/deploy/registry.mjs makes the | |
| 10 | + | # same image in the registry, adding one layer with the binary to the | |
| 11 | + | # base's layers (the same path, mode and entrypoint; a test in | |
| 12 | + | # scripts/deploy holds the two together), so a deploy job never pulls the | |
| 13 | + | # base. scripts/deploy.mjs builds it with Docker for a dry run, or when | |
| 14 | + | # that fails, passing BASE (the image recorded in services/runner/base.json) | |
| 15 | + | # and a build context holding only the binary. | |
| 12 | 16 | ||
| 13 | 17 | ARG BASE | |
| 14 | 18 | FROM ${BASE} |