diff --git a/README.md b/README.md index 2bc160c..9ed9167 100644 --- a/README.md +++ b/README.md @@ -116,16 +116,20 @@ than ignored: | `policy:` between segments | **works**, asymmetric | | `inbound: deny` | **works** — host firewall, read back after applying | | several public networks, routed not bridged | **works** — a transit router, never a shared bridge | -| `place:` | **refused at raise** — the node host it would place does not exist yet | +| `place: [host]` | **works** — tier 0 is placed and asked what the machine is | +| `place:` anything above tier 0 | **refused, by name** — those tiers do not exist yet | `raise` refuses a scenario declaring anything in the lower half, naming every gap. It does not raise a mesh that silently lacks what it declared — that is the fault this lab exists to catch (`novox/hq` 04-ISSUES/003: a firewall key declared in five manifests and read by no code, so a manifest appears to restrict a port and restricts nothing). -No scenario in `scenarios/` declares `place:` yet, so all of them raise. What they raise is -an underlay holding empty machines — correct, and not yet useful for anything, because the -node host that would be placed on them does not exist. +`bootstrap-single.yml` places the host. The rest raise an underlay and put nothing on it, +which is still correct for what they test. + +Placing needs a built host binary — set `MESH_LAB_HOST_BINARY` to one. It is an explicit path +rather than a search on purpose: the declaration design leaves *where `place:` gets its +artifacts from* open, and guessing would harden into the answer by accident. ## Measured on a workstation diff --git a/scenarios/bootstrap-single.yml b/scenarios/bootstrap-single.yml index ac73d45..f5f42ae 100644 --- a/scenarios/bootstrap-single.yml +++ b/scenarios/bootstrap-single.yml @@ -14,7 +14,10 @@ machines: at: { segment: hosting, address: [192.0.2.10, "2001:db8:a::10"] } inbound: allow -# No `place:` yet. The node host it would place does not exist — this lab is being built to +place: + all: [host] + +# The host is placed. Everything above tier 0 is still refused by name — this lab is built to # develop it. Declaring it anyway would make the scenario unraisable, and correctly so: the # lab refuses declarations it cannot materialise rather than raising a mesh that silently # lacks them. diff --git a/src/lifecycle/place.ts b/src/lifecycle/place.ts new file mode 100644 index 0000000..7d5d321 --- /dev/null +++ b/src/lifecycle/place.ts @@ -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 { + 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, + log: (message: string) => void = () => {}, +): Promise { + 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; +} diff --git a/src/lifecycle/raise.ts b/src/lifecycle/raise.ts index 9c712f1..5f8d517 100644 --- a/src/lifecycle/raise.ts +++ b/src/lifecycle/raise.ts @@ -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, diff --git a/src/lifecycle/supported.ts b/src/lifecycle/supported.ts index c724410..c78d132 100644 --- a/src/lifecycle/supported.ts +++ b/src/lifecycle/supported.ts @@ -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(); + 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`, ); } diff --git a/test/integration/placement.test.ts b/test/integration/placement.test.ts new file mode 100644 index 0000000..ff0ed53 --- /dev/null +++ b/test/integration/placement.test.ts @@ -0,0 +1,88 @@ +/** + * The lab, placing tier 0 inside a machine it raised. + * + * This is the seam that ends the lab being infrastructure with no consumer + * (novox/hq 03-DESIGN/00-as-is/11-the-lab.md). It needs a built host binary; without one it + * skips with a reason rather than passing having checked nothing. + */ + +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { loadScenario } from "../../src/declaration/parse.ts"; +import { raise } from "../../src/lifecycle/raise.ts"; +import { destroy, exec } from "../../src/lifecycle/operate.ts"; +import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; +import { labIsUsable, destroyAll } from "./harness.ts"; + +const capability = await labIsUsable(); +const binary = hostBinaryPath(); + +const skip = !capability.usable + ? `lab not usable: ${capability.why}` + : !binary + ? "MESH_LAB_HOST_BINARY is not set — build novox/mesh-host and point at it" + : !existsSync(binary) + ? `MESH_LAB_HOST_BINARY points at ${binary}, which does not exist` + : false; + +let instanceId = ""; + +after(async () => { + if (instanceId) await destroy(instanceId); + await destroyAll("bootstrap-single-"); +}, { timeout: 400_000 }); + +test("a raised machine contains the host", { skip, timeout: 900_000 }, async () => { + const raised = await raise(loadScenario("scenarios/bootstrap-single.yml"), {}); + instanceId = raised.instanceId; + + const { stdout } = await exec(instanceId, "anchor", [HOST_PATH, "version"]); + assert.ok(stdout.trim().length > 0, "the host is on the machine but does not run there"); +}); + +test("the host reports the MACHINE, not the workstation that placed it", { skip, timeout: 120_000 }, async () => { + // The check that proves detection detects rather than reporting a constant. A raised VM and + // the workstation differ in every capability, so a host that reported the workstation's + // answers would be obvious here and invisible anywhere else. + const { stdout } = await exec(instanceId, "anchor", [HOST_PATH, "profile", "--json"]); + const profile = JSON.parse(stdout) as { + capabilities: { name: string; present: boolean; detail: string }[]; + }; + const by = new Map(profile.capabilities.map((c) => [c.name, c])); + + // A machine raised by the lab is root and has a clean init. The workstation session is + // neither, so these are the two that would flip if the wrong machine were being read. + assert.equal(by.get("privileged")?.present, true, "a raised machine should be root"); + assert.equal(by.get("service-manager")?.present, true, "a raised machine should have an init"); + + for (const [name, verdict] of by) { + assert.ok(verdict.detail.trim().length > 0, `${name} was reported with no reason`); + } +}); + +test("ADR 0031 — the host confirms the machine carries no overlay", { skip, timeout: 120_000 }, async () => { + // The lab provides the underlay and NOTHING of the overlay. Asserted elsewhere by looking + // for wireguard interfaces; here the placed host reports it independently, which is a + // second witness rather than the same check twice. + const { stdout } = await exec(instanceId, "anchor", [HOST_PATH, "profile", "--json"]); + const profile = JSON.parse(stdout) as { capabilities: { name: string; present: boolean }[] }; + const overlay = profile.capabilities.find((c) => c.name === "overlay"); + + assert.equal(overlay?.present, false, "a freshly raised machine already had an overlay"); +}); + +test("the host's inventory is of the raised machine", { skip, timeout: 120_000 }, async () => { + const { stdout } = await exec(instanceId, "anchor", [HOST_PATH, "inventory", "--json"]); + const inv = JSON.parse(stdout) as { + machine: string; cpus: number; memory_kb: number; observed_at: string; unreadable?: string[]; + }; + + assert.ok(inv.machine.length > 0, "the machine did not report a name"); + assert.ok(inv.cpus > 0 && inv.memory_kb > 0, "the machine reported no cpus or no memory"); + assert.deepEqual(inv.unreadable ?? [], [], "something could not be read on a machine we raised"); + + // The scenario gives each machine 1GiB and 2 cpus. A host reporting the workstation's + // 24 cpus would pass every check above. + assert.ok(inv.cpus <= 4, `reported ${inv.cpus} cpus — that is not the raised machine`); +}); diff --git a/test/place.test.ts b/test/place.test.ts new file mode 100644 index 0000000..29cb3d0 --- /dev/null +++ b/test/place.test.ts @@ -0,0 +1,89 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseScenario } from "../src/declaration/parse.ts"; +import { planPlacements, PLACEABLE } from "../src/lifecycle/place.ts"; +import { assertSupported, UnsupportedError } from "../src/lifecycle/supported.ts"; + +/** + * `place:` is the seam where the lab stops being infrastructure with no consumer. Each test + * names what it defends, per novox/hq ADR 0034. + */ + +function scenario(place: string): ReturnType { + return parseScenario(` +scenario: placing +segments: + hosting: + kind: public + cidr: [192.0.2.0/24] +machines: + anchor: + at: { segment: hosting, address: [192.0.2.10] } + peer: + at: { segment: hosting, address: [192.0.2.20] } +${place} +`); +} + +test("`all:` reaches every machine", () => { + const placements = planPlacements(scenario("place:\n all: [host]")); + assert.deepEqual( + placements.map((p) => p.machine).sort(), + ["anchor", "peer"], + ); + for (const p of placements) assert.deepEqual(p.artifacts, ["host"]); +}); + +test("a per-machine entry OVERRIDES `all:`, it does not add to it", () => { + // Worth being exact about: a scenario naming one artifact for one machine gets that + // artifact, not that artifact plus everything in `all:`. The opposite reading would place + // things nobody asked for, which is the shape of fault this lab exists to catch. + const placements = planPlacements(scenario("place:\n all: [host]\n anchor: [substrate]")); + const byMachine = new Map(placements.map((p) => [p.machine, p.artifacts])); + + assert.deepEqual(byMachine.get("anchor"), ["substrate"], "anchor should have ONLY substrate"); + assert.deepEqual(byMachine.get("peer"), ["host"]); +}); + +test("a machine placed with nothing is not a placement", () => { + const placements = planPlacements(scenario("place:\n all: [host]\n anchor: []")); + assert.deepEqual(placements.map((p) => p.machine), ["peer"]); +}); + +test("no `place:` at all is no placements, not an error", () => { + assert.deepEqual(planPlacements(scenario("")), []); +}); + +test("placing the host is supported", () => { + // The whole point of stage 1: this used to be refused. + assert.doesNotThrow(() => assertSupported(scenario("place:\n all: [host]"))); +}); + +test("a tier that does not exist is refused BY NAME", () => { + // Named individually rather than refused as a whole, so a scenario placing a host and a + // substrate is told exactly which half the lab cannot do — rather than being told `place:` + // is unsupported when half of it now works. + try { + assertSupported(scenario("place:\n all: [host, substrate]\n peer: [control]")); + assert.fail("expected a refusal"); + } catch (err) { + assert.ok(err instanceof UnsupportedError); + const missing = err.missing.join("\n"); + assert.match(missing, /substrate/, "the substrate was not named"); + assert.match(missing, /control/, "the control plane was not named"); + assert.doesNotMatch(missing, /place: host/, "the host is placeable and was refused anyway"); + } +}); + +test("the refusal says what CAN be placed", () => { + // A refusal that does not say what is possible sends someone to the source to find out. + try { + assertSupported(scenario("place:\n all: [forge]")); + assert.fail("expected a refusal"); + } catch (err) { + assert.ok(err instanceof UnsupportedError); + for (const placeable of PLACEABLE) { + assert.match(err.missing.join("\n"), new RegExp(placeable)); + } + } +}); diff --git a/test/supported.test.ts b/test/supported.test.ts index 4eab53b..eae6fd6 100644 --- a/test/supported.test.ts +++ b/test/supported.test.ts @@ -48,24 +48,32 @@ machines: { a: { at: { segment: net, address: [192.0.2.1] }, inbound: deny } }`) assert.doesNotThrow(() => assertSupported(scenario)); }); -test("place is still refused — there is nothing to place yet", () => { +test("the host is placeable — it used to be refused, and tier 0 now exists", () => { + // These 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 (novox/hq ADR 0034). const scenario = parseScenario(`scenario: x segments: { net: { kind: public, cidr: [192.0.2.0/24] } } machines: { a: { at: { segment: net, address: [192.0.2.1] } } } place: { all: [host] }`); - assert.throws(() => assertSupported(scenario), UnsupportedError); + assert.doesNotThrow(() => assertSupported(scenario)); }); -test("the refusal explains what would silently be missing", () => { +test("a tier above 0 is still refused, and named", () => { + // The refusal narrowed rather than disappearing. A scenario placing a host AND a substrate + // must be told which half is missing — not that `place:` is unsupported, when half of it + // now works. const scenario = parseScenario(`scenario: x segments: { net: { kind: public, cidr: [192.0.2.0/24] } } machines: { a: { at: { segment: net, address: [192.0.2.1] } } } -place: { all: [host] }`); +place: { all: [host, substrate] }`); try { assertSupported(scenario); assert.fail("should have refused"); } catch (err) { - assert.equal((err as UnsupportedError).missing.length, 1); + assert.ok(err instanceof UnsupportedError); + assert.equal(err.missing.length, 1, `expected only the substrate: ${err.missing.join(", ")}`); + assert.match(err.missing[0] ?? "", /substrate/); assert.match(err instanceof Error ? err.message : "", /silently lacks them/); } });