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:
2026-08-28 01:56:32 +02:00
parent 78b6f058ff
commit d6eef25590
6 changed files with 379 additions and 15 deletions
+16 -2
View File
@@ -22,7 +22,8 @@ import { applyAddresses, applyDefaultRoutes } from "./address.ts";
import { assertSupported } from "./supported.ts";
import { planRouters, raiseRouters, raiseTransit } from "./router.ts";
import { applyHostFirewalls } from "./firewall.ts";
import { applyPlacements } from "./place.ts";
import { IMAGE_PREFIX, BASE_IMAGE_ALIAS, BASE_IMAGE_HOWTO, planPlacements, applyPlacements } from "./place.ts";
import { baseImageExists, UPSTREAM_IMAGE } from "./base.ts";
/** Drivers whose snapshots are copy-on-write. On `dir` a snapshot is a full copy. */
const COW_DRIVERS = ["btrfs", "zfs"];
@@ -166,7 +167,20 @@ export async function raise(
options: RaiseOptions = {},
): Promise<RaisedScenario> {
const log = options.onProgress ?? (() => {});
const image = options.image ?? "images:archlinux/current";
// A scenario that places a runtime or an image needs machines built from the base image,
// because a sealed machine cannot install one (novox/hq ADR 0046). Chosen here rather than
// declared, so a scenario says WHAT it needs and not which image provides it.
const needsRuntime = planPlacements(scenario).some(({ artifacts }) =>
artifacts.some((a) => a === "runtime" || a.startsWith(IMAGE_PREFIX))
);
if (needsRuntime && !options.image && !(await baseImageExists())) {
throw new Error(
`this scenario needs a container runtime inside its machines, and '${BASE_IMAGE_ALIAS}' ` +
`does not exist.\n${BASE_IMAGE_HOWTO}`,
);
}
const image = options.image ?? (needsRuntime ? BASE_IMAGE_ALIAS : UPSTREAM_IMAGE);
const readyTimeout = options.readyTimeoutSeconds ?? 180;
const instanceId = options.instanceId ?? newInstanceId(scenario.scenario, new Date());