/** * Building the base image a scenario's machines are raised from. * * A sealed scenario cannot install a container runtime: its segments use documentation ranges * and there is no route out (novox/hq ADR 0016). ADR 0006 records the consequence — *the lab * needs a way to place images, and the machine it places them into needs a container runtime, * which a sealed scenario cannot install either.* * * This is that, and it is research 012's reframing applied literally: **fetch at build time on * a machine that has a network, apply on a target that then needs nothing.** The build happens * here, once per lab, on a machine with a network. What a scenario raises afterwards needs * neither. * * Measured while writing it: installing the runtime takes about 30 seconds, publishing about a * minute, and the result is roughly 700 MiB. */ import { incus, incusOk, succeeds } from "../incus/client.ts"; import { BASE_IMAGE_ALIAS } from "./place.ts"; /** The stock image the base is built FROM. */ export const UPSTREAM_IMAGE = "images:archlinux/current"; const BUILDER = "mesh-lab-base-builder"; export class BaseImageError extends Error { constructor(message: string) { super(message); this.name = "BaseImageError"; } } /** * Build the base image, replacing any previous one. * * Every step is read back. A published image that turns out not to have a working runtime is * worse than no image, because every scenario raised from it fails somewhere else. */ export async function buildBaseImage( log: (message: string) => void = () => {}, ): Promise<{ alias: string; runtime: string }> { await succeeds(["delete", "-f", BUILDER], 120_000); log(` launching ${BUILDER} from ${UPSTREAM_IMAGE}, with a network`); await incus([ "launch", UPSTREAM_IMAGE, BUILDER, "--vm", "-c", "security.secureboot=false", "-c", "limits.memory=2GiB", "-c", "limits.cpu=2", ], 300_000); try { await waitForAgent(BUILDER); 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); // Read back from the runtime, not from the package manager. An installed package is not a // capability (novox/hq 04-ISSUES/007), and this is the one place to catch that — after // publishing, every scenario pays for it instead. const runtime = (await incusOk( ["exec", BUILDER, "--", "docker", "info", "--format", "{{.ServerVersion}}"], 120_000, ))?.trim(); if (!runtime) { throw new BaseImageError( `the runtime was installed in ${BUILDER} and does not answer. Publishing this would ` + `give every scenario an image that looks right and is not.`, ); } log(` runtime works (docker ${runtime})`); // Read back that the runtime will actually pull over plain HTTP from a documentation // range. Writing the file is not the same as the daemon honouring it, and a base image // that looks right here fails much later — in a sealed scenario, as a container that // cannot fetch its image, which is a long way from the cause. const trusted = await incusOk( ["exec", BUILDER, "--", "docker", "info", "--format", "{{.RegistryConfig.InsecureRegistryCIDRs}}"], 60_000, ); if (!trusted?.includes("192.0.2.0/24")) { throw new BaseImageError( `the runtime in ${BUILDER} does not trust the documentation ranges as plain-HTTP ` + `registries. It reported: ${trusted?.trim() || "nothing"}\n` + ` Every scenario raised from this image would fail to pull from its own registry.`, ); } log(" trusts the documentation ranges as registries"); log(" publishing"); await incus(["stop", BUILDER, "--timeout", "120"], 300_000); await incus(["publish", BUILDER, "--alias", BASE_IMAGE_ALIAS, "--reuse"], 900_000); const listed = await incusOk(["image", "list", BASE_IMAGE_ALIAS, "--format", "csv", "-c", "l"], 60_000); if (!listed?.includes(BASE_IMAGE_ALIAS)) { throw new BaseImageError( `publishing reported success and '${BASE_IMAGE_ALIAS}' is not in the image list.`, ); } log(` published ${BASE_IMAGE_ALIAS}`); return { alias: BASE_IMAGE_ALIAS, runtime }; } finally { // The builder is scaffolding. Leaving it standing would be a machine with a network in a // lab whose whole point is that scenarios do not have one. await succeeds(["delete", "-f", BUILDER], 120_000); } } /** Whether the base image exists, for a scenario to check before it raises. */ export async function baseImageExists(): Promise { const listed = await incusOk( ["image", "list", BASE_IMAGE_ALIAS, "--format", "csv", "-c", "l"], 30_000, ); return Boolean(listed?.includes(BASE_IMAGE_ALIAS)); } /** * Wait for the guest agent, because `launch` returning means the VM started, not that anything * inside it will answer. */ async function waitForAgent(name: string): Promise { for (let i = 0; i < 90; i++) { if (await succeeds(["exec", name, "--", "true"], 10_000)) return; await new Promise((r) => setTimeout(r, 2_000)); } throw new BaseImageError(`${name} started and its agent never answered.`); }