The lab can give a sealed machine a container runtime
ADR 0046's open 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."
The runtime half is done, and it is research 012's reframing applied literally
-- fetch at build time on a machine with a network, apply on a target that
needs nothing. `mesh-lab base build` launches a machine WITH a network,
installs a runtime, verifies it by asking the runtime rather than the package
manager, and publishes the result. Measured: ~30s to install, ~60s to publish,
~700MiB, paid once per lab rather than per scenario.
A scenario that places `runtime` or an image is then raised from that base
image, chosen rather than declared -- a scenario says what it needs, not which
image provides it. If the base does not exist it says so and how to build it.
Verified in a genuinely sealed machine (no route out, confirmed by ping):
package, service including the new `boot: enabled`, and action all applied,
were idempotent on a second run, and read back correctly. Those three had never
run anywhere but a workstation.
The image half is NOT done, and testing found why: a digest-pinned image cannot
be placed from an archive. `docker save alpine@sha256:...` produces an archive
with no repo tag, because a repo digest only exists for an image a registry
served -- so it loads dangling and a container declaring that digest reaches
for a registry the machine cannot see.
That collides with ADR 0046, which has the host REFUSE an unpinned image. Tag
refused by the host, digest unusable in the lab: there is currently no
declaration the lab can raise that exercises the container shape at all. Filed
as 04-ISSUES/009, whose resolution is a registry inside the scenario -- which is
what the real mesh does rather than a workaround for the lab.
Also fixed a weak check of my own, which is the same fault in miniature: the
load was tested with `includes("Loaded image")`, a prefix of both `Loaded
image:` and `Loaded image ID:`. So an unusable dangling load reported success
and the failure surfaced later as a container that would not start.
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 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 0032). ADR 0046 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);
|
||||
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})`);
|
||||
|
||||
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.`);
|
||||
}
|
||||
Reference in New Issue
Block a user