Files
mesh-lab/src/lifecycle/raise.ts
T
jschoubben 751948f0f9 The lab had a registry that production does not, so it tested a fiction
The lab raised a `registry` VM, pushed ~73 images into it from the workstation, and
rewrote every manifest reference — third-party ones included — to point at it. No
production mesh has such a thing. So every bed proved that a machine could fetch an
image from a registry that exists nowhere else, and the bootstrap problems that only
appear when a machine has to fetch for itself went unfound.

What replaces it is the two things that are true in the world:

**Public images come from the public internet.** mesh-lab already created a NAT'd
uplink for exactly this and attached it to any machine declaring `egress`; no scenario
ever declared it. They do now, and third-party references are left exactly as the
catalogue writes them.

**The mesh's own images have no registry and never will.** mesh-control, mesh-builder,
mesh-route-proxy and the per-module runtimes are built from source and exist in no
registry. A machine gets them the way an operator's machine does — they are built here
and loaded onto it — and is then named by the digest of its own image configuration,
which mesh-host now accepts as "an image this machine already holds".

`images:` therefore means only *ours*, and a third-party entry is refused rather than
quietly loaded: otherwise the fiction returns one convenient line at a time. It is
per-machine as well, because "everything, everywhere" was never a description of
anything real — handing whole-mesh-full's union to its two 30GiB workstations would
fill the disk with runtimes nothing on them will start.

**The uplink and the declared gateway would have fought, silently.** A gateway container
and the transit router reach the scenario and nothing else; a default route through
either is a black hole for anything outside, and it beats the uplink's DHCP route on
metric. So a machine with egress states the scenario's ranges explicitly — through the
same gateway or transit it would have defaulted to, so the overlay-across-NAT path is
unchanged — and leaves the default to the uplink. A range with no path inside the
scenario becomes `unreachable` rather than falling through: 192.168.1.0/24 is an
ordinary private range in fact, and letting it escape would put scenario traffic on
whatever network the workstation is sitting on. `scenarioRoutesFor` is pure and tested,
because a decision only a full raise could check is one nobody checks.

The registry-reachability check the raise gained earlier is kept, pointed at the real
thing: every machine with egress must resolve a name and reach the internet before the
raise says it finished. Same failure it was written for — a raise that returns, an apply
that dies on its first pull, an instance left a bare shell — now guarding the path that
actually carries.

The base image's trust of the documentation ranges as plain-HTTP registries STAYS. It
was never only for the lab's registry: the mesh has one of its own, the `registry`
module, serving artifacts to the whole mesh over plain HTTP from whatever node runs it.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-10 23:16:05 +02:00

359 lines
15 KiB
TypeScript

/**
* Materialise a declaration into a running scenario instance.
*
* Two rules from the design shape everything here.
*
* `raise` waits for the machines to be USABLE, not for the calls to return. Measured on a
* workstation those are 3.4s and 14.3s apart, and reporting the earlier number would be
* the mesh's own recurring failure — transport reported as effect.
*
* A failed raise LEAVES THE WRECKAGE. Tearing down on failure destroys the only evidence
* of what went wrong, and a scenario that failed to raise is more interesting than one
* that succeeded.
*
* See novox/hq 03-DESIGN/01-to-be/03-scenario-lifecycle.md
*/
import type { Scenario, Segment } from "../declaration/types.ts";
import { incus, incusOk, succeeds, pools, supportedDrivers } from "../incus/client.ts";
import { machineName, macFor, networkName, newInstanceId } from "./names.ts";
import { waitUntilAllUsable } from "./ready.ts";
import { applyAddresses, applyDefaultRoutes } from "./address.ts";
import { assertSupported } from "./supported.ts";
import { planRouters, raiseRouters, raiseTransit } from "./router.ts";
import { applyHostFirewalls } from "./firewall.ts";
import { IMAGE_PREFIX, BASE_IMAGE_ALIAS, BASE_IMAGE_HOWTO, planPlacements, applyPlacements, loadHeldImages } from "./place.ts";
import { baseImageExists, UPSTREAM_IMAGE } from "./base.ts";
import { confirmEgress } from "./egress.ts";
import type { HeldImage } from "../pinning.ts";
import { log as record } from "../log.ts";
/** Drivers whose snapshots are copy-on-write. On `dir` a snapshot is a full copy. */
const COW_DRIVERS = ["btrfs", "zfs"];
export interface RaiseOptions {
/** Base image for machines. */
image?: string;
/** Reuse an existing instance id rather than minting one — makes raise convergent. */
instanceId?: string;
/** Seconds to wait for each machine to become usable. */
readyTimeoutSeconds?: number;
onProgress?: (message: string) => void;
}
export interface RaisedScenario {
instanceId: string;
scenario: string;
machines: string[];
networks: string[];
pool: string;
/**
* The mesh's own images, as loaded onto the machines, and what a declaration should call them.
*
* Reported rather than declared: an image built from source has no digest until it has been
* built, and what names it here is the digest of its own configuration.
*
* **Only ours.** Everything third-party is pulled from the internet by the machine that needs
* it, so it is not in this list and nothing rewrites it.
*/
images: HeldImage[];
}
export class RaiseError extends Error {
readonly instanceId: string;
readonly step: string;
constructor(instanceId: string, step: string, cause: unknown) {
const detail = cause instanceof Error ? cause.message : String(cause);
super(
`raise failed at '${step}': ${detail}\n` +
`The instance '${instanceId}' has been LEFT STANDING for inspection. ` +
`Destroy it with: mesh-lab destroy ${instanceId}`,
);
this.name = "RaiseError";
this.instanceId = instanceId;
this.step = step;
}
}
/**
* Pick a pool that can snapshot cheaply, and say so loudly when there is not one.
*
* Measured: a `dir` snapshot of a 1.5 GB machine takes 9.9s and a full 1.6 GB, with a
* second snapshot unfinished after two minutes. On copy-on-write it is 0.13s and costs
* the delta. Restoring is the operation the inner loop repeats most, so a `dir` pool does
* not make the lab slow — it makes it unused.
*/
async function choosePool(log: (m: string) => void): Promise<string> {
const available = await pools();
const cow = available.find((p) => COW_DRIVERS.includes(p.driver));
if (cow) return cow.name;
const drivers = await supportedDrivers();
const possible = drivers.filter((d) => COW_DRIVERS.includes(d));
log(
possible.length > 0
? `WARNING: no copy-on-write pool exists, though the daemon offers ${possible.join("/")}. ` +
`Snapshots will be full copies — roughly 76x slower, and the inner loop unusable.`
: `WARNING: the daemon offers no copy-on-write driver. Snapshots will be full copies — ` +
`roughly 76x slower, and the inner loop unusable. Install btrfs tooling and restart it.`,
);
const fallback = available[0];
if (!fallback) throw new Error("no storage pool exists at all");
return fallback.name;
}
/**
* One isolated link per segment. Nothing joins them to anything outside the instance, and
* incus is told not to hand out addresses: a scenario declares the underlay, and letting
* a hypervisor's DHCP assign addresses would be the lab supplying facts the declaration
* is supposed to own.
*/
async function createNetwork(
instanceId: string,
segment: string,
spec: Segment,
): Promise<string> {
const name = networkName(instanceId, segment);
if (await succeeds(["network", "show", name], 15_000)) return name;
await incus([
"network", "create", name,
"ipv4.address=none",
"ipv6.address=none",
"ipv4.nat=false",
"ipv6.nat=false",
`user.mesh-lab.instance=${instanceId}`,
`user.mesh-lab.segment=${segment}`,
// Whether a segment is public and which ranges it carries are facts a link cannot be
// asked for afterwards — incus knows only that it is an isolated bridge. Recorded here
// so anything reading a raised instance back reads what was applied, rather than
// re-opening the declaration and reporting the request as though it were the result.
`user.mesh-lab.kind=${spec.kind}`,
`user.mesh-lab.cidr=${spec.cidr.join(",")}`,
...(spec.mtu === undefined ? [] : [`user.mesh-lab.mtu=${spec.mtu}`]),
]);
return name;
}
/**
* The one network the lab supplies rather than the declaration.
*
* Every segment a scenario describes is an isolated bridge with no addresses, no DHCP and no NAT,
* because the declaration owns addressing. This is the opposite of that on purpose: it is not part
* of the scenario, it carries no scenario traffic, and what is routable on it is the host's fact.
*
* It exists so a machine can fetch what it starts from. A first node pulls three images before
* there is any mesh, and the module that gives a mesh its own store pulls one more
* (novox/hq 04-ISSUES/029) — none of which anything inside a scenario can serve.
*
* Tagged like everything else, so tearing the scenario down takes it too.
*/
async function createUplink(instanceId: string): Promise<string> {
const name = networkName(instanceId, "uplink");
if (await succeeds(["network", "show", name], 15_000)) return name;
await incus([
"network", "create", name,
"ipv4.address=auto",
"ipv4.nat=true",
"ipv6.address=none",
`user.mesh-lab.instance=${instanceId}`,
"user.mesh-lab.segment=uplink",
]);
return name;
}
async function createMachine(
instanceId: string,
machine: string,
attachments: { segment: string }[],
image: string,
pool: string,
egress = false,
memory = "1GiB",
cpus = 2,
disk?: string,
): Promise<string> {
const name = machineName(instanceId, machine);
if (await succeeds(["config", "show", name], 15_000)) return name;
const args = [
"init", image, name,
"--vm",
"-s", pool,
// Arch images refuse to boot under secureboot with the shipped keys. Discovered by
// the first launch failing with exactly that message.
"-c", "security.secureboot=false",
"-c", `limits.memory=${memory}`,
"-c", `limits.cpu=${cpus}`,
"-c", `user.mesh-lab.instance=${instanceId}`,
"-c", `user.mesh-lab.machine=${machine}`,
];
// A bigger root disk than the pool default, when the scenario asks — a broad install stocks many
// images and exhausts the default, failing the apply with "no space left on device".
if (disk) args.push("-d", `root,size=${disk}`);
await incus(args, 300_000);
// eth0 comes from the profile and points at the wrong network, so every attachment is
// explicit. A machine on no segment gets no interface at all — that is what detached is.
await succeeds(["config", "device", "remove", name, "eth0"], 15_000);
for (const [index, attachment] of attachments.entries()) {
await incus([
"config", "device", "add", name, `eth${index}`, "nic",
"nictype=bridged",
`parent=${networkName(instanceId, attachment.segment)}`,
// Explicit, because incus assigns one at runtime without recording it in the device
// config — so reading it back returns nothing, and the guest has no stable handle.
`hwaddr=${macFor(instanceId, machine, index)}`,
]);
}
// After every declared attachment, so eth0..ethN keep meaning what the scenario said and the
// uplink is whatever comes next. A machine that never asked for one has no such interface at
// all, which is the difference between a closed scenario and an open one.
if (egress) {
await incus([
"config", "device", "add", name, `eth${attachments.length}`, "nic",
"nictype=bridged",
`parent=${await createUplink(instanceId)}`,
`hwaddr=${macFor(instanceId, machine, attachments.length)}`,
]);
}
return name;
}
export async function raise(
scenario: Scenario,
options: RaiseOptions = {},
): Promise<RaisedScenario> {
// **Progress always reaches the file, whether or not anyone asked to see it.**
//
// This used to be the caller's callback or nothing, and every function below takes its `log`
// from here — so a caller that passed none silenced the whole lifecycle. That is exactly what
// happened: the end-to-end test called `raise` with no callback, so the one run that mattered
// reported not a single step (novox/hq 04-ISSUES/024).
//
// Teeing rather than replacing: the caller still gets what it asked for, and the record is kept
// regardless. A record nobody switched on is the one you want after the thing goes wrong.
const log = (message: string): void => {
record.info(message);
options.onProgress?.(message);
};
// 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 0006). Chosen here rather than
// declared, so a scenario says WHAT it needs and not which image provides it.
const needsRuntime = (scenario.images ?? []).length > 0 ||
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());
// Refuse before spending a minute raising something that would silently lack half of
// what it declares. Deliberately outside the try: this is not a raise failure, nothing
// has been created, and there is no wreckage to leave standing.
assertSupported(scenario);
// **The step is the log.** Setting it and recording it are one act, so a step added later
// cannot be a step that goes unrecorded — which is the drift that made a thirty-five minute
// stall untraceable (novox/hq 04-ISSUES/024). Each entry closes the previous one with its
// duration, so the log says where a raise spends its time as well as where it stopped.
let step = "";
let stepFrom = Date.now();
const enter = (next: string): string => {
if (step) record.info(` ${step} — ${((Date.now() - stepFrom) / 1000).toFixed(1)}s`);
record.info(`▶ ${next}`);
stepFrom = Date.now();
step = next;
return next;
};
enter("choosing a storage pool");
try {
const pool = await choosePool(log);
log(`instance ${instanceId} pool ${pool}`);
enter("creating segments");
const networks: string[] = [];
for (const [segment, spec] of Object.entries(scenario.segments)) {
networks.push(await createNetwork(instanceId, segment, spec));
log(` segment ${segment}`);
}
enter("creating machines");
const created: string[] = [];
const byMachine = new Map<string, string>();
for (const [machine, spec] of Object.entries(scenario.machines)) {
const attachments = spec.at === "detached" ? [] : spec.at;
const name = await createMachine(
instanceId, machine, attachments, image, pool,
spec.at !== "detached" && spec.egress === true, spec.memory, spec.cpus, spec.disk);
created.push(name);
byMachine.set(machine, name);
log(` machine ${machine}${spec.at === "detached" ? " (detached)" : ""}`);
}
enter("starting machines");
for (const name of created) {
await succeeds(["start", name], 60_000);
}
enter("waiting for machines to become usable");
await waitUntilAllUsable(created, readyTimeout, log);
enter("applying declared addresses");
await applyAddresses(scenario, instanceId, byMachine, log);
// Transit first: a gateway's default route points at it, so it has to exist.
enter("wiring the public networks together");
const transit = await raiseTransit(scenario, instanceId, log);
enter("raising routers");
const routers = await raiseRouters(scenario, instanceId, planRouters(scenario, instanceId), log);
if (transit) routers.push(transit);
enter("routing machines through their gateways");
await applyDefaultRoutes(scenario, byMachine, log);
// Last: a machine that refuses inbound must still have been reachable while the lab
// was configuring it.
enter("applying host firewalls");
await applyHostFirewalls(scenario, byMachine, log);
// **Only now is "this machine can reach the outside" a true statement.** The route, the
// gateway and the machine's own filtering are all in place, so this is the path a pull takes.
// A raise that returned without checking would hand the next step a fact it depends on and
// has no way to test — which is how a substrate apply used to die on its first pull.
enter("confirming egress reaches the internet");
await confirmEgress(scenario, byMachine, log);
// Last, and only once the underlay is real. Placing before the machines can reach each
// other would test the host against a network the scenario does not describe.
enter("placing");
await applyPlacements(scenario, byMachine, log);
// After `placing`, because loading an image needs the container runtime that `placing`
// confirmed. The mesh's own images only — everything third-party is pulled by the machine
// itself, over its uplink, exactly as it is on a real one.
enter("loading the mesh's own images onto the machines");
const images = await loadHeldImages(scenario, byMachine, log);
return {
instanceId,
scenario: scenario.scenario,
images,
machines: [...created, ...routers],
networks,
pool,
};
} catch (cause) {
throw new RaiseError(instanceId, step, cause);
}
}