diff --git a/src/lifecycle/base.ts b/src/lifecycle/base.ts index 168b24c..6f556ef 100644 --- a/src/lifecycle/base.ts +++ b/src/lifecycle/base.ts @@ -54,6 +54,19 @@ export async function buildBaseImage( log(" installing a container runtime"); await incus(["exec", BUILDER, "--", "pacman", "-Sy", "--noconfirm", "docker"], 600_000); + + // Trust the documentation ranges as plain-HTTP registries. + // + // A scenario's registry is scenery inside the scenario, serving over HTTP, and a runtime + // will not pull from one without being told. Scoped to RFC 5737 and RFC 3849 ranges rather + // than a specific address, because those never route on the real internet — so this cannot + // make a real machine trust a real registry, whatever it is copied onto. + await incus([ + "exec", BUILDER, "--", "sh", "-c", + `mkdir -p /etc/docker && printf '%s' '${JSON.stringify({ + "insecure-registries": ["192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24"], + })}' > /etc/docker/daemon.json`, + ], 60_000); await incus(["exec", BUILDER, "--", "systemctl", "enable", "docker"], 60_000); await incus(["exec", BUILDER, "--", "systemctl", "start", "docker"], 120_000); diff --git a/src/lifecycle/registry.ts b/src/lifecycle/registry.ts new file mode 100644 index 0000000..dd4b10a --- /dev/null +++ b/src/lifecycle/registry.ts @@ -0,0 +1,199 @@ +/** + * A registry inside the scenario. + * + * A sealed machine cannot reach a registry, and an image placed from an archive cannot keep its + * digest — `docker save` of a digest reference produces an archive with no repo tag, because a + * repo digest only exists for an image a registry served (novox/hq 04-ISSUES/009). So an image + * pinned by digest, which is the only kind the host accepts + * ([ADR 0046](../../02-DECISIONS/0046-the-installer-fetches-what-it-pins.md)), could not be + * placed at all. + * + * The answer is a registry, and it is not a workaround for the lab: ADR 0048 names an OCI + * registry as substrate, and ADR 0046 says a first node fetches "upstream, wherever the image + * ordinarily lives". **This is that upstream** — scenery, like the transit router is the + * internet ([ADR 0033](../../02-DECISIONS/0033-a-router-is-scenery-not-a-node.md)). + * + * The digests it serves are its own, not Docker Hub's, and that is correct rather than a + * compromise. What ADR 0046 requires is a reference that is exact and cannot move. A digest + * assigned by this registry is both. + */ + +import { spawn } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** The image the registry itself runs from. Placed by tag, which archives keep. */ +export const REGISTRY_IMAGE = "registry:2"; + +/** Where the registry serves, inside its machine. */ +export const REGISTRY_PORT = 5000; + +export class RegistryError extends Error { + constructor(message: string) { + super(message); + this.name = "RegistryError"; + } +} + +export interface StockedImage { + /** What the scenario asked for, as written. */ + requested: string; + /** The repository path the registry serves it under. */ + repository: string; + /** The digest THIS registry assigned. What a declaration pins. */ + digest: string; +} + +export interface Stock { + /** A directory holding the registry's data, ready to be placed in a machine. */ + dataDir: string; + images: StockedImage[]; +} + +/** + * Build a registry's data directory on this workstation, with the given images in it. + * + * Runs a throwaway registry here — where there IS a network — pushes into it, and keeps what + * it wrote. Research 012's reframing again: fetch at build time on a machine that has a + * network, apply on a target that needs nothing. + * + * The caller owns the returned directory and must remove it. + */ +export async function stockRegistry( + references: string[], + log: (message: string) => void = () => {}, +): Promise { + if (references.length === 0) return { dataDir: "", images: [] }; + + const dataDir = await mkdtemp(join(tmpdir(), "mesh-lab-registry-")); + const container = `mesh-lab-stock-${process.pid}`; + const port = 5000 + (process.pid % 1000); + + await docker(["rm", "-f", container], 60_000); + const started = await docker( + ["run", "-d", "--name", container, "-p", `${port}:5000`, "-v", `${dataDir}:/var/lib/registry`, + REGISTRY_IMAGE], + 300_000, + ); + if (!started.ok) { + await rm(dataDir, { recursive: true, force: true }); + throw new RegistryError( + `cannot run ${REGISTRY_IMAGE} on this workstation to stock a registry: ${started.stderr.trim()}`, + ); + } + + try { + await waitForRegistry(port); + const images: StockedImage[] = []; + + for (const reference of references) { + // The repository path a machine will pull from. A tag is dropped: what a declaration + // pins is the digest, and carrying the tag as well would invite pinning the wrong one. + const repository = repositoryFor(reference); + const target = `localhost:${port}/${repository}`; + + const tagged = await docker(["tag", reference, target], 60_000); + if (!tagged.ok) { + throw new RegistryError( + `${reference} is not on this workstation, and the lab does not fetch on a scenario's ` + + `behalf. Pull it here first.\n ${tagged.stderr.trim()}`, + ); + } + const pushed = await docker(["push", target], 900_000); + if (!pushed.ok) throw new RegistryError(`cannot push ${reference}: ${pushed.stderr.trim()}`); + + const digest = digestFrom(pushed.stdout + pushed.stderr); + if (!digest) { + throw new RegistryError( + `${reference} was pushed and the registry did not report a digest. Without one there ` + + `is nothing for a declaration to pin.`, + ); + } + images.push({ requested: reference, repository, digest }); + log(` stocked ${repository}@${digest}`); + } + + return { dataDir, images }; + } catch (err) { + await rm(dataDir, { recursive: true, force: true }); + throw err; + } finally { + await docker(["rm", "-f", container], 60_000); + } +} + +/** `alpine:3.20` and `alpine` both serve from `alpine`; `foo/bar:1` from `foo/bar`. */ +export function repositoryFor(reference: string): string { + const withoutDigest = reference.split("@")[0] ?? reference; + const lastColon = withoutDigest.lastIndexOf(":"); + const lastSlash = withoutDigest.lastIndexOf("/"); + return lastColon > lastSlash ? withoutDigest.slice(0, lastColon) : withoutDigest; +} + +/** `docker push` prints `: digest: sha256:… size: …` on its last useful line. */ +export function digestFrom(output: string): string | null { + const match = output.match(/digest:\s*(sha256:[a-f0-9]{64})/); + return match?.[1] ?? null; +} + +async function waitForRegistry(port: number): Promise { + for (let i = 0; i < 30; i++) { + const probe = await docker(["run", "--rm", "--network", "host", REGISTRY_IMAGE, + "sh", "-c", `wget -q -O- http://localhost:${port}/v2/ >/dev/null 2>&1`], 30_000); + if (probe.ok) return; + await new Promise((r) => setTimeout(r, 1_000)); + } + throw new RegistryError("a registry was started on this workstation and never answered"); +} + +function docker( + args: string[], + timeoutMs: number, +): Promise<{ ok: boolean; stdout: string; stderr: string }> { + return new Promise((resolve) => { + const child = spawn("docker", args, { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs); + child.stdout.on("data", (d) => (stdout += d)); + child.stderr.on("data", (d) => (stderr += d)); + child.on("error", (err) => { + clearTimeout(timer); + resolve({ ok: false, stdout, stderr: err.message }); + }); + child.on("close", (code) => { + clearTimeout(timer); + resolve({ ok: code === 0, stdout, stderr }); + }); + }); +} + +// --- the registry inside a scenario ------------------------------------------------------------ + +/** + * Where the registry sits on its segment. + * + * A convention rather than a declaration, like the router's. `.250` is chosen to sit well away + * from the low addresses scenarios give their machines, so a scenario can be written without + * thinking about it and a collision is obvious when it happens. + */ +export const REGISTRY_HOST_OCTET = 250; + +/** The address the registry answers on, given the segment it is attached to. */ +export function registryAddress(cidr: string): string { + const [network] = cidr.split("/"); + const parts = (network ?? "").split("."); + if (parts.length !== 4) { + throw new RegistryError( + `cannot place a registry on '${cidr}': it is not an IPv4 network, and the registry needs ` + + `an address a machine can be pointed at.`, + ); + } + return `${parts[0]}.${parts[1]}.${parts[2]}.${REGISTRY_HOST_OCTET}`; +} + +/** What a declaration should pin, once a scenario is raised. */ +export function pinnedReference(address: string, image: StockedImage): string { + return `${address}:${REGISTRY_PORT}/${image.repository}@${image.digest}`; +} diff --git a/test/registry.test.ts b/test/registry.test.ts new file mode 100644 index 0000000..36a4e67 --- /dev/null +++ b/test/registry.test.ts @@ -0,0 +1,66 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { digestFrom, pinnedReference, registryAddress, repositoryFor } from "../src/lifecycle/registry.ts"; + +/** + * The registry inside a scenario (novox/hq 04-ISSUES/009). + * + * These test the pure parts. The parts that need a registry are exercised by raising a + * scenario, because a fake registry would assert that the fake behaves as expected + * (novox/hq ADR 0034). + */ + +test("a digest is read from what the registry actually said", () => { + // The real shape of `docker push` output. The digest here is the REGISTRY's, not Docker + // Hub's, and that is the point: a declaration pins what this registry serves. + const output = + "The push refers to repository [localhost:5000/alpine]\n" + + "63f227048c13: Pushed\n" + + "3.20: digest: sha256:6c2a9711b0a9f32b0239d9222eb1072309cf46c6431d319ae249186d811a987c size: 528\n"; + assert.equal( + digestFrom(output), + "sha256:6c2a9711b0a9f32b0239d9222eb1072309cf46c6431d319ae249186d811a987c", + ); +}); + +test("no digest is not an empty digest", () => { + // A push that reported no digest leaves nothing for a declaration to pin, and inventing one + // would be worse than failing — the host would refuse it later, further from the cause. + assert.equal(digestFrom("The push refers to repository [localhost:5000/alpine]\n"), null); + assert.equal(digestFrom(""), null); + // Hex, but the wrong LENGTH. An earlier version used "tooshort", whose letters fall outside + // a-f — so it failed the character class and proved nothing about the length check. + assert.equal(digestFrom("digest: sha256:abc123"), null); + assert.equal(digestFrom("digest: sha256:" + "a".repeat(63)), null, "63 is not 64"); +}); + +test("the repository is the reference without its tag", () => { + assert.equal(repositoryFor("alpine:3.20"), "alpine"); + assert.equal(repositoryFor("alpine"), "alpine"); + assert.equal(repositoryFor("library/postgres:17"), "library/postgres"); + // A port in a hostname is a colon that is NOT a tag, and treating it as one would serve the + // image from a truncated path. + assert.equal(repositoryFor("localhost:5000/alpine:3.20"), "localhost:5000/alpine"); + assert.equal(repositoryFor("localhost:5000/alpine"), "localhost:5000/alpine"); +}); + +test("the registry's address is derived from its segment", () => { + assert.equal(registryAddress("192.0.2.0/24"), "192.0.2.250"); + assert.equal(registryAddress("198.51.100.0/24"), "198.51.100.250"); + // An IPv6-only segment cannot host it, and saying so beats producing an address nothing + // can be pointed at. + assert.throws(() => registryAddress("2001:db8:a::/48"), /not an IPv4 network/); +}); + +test("what a declaration pins is the registry's own digest", () => { + // Not Docker Hub's. ADR 0046 requires a reference that is exact and cannot move, and a + // digest this registry assigned is both. + const pinned = pinnedReference("192.0.2.250", { + requested: "alpine:3.20", + repository: "alpine", + digest: "sha256:" + "6".repeat(64), + }); + assert.equal(pinned, `192.0.2.250:5000/alpine@sha256:${"6".repeat(64)}`); + assert.ok(pinned.includes("@sha256:"), "the host refuses anything not pinned by digest"); + assert.ok(!pinned.includes(":3.20"), "a tag would move; the digest is what is pinned"); +});