place: the host — the lab acquires a consumer

The lab raised an underlay and put nothing on it: correct, and useless, because
the thing it exists to test did not exist. Tier 0 now does, so `place: [host]`
works and a raised scenario finally contains something.

The refusal narrows rather than disappearing. A scenario placing a host and a
substrate is told which half is missing, by name — not that `place:` is
unsupported when half of it now works.

Placement reads back rather than assuming. A file arriving is not a host
working, so the binary is run before it is trusted to answer questions, and what
it reports is read from the machine (ADR 0035). The binary comes from an
explicit path, because the declaration design leaves where artifacts come from
open and a search would harden into the answer by accident.

The integration test that matters is the one asserting the host reports the
MACHINE and not the workstation that placed it. A raised VM and this workstation
differ in every capability — root versus uid 1000, a clean init versus a
degraded one, no docker versus docker, no wireguard versus wg0 — so a host
reporting the wrong machine is obvious here and invisible anywhere else.

And the placed host independently confirms ADR 0031: overlay absent on a freshly
raised machine. The underlay suite already asserted that by looking for
wireguard interfaces; this is a second witness rather than the same check twice.

Two tests failed the moment placement worked, which is what they were for. They
defended "there is nothing to place yet" while that was true; the decision
changed, so they change with it rather than being deleted.

Gate: 75 unit, 20 integration.
This commit is contained in:
2026-08-26 00:41:25 +02:00
parent b015068921
commit 16c13807a9
8 changed files with 377 additions and 12 deletions
+156
View File
@@ -0,0 +1,156 @@
/**
* `place:` — putting something inside the machines.
*
* Until now the lab raised an underlay and put nothing on it: correct, and useless, because
* the thing it exists to test did not exist (novox/hq 03-DESIGN/00-as-is/11-the-lab.md). Tier
* 0 now does, so this is the seam where the lab acquires a consumer.
*
* Only `host` is placeable. Everything else in the placement vocabulary — the substrate, a
* control plane, a forge — is still refused by name rather than ignored, because a scenario
* that declares something and raises without it is the fault this lab was built to catch
* (novox/hq 04-ISSUES/003).
*/
import type { Scenario } from "../declaration/types.ts";
import { incus, incusOk } from "../incus/client.ts";
/** What this stage can put inside a machine. */
export const PLACEABLE = ["host"] as const;
export type Placeable = (typeof PLACEABLE)[number];
/** Where the host binary lives on a machine once placed. */
export const HOST_PATH = "/usr/local/bin/mesh-host";
export interface Placement {
machine: string;
artifacts: string[];
}
/**
* Resolve `place:` to one list per machine.
*
* `all:` applies to every machine; a per-machine entry **overrides** it rather than adding to
* it, which is what the declaration design says and is worth being exact about — a scenario
* naming one artifact for one machine gets that artifact and not that artifact plus the rest.
*/
export function planPlacements(scenario: Scenario): Placement[] {
const place = scenario.place;
if (!place) return [];
const all = place.all ?? [];
return Object.keys(scenario.machines).map((machine) => {
const own = place[machine];
return { machine, artifacts: own ?? all };
}).filter((p) => p.artifacts.length > 0);
}
/**
* The host binary to place, from the environment.
*
* Deliberately an explicit path rather than a search. The declaration design leaves *where
* `place:` gets its artifacts from* open — before the mesh is self-hosting they come from
* outside, afterwards from the mesh — and it suggests a named source rather than a path. This
* is neither: it is the smallest thing that works while that stays undecided, and it refuses
* loudly rather than guessing, so nothing here hardens into the answer by accident.
*/
export function hostBinaryPath(): string | null {
return process.env["MESH_LAB_HOST_BINARY"] ?? null;
}
export class PlacementError extends Error {
readonly machine: string;
constructor(machine: string, message: string) {
super(message);
this.name = "PlacementError";
this.machine = machine;
}
}
export interface PlacedHost {
machine: string;
/** What the host reported about the machine, read back from it. */
profile: unknown;
version: string;
}
/**
* Put the host on a machine and ask it what the machine is.
*
* The result is read back from the running binary, never assumed from the fact that the copy
* succeeded (novox/hq ADR 0035). A file arriving is not a host working, which is the same
* distinction the host itself makes about installed packages.
*/
export async function placeHost(
instanceName: string,
machine: string,
binary: string,
log: (message: string) => void = () => {},
): Promise<PlacedHost> {
await incus(["file", "push", binary, `${instanceName}${HOST_PATH}`, "--mode", "0755"], 180_000);
// Read back that it is there and executable before trusting it to answer questions.
const version = (await incusOk(["exec", instanceName, "--", HOST_PATH, "version"], 60_000))?.trim();
if (!version) {
throw new PlacementError(
machine,
`the host binary was copied to ${machine} but does not run there. A file arriving is ` +
`not a host working.`,
);
}
const reported = await incusOk(
["exec", instanceName, "--", HOST_PATH, "profile", "--json"],
120_000,
);
if (!reported) {
throw new PlacementError(
machine,
`the host runs on ${machine} (${version}) but reported no profile. A host that cannot ` +
`say what a machine is cannot be asked to change it.`,
);
}
let profile: unknown;
try {
profile = JSON.parse(reported);
} catch (err) {
throw new PlacementError(
machine,
`the host on ${machine} reported something that is not a profile: ${(err as Error).message}`,
);
}
log(` placed the host on ${machine} (${version})`);
return { machine, profile, version };
}
/** Place everything a scenario declares. */
export async function applyPlacements(
scenario: Scenario,
machineNames: Map<string, string>,
log: (message: string) => void = () => {},
): Promise<PlacedHost[]> {
const placements = planPlacements(scenario);
if (placements.length === 0) return [];
const binary = hostBinaryPath();
if (!binary) {
throw new Error(
`this scenario places the host, and no host binary was given. Set ` +
`MESH_LAB_HOST_BINARY to a built mesh-host. Guessing at a path would place ` +
`whatever happened to be there.`,
);
}
const placed: PlacedHost[] = [];
for (const { machine, artifacts } of placements) {
const name = machineNames.get(machine);
if (!name) continue;
for (const artifact of artifacts) {
if (artifact !== "host") continue; // refused earlier; belt and braces
placed.push(await placeHost(name, machine, binary, log));
}
}
return placed;
}
+6
View File
@@ -22,6 +22,7 @@ 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";
/** Drivers whose snapshots are copy-on-write. On `dir` a snapshot is a full copy. */
const COW_DRIVERS = ["btrfs", "zfs"];
@@ -224,6 +225,11 @@ export async function raise(
step = "applying host firewalls";
await applyHostFirewalls(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.
step = "placing";
await applyPlacements(scenario, byMachine, log);
return {
instanceId,
scenario: scenario.scenario,
+13 -2
View File
@@ -14,6 +14,7 @@
*/
import type { Scenario } from "../declaration/types.ts";
import { PLACEABLE, planPlacements } from "./place.ts";
export class UnsupportedError extends Error {
readonly missing: string[];
@@ -33,9 +34,19 @@ export class UnsupportedError extends Error {
export function assertSupported(scenario: Scenario): void {
const missing: string[] = [];
if (scenario.place && Object.keys(scenario.place).length > 0) {
// `place: [host]` works. Everything else in the vocabulary is named individually rather
// than refused as a whole, so a scenario that places a host and a substrate is told exactly
// which half the lab cannot do.
const unplaceable = new Set<string>();
for (const { artifacts } of planPlacements(scenario)) {
for (const artifact of artifacts) {
if (!(PLACEABLE as readonly string[]).includes(artifact)) unplaceable.add(artifact);
}
}
for (const artifact of [...unplaceable].sort()) {
missing.push(
"place — nothing is placed inside the machines yet; they are raised bare",
`place: ${artifact} — only ${PLACEABLE.join(", ")} can be placed; the tiers above ` +
`tier 0 do not exist yet`,
);
}