Closes 04-ISSUES/009. A scenario declares `images:` by tag; the lab stocks a
registry on this workstation where there is a network, raises it inside the
scenario as scenery, and reports the references a declaration pins -- which are
the digests THIS registry assigned, and are not knowable until it is raised.
Verified in a sealed machine, confirmed by ping to have no route out: package,
service including boot state, a container pinned by digest, and an action
inside that container. Applied, idempotent on re-apply, and read back from the
machine rather than from the apply's own report. That is the first time the
container shape has worked in the lab at all, and it was the shape blocking the
substrate bootstrap.
Four faults found by running it, three of them mine and one worth keeping:
The read-back checked that the catalog endpoint answered, by looking for the
substring "repositories" -- which `{"repositories":[]}` also contains. So it
passed on a registry holding nothing, and the failure surfaced much later as a
container that could not be pulled. It now asks for each image's manifest BY
DIGEST, which is what a machine does.
A recursive push needs its destination to exist, or incus copies the source's
contents rather than the source. The data landed one directory too shallow and
the registry found nothing where it looks.
The registry writes its blobs as root through a bind mount, so the workstation
could not remove its own scratch directory afterwards. Whoever made the files
removes them -- the cleanup now runs in a container too. And a cleanup failure
no longer fails a raise that succeeded: the scenario is standing and usable,
and saying otherwise would be a false report.
The base image build did not verify that the runtime trusts the documentation
ranges as plain-HTTP registries. Writing the file is not the daemon honouring
it, and a base image that looks right fails much later, in a sealed scenario,
a long way from its cause. It is now read back from `docker info`.
144 lines
6.0 KiB
TypeScript
144 lines
6.0 KiB
TypeScript
/**
|
|
* 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<boolean> {
|
|
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<void> {
|
|
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.`);
|
|
}
|