From babf08b9f8e7dcdcd473e24b384d15167c35d8f1 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 14 Sep 2026 20:41:55 +0200 Subject: [PATCH 01/26] Raise machines that are somebody, and a bed that hands over nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every machine in a bed is a clone of one base image, so all of them booted with the same /etc/machine-id. systemd's DHCP client derives its client identifier from that file and dnsmasq keys leases on the identifier rather than the MAC, so four machines with four distinct MACs were handed one address and the host kept one ARP entry for it. Whichever machine last answered an ARP request received everybody's replies. This is the fault behind every run lost to "flaky lab DNS": resolution that works two times in three, pulls that succeed on a retry, and one machine out of four being fine while the rest have no path at all. It survived an earlier diagnosis that blamed resolver ordering, because reordering resolvers on a machine that has just won the ARP race looks exactly like a fix. Each machine is now given its own machine-id before the uplink lease is asked for, and a check after addresses are applied refuses to go on if two machines took the same one — the positive control this never had, since the fault is invisible where it happens and unrecognisable where it surfaces. The egress check also now demands five consecutive lookups rather than one. A single answer is what let a machine resolving one query in three pass and then die twenty minutes later inside a pull. And fresh-mesh: whole-mesh-full's topology with genesis-single's honesty. The four-machine bed loads thirty-four of the mesh's own images onto its machines from the workstation because it does not build them, which is a shape no real installation has and the same fiction the lab removed when it deleted its own registry. This scenario names no images at all. The machines pull what is public, the installer builds the control plane, and the mesh builds the rest — including, last and deliberately, a module on a machine that did not build it. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- scenarios/fresh-mesh.yml | 106 +++++++++ src/lifecycle/egress.ts | 53 ++++- src/lifecycle/raise.ts | 91 +++++++ test/integration/fresh-mesh.test.ts | 355 ++++++++++++++++++++++++++++ 4 files changed, 600 insertions(+), 5 deletions(-) create mode 100644 scenarios/fresh-mesh.yml create mode 100644 test/integration/fresh-mesh.test.ts diff --git a/scenarios/fresh-mesh.yml b/scenarios/fresh-mesh.yml new file mode 100644 index 0000000..4a0370d --- /dev/null +++ b/scenarios/fresh-mesh.yml @@ -0,0 +1,106 @@ +# FOUR FRESH MACHINES AND THE INSTALLER. Nothing is handed to them. +# +# This is `whole-mesh-full`'s topology with `genesis-single`'s honesty. The four-machine bed loads +# thirty-four of the mesh's own images onto its machines from the workstation, because it does not +# build them — they are produced by hand beside the bed and copied in. That is a shape no real +# installation has, and it is the same class of fiction the lab already removed once: it used to +# raise a registry inside the scenario, and a bootstrap that only worked against it went green here +# and would have failed on any real machine. +# +# So this bed hands over NOTHING. There is no `images:` list. Every machine gets a container +# runtime and the host binary, which are prerequisites of the machine rather than parts of the +# mesh, and after that the mesh is on its own: +# +# - the substrate, the registry and the builder's own dependencies are PULLED from the internet, +# which is where a bare machine gets them; +# - the control plane is BUILT, by the builder the installer carries, from a repository and a +# commit it is told to use; +# - every module after that is BUILT by the mesh's own builder and published into the mesh's own +# registry, and a machine that runs one PULLS it from there. +# +# That last clause is the thing no bed has ever checked, and it is why this one has four machines +# rather than one. Genesis puts the builder, the registry and everything they make on ONE machine. +# A second machine running a mesh-built module has to fetch it from a registry that asks who it is, +# and nothing yet gives a joined node an account for it. The bed asks anyway, in its own test, so +# the gap is a named failure rather than an absence. +# +# hosting (public, routable) home (private, behind the access point) +# novox 192.0.2.20 ── anchor ace 10.99.1.10 home server +# substrate, registry, shanks 10.99.1.20 workstation +# builder, control plane g14 10.99.1.30 workstation +# +# EGRESS IS NOT OPTIONAL HERE. With nothing loaded, a sealed machine stops at the installer's first +# pull. Every machine has a way out, and it is a SECOND path: each still reaches the rest of the +# scenario through its declared gateway, and the uplink carries only what leaves the scenario — +# the public images, and the forge the control plane is cloned from. +# +# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BOOTSTRAP_BINARY=.../mesh-bootstrap +# MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock +# MESH_LAB_CATALOG=.../mesh-catalog/modules +# MESH_LAB_SOURCE= MESH_LAB_SOURCE_REF= +scenario: fresh-mesh + +segments: + # The routable segment. novox lives here; its public address is the broker endpoint every token + # carries and the overlay hub the home nodes dial. + hosting: + kind: public + cidr: [192.0.2.0/24] + + # The household segment behind an ordinary home router: masquerades v4 outbound, forwards + # inbound, expires idle mappings after two minutes. + home: + kind: private + cidr: [10.99.1.0/24] + gateway: + to: hosting + address: [192.0.2.50] + nat: [v4] + forwardable: true + mapping_ttl: 120s + +machines: + # The anchor. Raised by the installer into a mesh of one, and then asked to build. + # + # Sized for what it actually does here: the store, the broker, the registry, TWO control planes + # during the pivot, the builder, and a build workspace holding a Node toolchain image and an npm + # cache. It is NOT sized for the whole novox service set, because this bed does not run one — it + # proves the machinery that would produce it. + novox: + at: { segment: hosting, address: [192.0.2.20] } + egress: true + inbound: allow + memory: 12GiB + cpus: 6 + disk: 60GiB + + # Three machines that JOIN. Host binary and a token, nothing else — no bootstrap, no substrate, + # no registry. They are deliberately small: what they are here to prove is that a joined machine + # can be given a module the mesh built, which is a question about credentials and not about load. + ace: + at: { segment: home, address: [10.99.1.10] } + egress: true + inbound: allow + memory: 4GiB + cpus: 2 + disk: 25GiB + shanks: + at: { segment: home, address: [10.99.1.20] } + egress: true + inbound: allow + memory: 3GiB + cpus: 2 + disk: 20GiB + g14: + at: { segment: home, address: [10.99.1.30] } + egress: true + inbound: allow + memory: 3GiB + cpus: 2 + disk: 20GiB + +# **No `images:` key, and that is the whole point of this file.** Anything a machine holds here, it +# pulled or the mesh built. See the header. + +place: + all: [host, runtime] diff --git a/src/lifecycle/egress.ts b/src/lifecycle/egress.ts index e2b4995..ebda7fa 100644 --- a/src/lifecycle/egress.ts +++ b/src/lifecycle/egress.ts @@ -89,9 +89,40 @@ export async function confirmEgress( // the retry is tightened so a silent server costs seconds rather than the step. A broken uplink // still fails the check above, before any of this. await resilientResolver(name); + + // **And then prove it is STEADY, because one success proved nothing.** + // + // The check above is satisfied by a single answer, and that is how a machine resolving one + // query in three passed it and then killed two installs twenty minutes later. What a run needs + // is not "a name resolved once" but "names resolve reliably", and those differ by exactly the + // failure that has cost the most time here. Consecutive, because alternating success and + // timeout is the observed shape — a total count would pass on the same machine. + const steady = await steadilyResolves(name, 5); + if (steady < 5) { + throw new EgressError( + `${machine} resolves ${UPSTREAM} only ${steady} time(s) in five consecutive tries.\n` + + ` It has egress and an unreliable resolver, which does not fail here — it fails later, ` + + `inside a pull or a clone, as "could not resolve host" with the cause long out of view.\n` + + ` The uplink's own resolver is the usual culprit and is deliberately last in the list; ` + + `a machine still failing this has something wrong upstream of the lab.`, + ); + } + log(` ${machine} resolves steadily (5/5)`); } } +/** How many of `tries` consecutive lookups answered. Stops at the first failure. */ +async function steadilyResolves(name: string, tries: number): Promise { + for (let i = 0; i < tries; i++) { + const said = await incusOk( + ["exec", name, "--", "sh", "-c", + `timeout 8 getent hosts ${UPSTREAM} >/dev/null && echo yes`], 20_000, + ); + if (said?.trim() !== "yes") return i; + } + return tries; +} + async function resolves(name: string, waitSeconds: number): Promise { const deadline = Date.now() + waitSeconds * 1_000; while (Date.now() < deadline) { @@ -140,11 +171,22 @@ async function reaches(name: string, waitSeconds: number): Promise { await incus([ @@ -152,7 +194,8 @@ async function resilientResolver(name: string): Promise { `link=$(ip -4 route show default | awk '{print $5}' | head -n1); ` + `via=$(ip -4 route show default | awk '{print $3}' | head -n1); ` + `if [ -n "$link" ] && command -v resolvectl >/dev/null 2>&1; then ` + - `resolvectl dns "$link" $via 1.1.1.1 8.8.8.8 >/dev/null 2>&1 || true; fi; true`, + `resolvectl dns "$link" 1.1.1.1 8.8.8.8 9.9.9.9 $via >/dev/null 2>&1 || true; ` + + `resolvectl flush-caches >/dev/null 2>&1 || true; fi; true`, ], 30_000); } diff --git a/src/lifecycle/raise.ts b/src/lifecycle/raise.ts index 0c1aa9c..96e402e 100644 --- a/src/lifecycle/raise.ts +++ b/src/lifecycle/raise.ts @@ -307,9 +307,18 @@ export async function raise( enter("waiting for machines to become usable"); await waitUntilAllUsable(created, readyTimeout, log); + // **Before the uplink lease is asked for, make sure each machine asks as itself.** + enter("giving each machine its own identity"); + await distinguishMachines(created, log); + enter("applying declared addresses"); await applyAddresses(scenario, instanceId, byMachine, log); + // The positive control for the step above: if two machines share an uplink address, say so + // HERE, where it is one obvious sentence, rather than letting it surface an hour later as + // intermittent name resolution on some machines and not others. + await noTwoMachinesShareAnAddress(scenario, 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); @@ -356,3 +365,85 @@ export async function raise( throw new RaiseError(instanceId, step, cause); } } + +/** + * Give every machine a machine-id of its own, before any of them asks for an uplink lease. + * + * **Every machine in a bed is a clone of one base image, so they all boot with the SAME + * `/etc/machine-id`.** systemd's DHCP client derives its client identifier from that file, and the + * uplink's dnsmasq keys leases on the client identifier rather than on the MAC — so four machines + * with four distinct MACs were all handed *the same address*, and the host kept one ARP entry for + * it. Whichever machine last answered an ARP request got everybody's replies. + * + * This is the fault behind every "the lab's DNS is flaky" run. It does not present as an address + * conflict; it presents as name resolution that works two times in three, as pulls that succeed on + * a retry, and as one machine out of four being fine — because that machine happened to be holding + * the address. It survived an earlier diagnosis that blamed resolver ordering, because reordering + * resolvers on a machine that has just won the ARP race does appear to fix it. + * + * **Ordering is the whole of it, and the first version got it wrong in the other direction.** That + * version also restarted networkd and waited for a new lease, which is what you would do to repair + * a machine already holding a shared address. Here there is nothing to repair yet: the uplink is + * still down at this point and `applyAddresses` is what asks for the lease. So this writes the + * identity and stops, and the request that follows is made as somebody. + * + * Machines without `systemd-machine-id-setup` are left alone; the step is advisory. + */ +async function distinguishMachines(names: string[], log: (m: string) => void): Promise { + for (const name of names) { + const said = await incusOk([ + "exec", name, "--", "sh", "-c", + // Regenerated rather than written: `systemd-machine-id-setup` owns the format, and a + // hand-made value that is not 32 hex characters is rejected by systemd at next boot. + `command -v systemd-machine-id-setup >/dev/null 2>&1 || { echo unchanged; exit 0; }; ` + + `rm -f /etc/machine-id && systemd-machine-id-setup >/dev/null 2>&1; ` + + `cat /etc/machine-id`, + ], 60_000); + log(` ${name} is ${said?.trim().slice(0, 12) || "unchanged"}`); + } +} + +/** + * Refuse to go on if two machines took the same uplink address. + * + * A check rather than a comment, because the failure it guards is invisible where it happens and + * unrecognisable where it surfaces. Every machine that declares egress is asked what address it + * holds; two the same is a stop, named as what it is. + */ +async function noTwoMachinesShareAnAddress( + scenario: Scenario, + byMachine: Map, + log: (m: string) => void, +): Promise { + const held = new Map(); + for (const [machine, spec] of Object.entries(scenario.machines)) { + if (!spec.egress || spec.at === "detached") continue; + const name = byMachine.get(machine); + if (!name) continue; + const said = (await incusOk([ + "exec", name, "--", "sh", "-c", + // The `src` of the default route, NOT its last field — the first version of this took + // `$NF` and compared four machines' route METRIC, which is identical by construction and + // made the guard fire on every bed. A check that cannot be wrong is worth less than one + // that is read carefully once. + `ip -4 -o route show default 2>/dev/null | ` + + `awk '{for (i = 1; i <= NF; i++) if ($i == "src") { print $(i + 1); exit }}' | head -n1`, + ], 30_000))?.trim(); + if (!said) continue; + held.set(said, [...(held.get(said) ?? []), machine]); + } + const shared = [...held.entries()].filter(([, who]) => who.length > 1); + if (shared.length === 0) { + if (held.size > 0) log(` every machine with egress took an address of its own`); + return; + } + throw new Error( + `two machines took the SAME uplink address: ` + + shared.map(([a, who]) => `${a} held by ${who.join(" and ")}`).join("; ") + `.\n` + + ` They are clones of one image, so they present one DHCP client identity unless each is ` + + `given its own machine-id before the lease is asked for.\n` + + ` This does not fail as an address conflict. It fails later, as name resolution that works ` + + `about two times in three and as pulls that succeed on a retry, because the host holds one ` + + `ARP entry and whichever machine answered last receives the replies.`, + ); +} diff --git a/test/integration/fresh-mesh.test.ts b/test/integration/fresh-mesh.test.ts new file mode 100644 index 0000000..2c7198b --- /dev/null +++ b/test/integration/fresh-mesh.test.ts @@ -0,0 +1,355 @@ +/** + * FOUR FRESH MACHINES, AND NOTHING HANDED TO THEM. + * + * The four-machine bed (`whole-mesh-full`) proves the mesh converges. It does so by loading + * thirty-four of the mesh's own images onto its machines from the workstation, because it does not + * build them — something beside the bed built them and copied them in. No real installation looks + * like that, and the lab has been burned by exactly this shape before: it used to raise a registry + * inside the scenario, and a bootstrap that only worked against that registry went green here and + * would have failed on any bare machine. + * + * This bed hands over nothing. The scenario has no `images:` list at all. What the machines get is + * a container runtime and the host binary — prerequisites of a machine, not parts of a mesh — and + * from there: + * + * 1. novox is raised into a mesh of one by the installer, which BUILDS the control plane. + * 2. ace, shanks and g14 JOIN it, across a household NAT, with a token and nothing else. + * 3. The mesh builds the shared base from source, with its own builder. + * 4. The mesh builds a real module standing on that base. + * 5. The anchor runs it, pinned to a digest the mesh's own registry assigned. + * 6. A JOINED machine runs it — which means pulling from a registry that asks who it is. + * + * Steps 1 and 2 are proven elsewhere and are here because the later ones need them. **Steps 3 + * through 6 are what this bed exists for**, and 6 is the one nothing has ever checked: genesis + * puts the builder, the registry and everything they produce on ONE machine, so every earlier + * proof of a mesh-built module running is a proof about the machine that built it. A second + * machine has to fetch, and fetching needs an account nothing yet grants (novox/hq issue 042). + * + * Each step is recorded separately rather than allowed to throw, so a gap at 6 reports as a gap at + * 6 instead of erasing the evidence for 3, 4 and 5. + * + * MESH_LAB_INCUS='sudo -n incus' + * MESH_LAB_HOST_BINARY=.../mesh-host/mesh-host + * MESH_LAB_BOOTSTRAP_BINARY=.../mesh-host/mesh-bootstrap + * MESH_LAB_BUNDLE=.../mesh-host/examples/substrate-first-node.lock + * MESH_LAB_CATALOG=.../mesh-catalog/modules + * MESH_LAB_SOURCE=/mesh-control.git MESH_LAB_SOURCE_REF= + * MESH_LAB_KEEP=1 to leave it standing afterwards + */ +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { loadScenario } from "../../src/declaration/parse.ts"; +import { raise } from "../../src/lifecycle/raise.ts"; +import { destroy, exec, push } from "../../src/lifecycle/operate.ts"; +import { bootstrapBinaryPath, hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; +import { labIsUsable, destroyAll, substrateBundle } from "./harness.ts"; +import { genesis, type GenesisResult } from "./genesis.ts"; + +const SCENARIO = "fresh-mesh"; +const CONTROL = "novox"; +const HOME_NODES = ["ace", "shanks", "g14"]; +/** The joined machine asked to run a mesh-built module. Any of the three would do. */ +const SECOND = "ace"; + +/** The anchor's public address — what every other machine dials, and what its own token must name. */ +const ANCHOR = "192.0.2.20"; +/** Where this mesh's registry answers, on the anchor's public address so a joined node can reach it. */ +const REGISTRY = `${ANCHOR}:5000`; + +/** + * The module built on top of the base, and the base it stands on. + * + * `amqp-ping` is deliberately small and deliberately REAL: its own TypeScript, compiled by the + * shared toolchain, running on the shared runtime, talking to the broker. A module whose artifact + * is a mirrored public image would pass every assertion below while skipping the whole of what is + * under test (novox/hq SELF-UPGRADE-PLAN, rule 1). + */ +const BASE = { module: "mesh-tools", repo: "mesh-tools", path: "" }; +const MODULE = { module: "amqp-ping", repo: "mesh-catalog", path: "modules/amqp-ping" }; + +const capability = await labIsUsable(); +const binary = hostBinaryPath(); +const installer = bootstrapBinaryPath(); +const bundle = process.env["MESH_LAB_BUNDLE"] ?? ""; +const catalogDir = process.env["MESH_LAB_CATALOG"] ?? ""; +const source = process.env["MESH_LAB_SOURCE"] ?? ""; +const sourceRef = process.env["MESH_LAB_SOURCE_REF"] ?? ""; +const KEEP = !!process.env["MESH_LAB_KEEP"]; +const FIXED_ID = process.env["MESH_LAB_INSTANCE_ID"] ?? (KEEP ? "fresh-mesh-live" : undefined); + +/** + * Where a repository other than the control plane's lives. + * + * Derived from `MESH_LAB_SOURCE` by swapping the last path segment, because every one of these + * repositories sits beside the others under the same owner on the same forge. Overridable, so a + * forge that is arranged differently does not need this bed edited. + */ +function forgeUrl(repo: string): string { + const override = process.env[`MESH_LAB_SOURCE_${repo.toUpperCase().replaceAll("-", "_")}`]; + if (override) return override; + return source.replace(/[^/]+\.git$/, `${repo}.git`); +} +/** A branch is acceptable for an ordinary build; only genesis insists on a commit (ADR 0071). */ +const buildRef = process.env["MESH_LAB_BUILD_REF"] ?? "main"; + +/** + * The shared base's manifest, on this workstation. + * + * The base is a repository with a manifest at its root (novox/hq ADR 0069), so unlike the + * catalogue's modules it is not under `MESH_LAB_CATALOG`. Derived from that path on the convention + * that the checkouts sit beside each other, and overridable for a layout where they do not. + */ +const baseManifest = process.env["MESH_LAB_BASE_MANIFEST"] ?? + resolve(catalogDir, "..", "..", BASE.repo, "module.json"); + +const skip = + !capability.usable ? capability.why : + !binary ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" : + !installer ? "MESH_LAB_BOOTSTRAP_BINARY is not set to a built mesh-bootstrap" : + !source ? "MESH_LAB_SOURCE is not set to the repository the control plane is built from" : + !sourceRef ? "MESH_LAB_SOURCE_REF is not set to the commit to build" : + !bundle || !existsSync(bundle) ? "MESH_LAB_BUNDLE is not set to a substrate template" : + !catalogDir || !existsSync(catalogDir) ? "MESH_LAB_CATALOG is not set to mesh-catalog/modules" : + false; + +let instanceId = ""; +let raised: GenesisResult; + +// ---- talking to the machines ------------------------------------------------------------------ + +function quote(s: string): string { + return `'${s.replaceAll("'", `'\\''`)}'`; +} +async function on(machine: string, command: string, timeoutMs?: number): Promise<{ out: string; ok: boolean }> { + const { stdout } = await exec(instanceId, machine, [ + "sh", "-c", `exec 2>&1\n${command}\necho "__exit=$?"`, + ], timeoutMs); + const marker = stdout.lastIndexOf("__exit="); + if (marker < 0) return { out: stdout, ok: false }; + return { out: stdout.slice(0, marker), ok: stdout.slice(marker + 7).trim() === "0" }; +} +async function must(machine: string, command: string, timeoutMs?: number): Promise { + const { out, ok } = await on(machine, command, timeoutMs); + if (!ok) throw new Error(`${machine}: ${command}\n${out}`); + return out; +} +async function mesh(command: string, timeoutMs?: number): Promise { + return must(CONTROL, `docker exec mesh-control /mesh-control ${command}`, timeoutMs); +} +function tokenFrom(said: string): string { + const found = said.split("\n").map((l) => l.trim()).find((l) => l.length > 100 && !l.includes(" ")); + assert.ok(found, `no token in:\n${said}`); + return found; +} + +// ---- steps, recorded rather than thrown -------------------------------------------------------- + +interface Step { ok: boolean; why: string; said: string } +const steps = new Map(); +const order: string[] = []; + +/** Run a step, remember what it said, and never throw. A step whose predecessor failed is skipped. */ +async function step(name: string, after_: string | null, fn: () => Promise): Promise { + order.push(name); + if (after_ && !steps.get(after_)?.ok) { + steps.set(name, { ok: false, why: `not attempted — "${after_}" did not succeed`, said: "" }); + console.log(`SKIPPED ${name}`); + return; + } + console.log(`\n======== ${name} ========`); + try { + const said = await fn(); + steps.set(name, { ok: true, why: "", said }); + console.log(`OK ${name}`); + } catch (err) { + const why = (err as Error).message; + steps.set(name, { ok: false, why, said: "" }); + console.log(`FAILED ${name}\n${why.split("\n").slice(0, 25).join("\n")}`); + } +} + +function report(name: string): string { + const s = steps.get(name); + if (!s) return `${name}: never ran`; + const lines = order.map((n) => { + const it = steps.get(n); + return ` ${it?.ok ? "PASS" : "FAIL"} ${n}`; + }); + return `${s.why}\n\nWhere this bed got to:\n${lines.join("\n")}`; +} + +before(async () => { + if (skip) return; + + const bed = await raise(loadScenario(`scenarios/${SCENARIO}.yml`), { + onProgress: (m) => console.log(`raise: ${m}`), + ...(FIXED_ID ? { instanceId: FIXED_ID } : {}), + }); + instanceId = bed.instanceId; + console.log(`INSTANCE ${instanceId}${KEEP ? " (KEEP — will be left standing)" : ""}`); + console.log(`NOTHING WAS LOADED: this scenario names no images. Every image on every machine ` + + `below was pulled from the internet or built by the mesh.`); + + // ---- 1. GENESIS ----------------------------------------------------------------------------- + // + // The shared description, the same one `genesis-single` calls. The bundle is the TEMPLATE with + // nothing held: no image is pre-resolved, because none is here to resolve to. + await step("novox becomes a mesh of one, raised by the installer", null, async () => { + try { + raised = await genesis({ + instanceId, + node: CONTROL, + installer: installer as string, + catalogDir, + // The broker's advertised address, corrected. + // + // The template hardcodes 192.0.2.10:5671 — the address of the anchor in the single-machine + // bed. A token carries this verbatim as the endpoint an enrolling node dials, so on a mesh + // whose anchor is somewhere else every node, including this one, would enrol against an + // address nothing answers on. The installer refuses to guess it and says so, which is + // right: it does not know what this machine is called from outside. + bundleTemplate: substrateBundle(bundle, []).replaceAll("192.0.2.10:5671", `${ANCHOR}:5671`), + registry: REGISTRY, + source, + sourceRef, + log: (m) => console.log(m), + }); + } catch (err) { + throw new Error(`the installer never ran: ${(err as Error).message}`); + } + if (!raised.ok) throw new Error(`${raised.step || "no step named"}: ${raised.why}\n\n${raised.report.join("\n")}`); + return raised.report.join("\n"); + }); + + // ---- 2. JOINING ----------------------------------------------------------------------------- + // + // Host binary and a token. novox is NOT in this loop — the installer enrolled it, and enrolling + // it again would offer the mesh a second identity for a node it already knows. + await step("three machines join it across the household gateway", + "novox becomes a mesh of one, raised by the installer", async () => { + const said: string[] = []; + for (const machine of HOME_NODES) { + await mesh(`node add ${machine}`); + const token = tokenFrom(await mesh(`token issue --node ${machine}`)); + const out = await must(machine, `${HOST_PATH} enrol --token ${quote(token)}`, 180_000); + assert.match(out, new RegExp(`enrolled as ${machine}`), out); + await must(machine, `nohup ${HOST_PATH} run > /var/log/mesh-host.log 2>&1 & sleep 3`); + said.push(` ${machine} enrolled and running`); + } + const nodes = await mesh("node list"); + said.push(nodes.trim()); + return said.join("\n"); + }); + + // ---- 3. THE MESH BUILDS THE SHARED BASE ----------------------------------------------------- + // + // The toolchain and runtime every module with code of its own stands on. It is a module, and it + // is built like one — cloned from the forge by the builder installing put here, compiled on the + // machine, published into the mesh's own registry. + await step("the mesh builds the shared base from source", + "three machines join it across the household gateway", async () => { + assert.ok(existsSync(baseManifest), + `no manifest for the shared base at ${baseManifest} — set MESH_LAB_BASE_MANIFEST`); + await push(instanceId, CONTROL, baseManifest, `/tmp/${BASE.module}.json`); + // Registered from the manifest the builder will also read, so what the mesh holds and what it + // builds are the same description of the same module. + await mesh(`module add /tmp/${BASE.module}.json`).catch(() => {}); + const built = await mesh( + `build ${forgeUrl(BASE.repo)} --ref ${buildRef} --wait 1200s`, 1_500_000); + assert.doesNotMatch(built, /failed/i, built); + return built; + }); + + // ---- 4. AND A MODULE STANDING ON IT --------------------------------------------------------- + await step("the mesh builds a module standing on that base", + "the mesh builds the shared base from source", async () => { + const manifest = resolve(catalogDir, MODULE.module, "module.json"); + assert.ok(existsSync(manifest), `no manifest at ${manifest}`); + await push(instanceId, CONTROL, manifest, `/tmp/${MODULE.module}.json`); + await mesh(`module add /tmp/${MODULE.module}.json`); + const built = await mesh( + `build ${forgeUrl(MODULE.repo)} --path ${MODULE.path} --ref ${buildRef} --wait 1200s`, + 1_500_000); + assert.doesNotMatch(built, /failed/i, built); + // The point of the whole step: what came out is named by a digest this mesh's registry + // assigned, not by a placeholder and not by a tag. + const builds = await mesh(`builds ${MODULE.module}`); + assert.match(builds, /sha256:[0-9a-f]{12}/, + `the build recorded no digest — the module is not pinned to anything this registry serves:\n${builds}`); + return `${built}\n${builds}`; + }); + + // ---- 5. THE ANCHOR RUNS IT ------------------------------------------------------------------ + // + // The machine that built it. This is the case every earlier proof covered, and it is here as the + // control for step 6: if this fails, step 6's failure says nothing about fetching. + await step("the anchor runs the module the mesh built", + "the mesh builds a module standing on that base", async () => { + await mesh(`module issue ${MODULE.module} --node ${CONTROL}`).catch(() => {}); + await mesh(`assign ${CONTROL} ${MODULE.module}`); + await mesh(`push ${CONTROL}`, 600_000); + for (let i = 0; i < 40; i++) { + const ps = (await on(CONTROL, `docker ps --format '{{.Names}}\t{{.Status}}'`)).out; + if (/amqp-ping/.test(ps) && /Up /.test(ps.split("\n").find((l) => l.includes("amqp-ping")) ?? "")) { + return ps; + } + await new Promise((r) => setTimeout(r, 5_000)); + } + throw new Error(`amqp-ping never came up on ${CONTROL}:\n` + + (await on(CONTROL, `docker ps -a --format '{{.Names}}\t{{.Status}}'`)).out); + }); + + // ---- 6. AND A MACHINE THAT DID NOT BUILD IT ------------------------------------------------- + // + // **The thing nothing has ever checked.** ace did not build this image and has never seen it. To + // run it, it must fetch it from the mesh's registry — and a joined node has no account there. + // The mesh grants a consumer a credential for a database; it does not yet do so for the store + // its own images live in (novox/hq issue 042). + // + // Asked anyway, and asked LAST, so that when it fails the five steps above still stand as + // evidence of what does work. + await step(`a joined machine runs the module the mesh built`, + "the anchor runs the module the mesh built", async () => { + await mesh(`module issue ${MODULE.module} --node ${SECOND}`).catch(() => {}); + await mesh(`assign ${SECOND} ${MODULE.module}`); + await mesh(`push ${SECOND}`, 600_000); + for (let i = 0; i < 40; i++) { + const ps = (await on(SECOND, `docker ps --format '{{.Names}}\t{{.Status}}'`)).out; + const line = ps.split("\n").find((l) => l.includes("amqp-ping")); + if (line && /Up /.test(line)) return ps; + await new Promise((r) => setTimeout(r, 5_000)); + } + const state = (await on(SECOND, `docker ps -a --format '{{.Names}}\t{{.Status}}'`)).out; + const log = (await on(SECOND, `tail -40 /var/log/mesh-host.log`)).out; + throw new Error( + `amqp-ping never came up on ${SECOND} — the machine that did NOT build it.\n\n` + + `containers:\n${state}\n\nwhat the host said:\n${log}`); + }); + + console.log(`\n================ WHAT THIS MESH DID FOR ITSELF ================`); + for (const n of order) console.log(` ${steps.get(n)?.ok ? "PASS" : "FAIL"} ${n}`); +}, { timeout: 7_200_000 }); + +after(async () => { + if (KEEP) { + console.log(`\nLEFT STANDING: ${instanceId} — not destroyed (MESH_LAB_KEEP).`); + return; + } + if (instanceId) await destroy(instanceId); + await destroyAll(`${SCENARIO}-`); +}, { timeout: 900_000 }); + +for (const name of [ + "novox becomes a mesh of one, raised by the installer", + "three machines join it across the household gateway", + "the mesh builds the shared base from source", + "the mesh builds a module standing on that base", + "the anchor runs the module the mesh built", + "a joined machine runs the module the mesh built", +]) { + test(name, { skip, timeout: 60_000 }, () => { + assert.ok(steps.get(name)?.ok, report(name)); + }); +} From 4ef0a1905326186562cff644012a8672f1cbf208 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 14 Sep 2026 20:46:11 +0200 Subject: [PATCH 02/26] A manifest the control plane can open, and stop swallowing the failure when it cannot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mesh-control runs in a container, so a manifest pushed to the machine is not a file it can read; `module add` said so plainly and it was briefly taken for a missing manifest. It is copied the last step of the way now. The base's registration was doing this too, and its failure was swallowed by a bare catch on the reasoning that the module might already be known. The step passed regardless — a base with nothing to stand on builds whether or not the mesh holds a record of it — and the fault surfaced one step later, where the record was needed. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- test/integration/fresh-mesh.test.ts | 32 +++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/test/integration/fresh-mesh.test.ts b/test/integration/fresh-mesh.test.ts index 2c7198b..b5b524b 100644 --- a/test/integration/fresh-mesh.test.ts +++ b/test/integration/fresh-mesh.test.ts @@ -138,6 +138,22 @@ async function must(machine: string, command: string, timeoutMs?: number): Promi async function mesh(command: string, timeoutMs?: number): Promise { return must(CONTROL, `docker exec mesh-control /mesh-control ${command}`, timeoutMs); } + +/** + * Register a module from a manifest on this workstation. + * + * **The control plane runs in a container, so a file on the machine is not a file it can open.** + * Pushing the manifest to the machine and naming that path got `no such file or directory` from + * inside mesh-control, which is correct and was briefly mistaken for a missing manifest. It is + * copied the last step of the way with `docker cp`. + */ +async function registerModule(module: string, manifest: string): Promise { + assert.ok(existsSync(manifest), `no manifest for ${module} at ${manifest}`); + const onMachine = `/tmp/${module}.json`; + await push(instanceId, CONTROL, manifest, onMachine); + await must(CONTROL, `docker cp ${onMachine} mesh-control:${onMachine}`); + return mesh(`module add ${onMachine}`); +} function tokenFrom(said: string): string { const found = said.split("\n").map((l) => l.trim()).find((l) => l.length > 100 && !l.includes(" ")); assert.ok(found, `no token in:\n${said}`); @@ -250,12 +266,15 @@ before(async () => { // machine, published into the mesh's own registry. await step("the mesh builds the shared base from source", "three machines join it across the household gateway", async () => { - assert.ok(existsSync(baseManifest), - `no manifest for the shared base at ${baseManifest} — set MESH_LAB_BASE_MANIFEST`); - await push(instanceId, CONTROL, baseManifest, `/tmp/${BASE.module}.json`); // Registered from the manifest the builder will also read, so what the mesh holds and what it // builds are the same description of the same module. - await mesh(`module add /tmp/${BASE.module}.json`).catch(() => {}); + // + // **Not swallowed.** This call used to end in `.catch(() => {})`, on the reasoning that the + // base might already be known. It hid a real failure — the manifest was being named at a path + // inside a container that had never seen it — and the step passed anyway, because a base with + // nothing to stand on builds whether or not the mesh has a record of it. The next step, which + // needs that record, is where it surfaced. + await registerModule(BASE.module, baseManifest); const built = await mesh( `build ${forgeUrl(BASE.repo)} --ref ${buildRef} --wait 1200s`, 1_500_000); assert.doesNotMatch(built, /failed/i, built); @@ -265,10 +284,7 @@ before(async () => { // ---- 4. AND A MODULE STANDING ON IT --------------------------------------------------------- await step("the mesh builds a module standing on that base", "the mesh builds the shared base from source", async () => { - const manifest = resolve(catalogDir, MODULE.module, "module.json"); - assert.ok(existsSync(manifest), `no manifest at ${manifest}`); - await push(instanceId, CONTROL, manifest, `/tmp/${MODULE.module}.json`); - await mesh(`module add /tmp/${MODULE.module}.json`); + await registerModule(MODULE.module, resolve(catalogDir, MODULE.module, "module.json")); const built = await mesh( `build ${forgeUrl(MODULE.repo)} --path ${MODULE.path} --ref ${buildRef} --wait 1200s`, 1_500_000); From f04911a763e33c6ea17c407313572b6c057b13cd Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 14 Sep 2026 20:56:35 +0200 Subject: [PATCH 03/26] Give the module the broker it asks for, rather than a module that asks for nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mesh refused to place amqp-ping: nothing provides amqp. That refusal is right. The substrate raises a broker, but as a bundle resource — plumbing, not a module the mesh has a record of — so it offers nothing to anything, and a module wanting a broker wants one in the graph. lavinmq is that module and needs no building, its image being upstream, so this is a register and an assign. The alternative was to pick a module with no requires, which would have passed by testing less. Also: the control plane's image has no /tmp to copy a manifest into. Root does. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- test/integration/fresh-mesh.test.ts | 47 ++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/test/integration/fresh-mesh.test.ts b/test/integration/fresh-mesh.test.ts index b5b524b..70627ce 100644 --- a/test/integration/fresh-mesh.test.ts +++ b/test/integration/fresh-mesh.test.ts @@ -67,6 +67,16 @@ const REGISTRY = `${ANCHOR}:5000`; * under test (novox/hq SELF-UPGRADE-PLAN, rule 1). */ const BASE = { module: "mesh-tools", repo: "mesh-tools", path: "" }; +/** + * What `amqp-ping` requires, and what the substrate does not supply. + * + * The installer raises a broker, but as a bundle resource — plumbing, not a module the mesh has a + * record of, so it provides nothing to anything. A module asking for `amqp` is asking for a + * provider in the graph, and this is it. No build: its image is upstream. + */ +const PROVIDER = { module: "lavinmq" }; +/** Named once, because the step title is also how later steps say what they waited on. */ +const NEEDS = "the mesh runs a broker for that module to talk to"; const MODULE = { module: "amqp-ping", repo: "mesh-catalog", path: "modules/amqp-ping" }; const capability = await labIsUsable(); @@ -146,13 +156,17 @@ async function mesh(command: string, timeoutMs?: number): Promise { * Pushing the manifest to the machine and naming that path got `no such file or directory` from * inside mesh-control, which is correct and was briefly mistaken for a missing manifest. It is * copied the last step of the way with `docker cp`. + * + * **Into the root, not into /tmp.** The control plane's image is a minimal one and has no `/tmp` + * to copy into — `docker cp` says so in those words. `/` is the one directory every image has. */ async function registerModule(module: string, manifest: string): Promise { assert.ok(existsSync(manifest), `no manifest for ${module} at ${manifest}`); const onMachine = `/tmp/${module}.json`; + const inContainer = `/${module}.json`; await push(instanceId, CONTROL, manifest, onMachine); - await must(CONTROL, `docker cp ${onMachine} mesh-control:${onMachine}`); - return mesh(`module add ${onMachine}`); + await must(CONTROL, `docker cp ${onMachine} mesh-control:${inContainer}`); + return mesh(`module add ${inContainer}`); } function tokenFrom(said: string): string { const found = said.split("\n").map((l) => l.trim()).find((l) => l.length > 100 && !l.includes(" ")); @@ -297,12 +311,36 @@ before(async () => { return `${built}\n${builds}`; }); + // ---- 4b. WHAT THE MODULE NEEDS -------------------------------------------------------------- + // + // `amqp-ping` requires the `amqp` provision, and the mesh refused to place it: "nothing provides + // amqp, wanted by amqp-ping". That refusal is correct and is the reason this step exists rather + // than the reason to pick an easier module. **The substrate's broker is not a provider.** It is + // raised by the installer as part of the bundle, so it is a running container and not a module + // with something to offer — the mesh's own plumbing, not an entry in its graph. A module that + // wants a broker wants one the mesh knows about. + // + // `lavinmq` is that module, and it needs no building: its image is upstream, so this is a + // register and an assign, and it is a fair test of provisioning rather than of building. + await step(NEEDS, "the mesh builds a module standing on that base", async () => { + await registerModule(PROVIDER.module, resolve(catalogDir, PROVIDER.module, "module.json")); + await mesh(`assign ${CONTROL} ${PROVIDER.module}`); + await mesh(`push ${CONTROL}`, 600_000); + for (let i = 0; i < 60; i++) { + const ps = (await on(CONTROL, `docker ps --format '{{.Names}}\t{{.Status}}'`)).out; + const line = ps.split("\n").find((l) => l.includes(PROVIDER.module)); + if (line && /Up /.test(line)) return ps; + await new Promise((r) => setTimeout(r, 5_000)); + } + throw new Error(`${PROVIDER.module} never came up on ${CONTROL}:\n` + + (await on(CONTROL, `docker ps -a --format '{{.Names}}\t{{.Status}}'`)).out); + }); + // ---- 5. THE ANCHOR RUNS IT ------------------------------------------------------------------ // // The machine that built it. This is the case every earlier proof covered, and it is here as the // control for step 6: if this fails, step 6's failure says nothing about fetching. - await step("the anchor runs the module the mesh built", - "the mesh builds a module standing on that base", async () => { + await step("the anchor runs the module the mesh built", NEEDS, async () => { await mesh(`module issue ${MODULE.module} --node ${CONTROL}`).catch(() => {}); await mesh(`assign ${CONTROL} ${MODULE.module}`); await mesh(`push ${CONTROL}`, 600_000); @@ -362,6 +400,7 @@ for (const name of [ "three machines join it across the household gateway", "the mesh builds the shared base from source", "the mesh builds a module standing on that base", + NEEDS, "the anchor runs the module the mesh built", "a joined machine runs the module the mesh built", ]) { From c3d5ec1d55854213b2c3fc8043374e06b19d7651 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 14 Sep 2026 21:05:28 +0200 Subject: [PATCH 04/26] Build each repository from its own ref, and build the provider lavinmq needs building now, so the step that assigns it builds it first. And the ref is per repository rather than one value for all of them: a change under test lives in one repository, and building the others from that branch would prove it against itself. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- test/integration/fresh-mesh.test.ts | 35 +++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/test/integration/fresh-mesh.test.ts b/test/integration/fresh-mesh.test.ts index 70627ce..7b95aa5 100644 --- a/test/integration/fresh-mesh.test.ts +++ b/test/integration/fresh-mesh.test.ts @@ -74,7 +74,7 @@ const BASE = { module: "mesh-tools", repo: "mesh-tools", path: "" }; * record of, so it provides nothing to anything. A module asking for `amqp` is asking for a * provider in the graph, and this is it. No build: its image is upstream. */ -const PROVIDER = { module: "lavinmq" }; +const PROVIDER = { module: "lavinmq", repo: "mesh-catalog", path: "modules/lavinmq" }; /** Named once, because the step title is also how later steps say what they waited on. */ const NEEDS = "the mesh runs a broker for that module to talk to"; const MODULE = { module: "amqp-ping", repo: "mesh-catalog", path: "modules/amqp-ping" }; @@ -101,8 +101,21 @@ function forgeUrl(repo: string): string { if (override) return override; return source.replace(/[^/]+\.git$/, `${repo}.git`); } -/** A branch is acceptable for an ordinary build; only genesis insists on a commit (ADR 0071). */ -const buildRef = process.env["MESH_LAB_BUILD_REF"] ?? "main"; +/** + * What to build, per repository. + * + * A branch is acceptable for an ordinary build; only genesis insists on a commit (ADR 0071). Per + * repository rather than one value for all of them, because a change under test usually lives in + * one repository and the rest should be built from what everyone else has — building them all from + * a feature branch would prove that branch against itself. + * + * MESH_LAB_BUILD_REF the default for every repository + * MESH_LAB_BUILD_REF_MESH_CATALOG ...overridden for one + */ +function refFor(repo: string): string { + const override = process.env[`MESH_LAB_BUILD_REF_${repo.toUpperCase().replaceAll("-", "_")}`]; + return override ?? process.env["MESH_LAB_BUILD_REF"] ?? "main"; +} /** * The shared base's manifest, on this workstation. @@ -290,7 +303,7 @@ before(async () => { // needs that record, is where it surfaced. await registerModule(BASE.module, baseManifest); const built = await mesh( - `build ${forgeUrl(BASE.repo)} --ref ${buildRef} --wait 1200s`, 1_500_000); + `build ${forgeUrl(BASE.repo)} --ref ${refFor(BASE.repo)} --wait 1200s`, 1_500_000); assert.doesNotMatch(built, /failed/i, built); return built; }); @@ -300,7 +313,7 @@ before(async () => { "the mesh builds the shared base from source", async () => { await registerModule(MODULE.module, resolve(catalogDir, MODULE.module, "module.json")); const built = await mesh( - `build ${forgeUrl(MODULE.repo)} --path ${MODULE.path} --ref ${buildRef} --wait 1200s`, + `build ${forgeUrl(MODULE.repo)} --path ${MODULE.path} --ref ${refFor(MODULE.repo)} --wait 1200s`, 1_500_000); assert.doesNotMatch(built, /failed/i, built); // The point of the whole step: what came out is named by a digest this mesh's registry @@ -320,10 +333,18 @@ before(async () => { // with something to offer — the mesh's own plumbing, not an entry in its graph. A module that // wants a broker wants one the mesh knows about. // - // `lavinmq` is that module, and it needs no building: its image is upstream, so this is a - // register and an assign, and it is a fair test of provisioning rather than of building. + // `lavinmq` is that module. It was first added here on the belief that it needed no building — + // its broker is an upstream image — and the mesh refused it: two of its three containers named a + // placeholder digest, "which is never a real image". That was right. The broker is upstream, but + // the module is not only the broker: it carries a run-once bootstrap that writes the broker's + // configuration, a provisioner that grants each consumer its own vhost and user, tools and an + // event consumer. All of that is its own code and has to be built like anything else. await step(NEEDS, "the mesh builds a module standing on that base", async () => { await registerModule(PROVIDER.module, resolve(catalogDir, PROVIDER.module, "module.json")); + const built = await mesh( + `build ${forgeUrl(PROVIDER.repo)} --path ${PROVIDER.path} --ref ${refFor(PROVIDER.repo)} --wait 1200s`, + 1_500_000); + assert.doesNotMatch(built, /failed/i, built); await mesh(`assign ${CONTROL} ${PROVIDER.module}`); await mesh(`push ${CONTROL}`, 600_000); for (let i = 0; i < 60; i++) { From 9146f30859207f4752f074ddeb671a62d410ba5f Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 14 Sep 2026 21:20:57 +0200 Subject: [PATCH 05/26] Name the container, and issue the account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Waiting for "a container whose name contains lavinmq" was satisfied by the broker — lavinmq, up and healthy — while the thing under test, the module's own runtime mesh-lavinmq, crash-looped beside it. The step went green and the fault was found by reading docker ps by hand. Containers are named exactly now, and a failure prints that container's own last words. And lavinmq gets a broker account, which it was never issued. Without one the mesh still fills the secret the module declares it owns, with a generated value, so the runtime starts, fails to parse a password as a credential document, and loops on a JSON syntax error that mentions no missing account. The two module-issue calls written .catch(() => {}) are not. That pattern has now hidden three separate faults in this file. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- test/integration/fresh-mesh.test.ts | 68 +++++++++++++++++------------ 1 file changed, 39 insertions(+), 29 deletions(-) diff --git a/test/integration/fresh-mesh.test.ts b/test/integration/fresh-mesh.test.ts index 7b95aa5..83c83fe 100644 --- a/test/integration/fresh-mesh.test.ts +++ b/test/integration/fresh-mesh.test.ts @@ -162,6 +162,29 @@ async function mesh(command: string, timeoutMs?: number): Promise { return must(CONTROL, `docker exec mesh-control /mesh-control ${command}`, timeoutMs); } +/** + * Wait for one container, BY NAME, to be running. + * + * **Named exactly, because substring matching passed a step that had failed.** Waiting for "a + * container whose name contains lavinmq" was satisfied by the broker — `lavinmq`, up and healthy — + * while the thing actually under test, the module's own runtime `mesh-lavinmq`, was crash-looping + * beside it. The step went green and the fault was found by reading `docker ps` by hand. + */ +async function waitForContainer(node: string, container: string, seconds = 200): Promise { + const deadline = Date.now() + seconds * 1_000; + let last = ""; + while (Date.now() < deadline) { + const ps = (await on(node, `docker ps -a --format '{{.Names}}\t{{.Status}}'`)).out; + last = ps; + const line = ps.split("\n").find((l) => l.split("\t")[0]?.trim() === container); + if (line && /^Up /.test(line.split("\t")[1]?.trim() ?? "")) return ps; + await new Promise((r) => setTimeout(r, 5_000)); + } + const logs = (await on(node, `docker logs --tail 15 ${container} 2>&1`)).out; + throw new Error( + `${container} is not running on ${node}.\n\ncontainers:\n${last}\n\nwhat it said:\n${logs}`); +} + /** * Register a module from a manifest on this workstation. * @@ -346,15 +369,15 @@ before(async () => { 1_500_000); assert.doesNotMatch(built, /failed/i, built); await mesh(`assign ${CONTROL} ${PROVIDER.module}`); + // **Its account on the mesh's own broker, and not swallowed.** A module's runtime is a tool + // host: it connects to the mesh broker before it does anything, and what it reads is a sealed + // credential document. Without an account the mesh still fills the secret this module declares + // it owns — with a generated value — so the container starts, fails to parse a password as a + // credential, and crash-loops on a JSON syntax error that says nothing about the missing + // account. Issuing it is not optional and neither is hearing that it failed. + await mesh(`module issue ${PROVIDER.module} --node ${CONTROL}`); await mesh(`push ${CONTROL}`, 600_000); - for (let i = 0; i < 60; i++) { - const ps = (await on(CONTROL, `docker ps --format '{{.Names}}\t{{.Status}}'`)).out; - const line = ps.split("\n").find((l) => l.includes(PROVIDER.module)); - if (line && /Up /.test(line)) return ps; - await new Promise((r) => setTimeout(r, 5_000)); - } - throw new Error(`${PROVIDER.module} never came up on ${CONTROL}:\n` + - (await on(CONTROL, `docker ps -a --format '{{.Names}}\t{{.Status}}'`)).out); + return waitForContainer(CONTROL, "mesh-lavinmq"); }); // ---- 5. THE ANCHOR RUNS IT ------------------------------------------------------------------ @@ -362,18 +385,10 @@ before(async () => { // The machine that built it. This is the case every earlier proof covered, and it is here as the // control for step 6: if this fails, step 6's failure says nothing about fetching. await step("the anchor runs the module the mesh built", NEEDS, async () => { - await mesh(`module issue ${MODULE.module} --node ${CONTROL}`).catch(() => {}); + await mesh(`module issue ${MODULE.module} --node ${CONTROL}`); await mesh(`assign ${CONTROL} ${MODULE.module}`); await mesh(`push ${CONTROL}`, 600_000); - for (let i = 0; i < 40; i++) { - const ps = (await on(CONTROL, `docker ps --format '{{.Names}}\t{{.Status}}'`)).out; - if (/amqp-ping/.test(ps) && /Up /.test(ps.split("\n").find((l) => l.includes("amqp-ping")) ?? "")) { - return ps; - } - await new Promise((r) => setTimeout(r, 5_000)); - } - throw new Error(`amqp-ping never came up on ${CONTROL}:\n` + - (await on(CONTROL, `docker ps -a --format '{{.Names}}\t{{.Status}}'`)).out); + return waitForContainer(CONTROL, MODULE.module); }); // ---- 6. AND A MACHINE THAT DID NOT BUILD IT ------------------------------------------------- @@ -387,20 +402,15 @@ before(async () => { // evidence of what does work. await step(`a joined machine runs the module the mesh built`, "the anchor runs the module the mesh built", async () => { - await mesh(`module issue ${MODULE.module} --node ${SECOND}`).catch(() => {}); + await mesh(`module issue ${MODULE.module} --node ${SECOND}`); await mesh(`assign ${SECOND} ${MODULE.module}`); await mesh(`push ${SECOND}`, 600_000); - for (let i = 0; i < 40; i++) { - const ps = (await on(SECOND, `docker ps --format '{{.Names}}\t{{.Status}}'`)).out; - const line = ps.split("\n").find((l) => l.includes("amqp-ping")); - if (line && /Up /.test(line)) return ps; - await new Promise((r) => setTimeout(r, 5_000)); + try { + return await waitForContainer(SECOND, MODULE.module); + } catch (err) { + const log = (await on(SECOND, `tail -40 /var/log/mesh-host.log`)).out; + throw new Error(`${(err as Error).message}\n\nwhat the host said:\n${log}`); } - const state = (await on(SECOND, `docker ps -a --format '{{.Names}}\t{{.Status}}'`)).out; - const log = (await on(SECOND, `tail -40 /var/log/mesh-host.log`)).out; - throw new Error( - `amqp-ping never came up on ${SECOND} — the machine that did NOT build it.\n\n` + - `containers:\n${state}\n\nwhat the host said:\n${log}`); }); console.log(`\n================ WHAT THIS MESH DID FOR ITSELF ================`); From 7641bb20592fb470db8d20d9c0d47ffa9e9b1325 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 14 Sep 2026 21:52:24 +0200 Subject: [PATCH 06/26] A one-node mesh, and twelve things that have to be true of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The common case, and the one that was never tested as a whole. What existed asked whether four machines converged; it never asked whether ONE machine ends up holding a mesh. The order was wrong too. Three machines were enrolled second, into a mesh that could not yet produce a single module, and that was reported as though something had been shown. 17-raising-a-mesh is explicit: genesis ends with a mesh that RUNS, and what remains after the core modules are built is "adding machines". So the core comes first and machines arrive last — here, not at all, because a second node is only meaningful once the first is complete. Three things were missing entirely and nothing complained, because nothing asked: the mesh never built its own catalogue, never had a store of its own for that catalogue to use, and never rebuilt its own control plane through the module path. And four checks that were absent rather than failing: - it can describe itself — status, module list, plan --json, and the catalogue's five tools ASKED rather than observed. A container being up was being read as the catalogue working, which is the same error as matching a container by substring and finding the wrong one. - its networking is what the modules asked for — default closed, ssh open, declared ports open, .internal names written, module networks present. Left out altogether, which is hard to defend given the firewall work this week. - a change to a module's source reaches the machine on its own. The capability the migration depends on. - it comes back after a reboot. Never once tested; the lab had no way to restart a machine, because nothing had ever needed one. Machines are named by role now — anchor, home-server, workstation, laptop — not after the operator's own nodes, which made test output and real state hard to tell apart. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- scenarios/fresh-mesh.yml | 22 +- scenarios/one-node-mesh.yml | 56 +++ test/integration/fresh-mesh.test.ts | 216 +++++--- test/integration/one-node-mesh.test.ts | 649 +++++++++++++++++++++++++ 4 files changed, 859 insertions(+), 84 deletions(-) create mode 100644 scenarios/one-node-mesh.yml create mode 100644 test/integration/one-node-mesh.test.ts diff --git a/scenarios/fresh-mesh.yml b/scenarios/fresh-mesh.yml index 4a0370d..2a7f5c9 100644 --- a/scenarios/fresh-mesh.yml +++ b/scenarios/fresh-mesh.yml @@ -25,9 +25,9 @@ # the gap is a named failure rather than an absence. # # hosting (public, routable) home (private, behind the access point) -# novox 192.0.2.20 ── anchor ace 10.99.1.10 home server -# substrate, registry, shanks 10.99.1.20 workstation -# builder, control plane g14 10.99.1.30 workstation +# anchor 192.0.2.20 ── anchor home-server 10.99.1.10 home server +# substrate, registry, workstation 10.99.1.20 workstation +# builder, control plane laptop 10.99.1.30 workstation # # EGRESS IS NOT OPTIONAL HERE. With nothing loaded, a sealed machine stops at the installer's first # pull. Every machine has a way out, and it is a SECOND path: each still reaches the rest of the @@ -41,7 +41,7 @@ scenario: fresh-mesh segments: - # The routable segment. novox lives here; its public address is the broker endpoint every token + # The routable segment. anchor lives here; its public address is the broker endpoint every token # carries and the overlay hub the home nodes dial. hosting: kind: public @@ -63,10 +63,10 @@ machines: # The anchor. Raised by the installer into a mesh of one, and then asked to build. # # Sized for what it actually does here: the store, the broker, the registry, TWO control planes - # during the pivot, the builder, and a build workspace holding a Node toolchain image and an npm - # cache. It is NOT sized for the whole novox service set, because this bed does not run one — it + # during the pivot, the builder, and a build worksphome-server holding a Node toolchain image and an npm + # cache. It is NOT sized for the whole anchor service set, because this bed does not run one — it # proves the machinery that would produce it. - novox: + anchor: at: { segment: hosting, address: [192.0.2.20] } egress: true inbound: allow @@ -77,21 +77,21 @@ machines: # Three machines that JOIN. Host binary and a token, nothing else — no bootstrap, no substrate, # no registry. They are deliberately small: what they are here to prove is that a joined machine # can be given a module the mesh built, which is a question about credentials and not about load. - ace: + home-server: at: { segment: home, address: [10.99.1.10] } egress: true inbound: allow memory: 4GiB cpus: 2 disk: 25GiB - shanks: + workstation: at: { segment: home, address: [10.99.1.20] } egress: true inbound: allow memory: 3GiB cpus: 2 disk: 20GiB - g14: + laptop: at: { segment: home, address: [10.99.1.30] } egress: true inbound: allow @@ -102,5 +102,5 @@ machines: # **No `images:` key, and that is the whole point of this file.** Anything a machine holds here, it # pulled or the mesh built. See the header. -place: +plhome-server: all: [host, runtime] diff --git a/scenarios/one-node-mesh.yml b/scenarios/one-node-mesh.yml new file mode 100644 index 0000000..fb62b03 --- /dev/null +++ b/scenarios/one-node-mesh.yml @@ -0,0 +1,56 @@ +# ONE MACHINE, AND EVERYTHING A MESH HAS TO BE. Nothing is handed to it. +# +# The common case, and the one worth getting right first: a person with a single machine runs the +# installer and ends up with a mesh that works. Not a mesh that *runs* — `17-raising-a-mesh` is +# careful about that difference, and so is this scenario. Genesis ends with a substrate, a registry, +# a built control plane and a builder, and a mesh in that state cannot produce anything and holds no +# record of what it has. Calling that "up" is how the catalogue came to be missing from a test for +# weeks without anything complaining. +# +# So the test driving this asks the harder question: can this machine, given nothing but a container +# runtime and the host binary, end up holding +# +# - a substrate and a registry it pulled from the internet, +# - a control plane it BUILT, and then rebuilt from its own repository through the module path, +# - a builder that takes work over the broker, +# - the shared base every module with code of its own stands on, +# - a store of its own — the substrate's is the control plane's own plumbing, not a provider, +# - a catalogue, so it can say what it has and what a change reaches, +# - and a module of its own, built, provisioned and running. +# +# **There is no `images:` key, and that is the whole point of this file.** The four-machine scenario +# next door loads thirty-four of the mesh's own images from the workstation because it does not +# build them — a shape no real installation has. Here nothing is loaded. What the machine holds it +# either pulled from the internet or made. +# +# EGRESS IS NOT OPTIONAL. With nothing loaded, a sealed machine stops at the installer's first pull. +# +# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BOOTSTRAP_BINARY=.../mesh-bootstrap +# MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock +# MESH_LAB_CATALOG=.../mesh-catalog/modules +# MESH_LAB_SOURCE= MESH_LAB_SOURCE_REF= +scenario: one-node-mesh + +segments: + hosting: + kind: public + cidr: [192.0.2.0/24] + +machines: + # The address matters: the substrate template names the broker at a fixed address, and a token + # carries that verbatim as the endpoint an enrolling node dials. With one machine, that machine + # must BE it, or the mesh hands out an endpoint nothing answers on. + # + # Sized for what it actually does: the store, the broker, the registry, two control planes during + # the pivot, the builder, a Node toolchain and an npm cache in the build workspace, and then every + # core module it builds and runs on top of that. + anchor: + at: { segment: hosting, address: [192.0.2.10] } + egress: true + inbound: allow + memory: 12GiB + cpus: 6 + disk: 60GiB + +place: + all: [host, runtime] diff --git a/test/integration/fresh-mesh.test.ts b/test/integration/fresh-mesh.test.ts index 83c83fe..4f30d54 100644 --- a/test/integration/fresh-mesh.test.ts +++ b/test/integration/fresh-mesh.test.ts @@ -48,10 +48,10 @@ import { labIsUsable, destroyAll, substrateBundle } from "./harness.ts"; import { genesis, type GenesisResult } from "./genesis.ts"; const SCENARIO = "fresh-mesh"; -const CONTROL = "novox"; -const HOME_NODES = ["ace", "shanks", "g14"]; +const CONTROL = "anchor"; +const HOME_NODES = ["home-server", "workstation", "laptop"]; /** The joined machine asked to run a mesh-built module. Any of the three would do. */ -const SECOND = "ace"; +const SECOND = "home-server"; /** The anchor's public address — what every other machine dials, and what its own token must name. */ const ANCHOR = "192.0.2.20"; @@ -74,9 +74,30 @@ const BASE = { module: "mesh-tools", repo: "mesh-tools", path: "" }; * record of, so it provides nothing to anything. A module asking for `amqp` is asking for a * provider in the graph, and this is it. No build: its image is upstream. */ -const PROVIDER = { module: "lavinmq", repo: "mesh-catalog", path: "modules/lavinmq" }; +const PROVIDER = { module: "lavinmq", repo: "mesh-catalog", path: "modules/lavinmq", container: "mesh-lavinmq" }; +/** + * The store, and the catalogue that cannot exist without it. + * + * Both were missing from this test entirely, and nothing complained, because nothing asked. A mesh + * with no catalogue holds no module graph — it cannot say what it has, what a module is made of, + * what a change reaches, or what must be rebuilt. It ran anyway, which is the point: "the mesh is + * up" was being read off genesis finishing. + */ +const STORE = { module: "postgres", repo: "mesh-catalog", path: "modules/postgres", container: "mesh-postgres" }; +const CATALOGUE = { module: "mesh-catalog", repo: "mesh-catalog", path: "modules/mesh-catalog", container: "mesh-catalog" }; +/** The control plane, rebuilt from its own repository — the step that ends the installer's tenure. */ +const CONTROL_PLANE = { module: "mesh-control", repo: "mesh-control", path: "", container: "mesh-control" }; /** Named once, because the step title is also how later steps say what they waited on. */ const NEEDS = "the mesh runs a broker for that module to talk to"; +const GENESIS = "a bare machine becomes a mesh of one, raised by the installer"; +const BASE_BUILT = "the mesh builds the shared base from source"; +const STORE_RUNS = "the mesh builds and runs a store of its own"; +const CATALOGUE_RUNS = "the mesh builds and runs its own catalogue"; +const CONTROL_REBUILT = "the mesh rebuilds its own control plane from source"; +const MODULE_BUILT = "the mesh builds a module standing on that base"; +const ANCHOR_RUNS = "the anchor runs the module the mesh built"; +const JOINED = "three machines join the mesh, and reach it over its private network"; +const SECOND_RUNS = "a joined machine runs the module the mesh built"; const MODULE = { module: "amqp-ping", repo: "mesh-catalog", path: "modules/amqp-ping" }; const capability = await labIsUsable(); @@ -162,6 +183,34 @@ async function mesh(command: string, timeoutMs?: number): Promise { return must(CONTROL, `docker exec mesh-control /mesh-control ${command}`, timeoutMs); } +/** A module the mesh has to make for itself: where its source is, and what it runs when it works. */ +interface CoreModule { module: string; repo: string; path: string; container: string } + +/** + * Build a module from source, install it, and wait for it to actually run. + * + * The whole sequence a module goes through, in one place because the mesh has to do it for several + * before it is a mesh at all: register the manifest, build it from its repository and path, issue + * the broker account its runtime needs, assign it, send it, and then ask the machine whether the + * container is up. + * + * **The account is not optional and is not swallowed.** A module's runtime is a tool host: it + * connects to the mesh's broker before doing anything. Without an account the mesh still fills the + * secret the module declares it owns — with a generated value — so the container starts, fails to + * parse a password as a credential document, and loops on a JSON syntax error mentioning no + * missing account. That cost a step that reported PASS. + */ +async function bringUp(m: CoreModule): Promise { + await registerModule(m.module, resolve(catalogDir, m.module, "module.json")); + const built = await mesh( + `build ${forgeUrl(m.repo)} --path ${m.path} --ref ${refFor(m.repo)} --wait 1200s`, 1_500_000); + assert.doesNotMatch(built, /failed/i, built); + await mesh(`module issue ${m.module} --node ${CONTROL}`); + await mesh(`assign ${CONTROL} ${m.module}`); + await mesh(`push ${CONTROL}`, 600_000); + return `${built}\n${await waitForContainer(CONTROL, m.container)}`; +} + /** * Wait for one container, BY NAME, to be running. * @@ -262,7 +311,7 @@ before(async () => { // // The shared description, the same one `genesis-single` calls. The bundle is the TEMPLATE with // nothing held: no image is pre-resolved, because none is here to resolve to. - await step("novox becomes a mesh of one, raised by the installer", null, async () => { + await step("a bare machine becomes a mesh of one, raised by the installer", null, async () => { try { raised = await genesis({ instanceId, @@ -289,41 +338,29 @@ before(async () => { return raised.report.join("\n"); }); - // ---- 2. JOINING ----------------------------------------------------------------------------- + // ---- 2..6. THE MESH BECOMES ONE -------------------------------------------------------------- // - // Host binary and a token. novox is NOT in this loop — the installer enrolled it, and enrolling - // it again would offer the mesh a second identity for a node it already knows. - await step("three machines join it across the household gateway", - "novox becomes a mesh of one, raised by the installer", async () => { - const said: string[] = []; - for (const machine of HOME_NODES) { - await mesh(`node add ${machine}`); - const token = tokenFrom(await mesh(`token issue --node ${machine}`)); - const out = await must(machine, `${HOST_PATH} enrol --token ${quote(token)}`, 180_000); - assert.match(out, new RegExp(`enrolled as ${machine}`), out); - await must(machine, `nohup ${HOST_PATH} run > /var/log/mesh-host.log 2>&1 & sleep 3`); - said.push(` ${machine} enrolled and running`); - } - const nodes = await mesh("node list"); - said.push(nodes.trim()); - return said.join("\n"); - }); + // **Joining used to be here, second, and that was the wrong order.** Three machines were enrolled + // into a mesh that could not yet produce a single module, and the step was reported as though + // something had been shown. They do join — reliably — but joining a mesh that can build nothing + // proves only that enrolment works, which was never the doubtful part. + // + // `17-raising-a-mesh` says it plainly: genesis ends with a mesh of one that RUNS, and that is not + // the same as a mesh that WORKS; what remains after the core modules are built is "adding + // machines, and deciding what they run". So everything a mesh needs to be a mesh happens first, + // and machines arrive at the end. - // ---- 3. THE MESH BUILDS THE SHARED BASE ----------------------------------------------------- - // // The toolchain and runtime every module with code of its own stands on. It is a module, and it // is built like one — cloned from the forge by the builder installing put here, compiled on the // machine, published into the mesh's own registry. - await step("the mesh builds the shared base from source", - "three machines join it across the household gateway", async () => { + await step(BASE_BUILT, GENESIS, async () => { // Registered from the manifest the builder will also read, so what the mesh holds and what it // builds are the same description of the same module. // // **Not swallowed.** This call used to end in `.catch(() => {})`, on the reasoning that the // base might already be known. It hid a real failure — the manifest was being named at a path // inside a container that had never seen it — and the step passed anyway, because a base with - // nothing to stand on builds whether or not the mesh has a record of it. The next step, which - // needs that record, is where it surfaced. + // nothing to stand on builds whether or not the mesh has a record of it. await registerModule(BASE.module, baseManifest); const built = await mesh( `build ${forgeUrl(BASE.repo)} --ref ${refFor(BASE.repo)} --wait 1200s`, 1_500_000); @@ -331,9 +368,39 @@ before(async () => { return built; }); - // ---- 4. AND A MODULE STANDING ON IT --------------------------------------------------------- - await step("the mesh builds a module standing on that base", - "the mesh builds the shared base from source", async () => { + // A store of its own. **Not the substrate's.** The installer raises a store for the control + // plane to keep its own records in, the way it raises a broker — plumbing, not a module the mesh + // has any record of, so it provides nothing to anything. A module that wants a database wants a + // provider in the graph, and the catalogue below is the first thing to want one. + await step(STORE_RUNS, BASE_BUILT, () => bringUp(STORE)); + + // And the catalogue, which was missing from this test altogether. + // + // Without it the mesh holds no module graph: it cannot say what it has, what a module is made + // of, what a change to one reaches, or what must be rebuilt. A mesh in that state still runs, + // which is exactly how its absence went unnoticed — "the mesh is up" was being read off the + // installer finishing rather than off the mesh being able to answer anything. + await step(CATALOGUE_RUNS, STORE_RUNS, () => bringUp(CATALOGUE)); + + // The control plane, rebuilt from its own repository and rolled out. + // + // The installer built it once, which is what got the mesh running. Building it again THROUGH THE + // MODULE PATH — build, notice the version moved, roll it out — is a different claim: it is the + // moment the mesh stops depending on the installer for anything, and the first time the thing + // that performs an upgrade performs one on itself. + await step(CONTROL_REBUILT, CATALOGUE_RUNS, async () => { + const built = await mesh( + `build ${forgeUrl(CONTROL_PLANE.repo)} --ref ${sourceRef} --wait 1200s`, 1_500_000); + assert.doesNotMatch(built, /failed/i, built); + const rolled = await mesh(`upgrade ${CONTROL_PLANE.module} roll-out`, 900_000); + // Asked of the machine rather than believed from the command: the control plane that answers + // afterwards is the one that has to be running for anything below this line to mean anything. + await waitForContainer(CONTROL, CONTROL_PLANE.container); + return `${built}\n${rolled}`; + }); + + // ---- 7..8. SOMETHING TO RUN --------------------------------------------------------------- + await step(MODULE_BUILT, CONTROL_REBUILT, async () => { await registerModule(MODULE.module, resolve(catalogDir, MODULE.module, "module.json")); const built = await mesh( `build ${forgeUrl(MODULE.repo)} --path ${MODULE.path} --ref ${refFor(MODULE.repo)} --wait 1200s`, @@ -347,61 +414,64 @@ before(async () => { return `${built}\n${builds}`; }); - // ---- 4b. WHAT THE MODULE NEEDS -------------------------------------------------------------- - // // `amqp-ping` requires the `amqp` provision, and the mesh refused to place it: "nothing provides // amqp, wanted by amqp-ping". That refusal is correct and is the reason this step exists rather - // than the reason to pick an easier module. **The substrate's broker is not a provider.** It is - // raised by the installer as part of the bundle, so it is a running container and not a module - // with something to offer — the mesh's own plumbing, not an entry in its graph. A module that - // wants a broker wants one the mesh knows about. + // than the reason to pick an easier module. // // `lavinmq` is that module. It was first added here on the belief that it needed no building — - // its broker is an upstream image — and the mesh refused it: two of its three containers named a - // placeholder digest, "which is never a real image". That was right. The broker is upstream, but - // the module is not only the broker: it carries a run-once bootstrap that writes the broker's + // its broker is an upstream image — and the mesh refused it again: two of its three containers + // named a placeholder digest, "which is never a real image". Also right. The broker is upstream, + // but the module is not only the broker: it carries a run-once bootstrap that writes the broker's // configuration, a provisioner that grants each consumer its own vhost and user, tools and an - // event consumer. All of that is its own code and has to be built like anything else. - await step(NEEDS, "the mesh builds a module standing on that base", async () => { - await registerModule(PROVIDER.module, resolve(catalogDir, PROVIDER.module, "module.json")); - const built = await mesh( - `build ${forgeUrl(PROVIDER.repo)} --path ${PROVIDER.path} --ref ${refFor(PROVIDER.repo)} --wait 1200s`, - 1_500_000); - assert.doesNotMatch(built, /failed/i, built); - await mesh(`assign ${CONTROL} ${PROVIDER.module}`); - // **Its account on the mesh's own broker, and not swallowed.** A module's runtime is a tool - // host: it connects to the mesh broker before it does anything, and what it reads is a sealed - // credential document. Without an account the mesh still fills the secret this module declares - // it owns — with a generated value — so the container starts, fails to parse a password as a - // credential, and crash-loops on a JSON syntax error that says nothing about the missing - // account. Issuing it is not optional and neither is hearing that it failed. - await mesh(`module issue ${PROVIDER.module} --node ${CONTROL}`); - await mesh(`push ${CONTROL}`, 600_000); - return waitForContainer(CONTROL, "mesh-lavinmq"); - }); + // event consumer, all of it its own code. + await step(NEEDS, MODULE_BUILT, () => bringUp(PROVIDER)); - // ---- 5. THE ANCHOR RUNS IT ------------------------------------------------------------------ - // // The machine that built it. This is the case every earlier proof covered, and it is here as the - // control for step 6: if this fails, step 6's failure says nothing about fetching. - await step("the anchor runs the module the mesh built", NEEDS, async () => { + // control for the last step: if this fails, that one's failure says nothing about fetching. + await step(ANCHOR_RUNS, NEEDS, async () => { await mesh(`module issue ${MODULE.module} --node ${CONTROL}`); await mesh(`assign ${CONTROL} ${MODULE.module}`); await mesh(`push ${CONTROL}`, 600_000); return waitForContainer(CONTROL, MODULE.module); }); - // ---- 6. AND A MACHINE THAT DID NOT BUILD IT ------------------------------------------------- + // ---- 9. NOW MACHINES MAY ARRIVE --------------------------------------------------------------- // - // **The thing nothing has ever checked.** ace did not build this image and has never seen it. To - // run it, it must fetch it from the mesh's registry — and a joined node has no account there. - // The mesh grants a consumer a credential for a database; it does not yet do so for the store - // its own images live in (novox/hq issue 042). + // Host binary and a token, and nothing else. The anchor is NOT in this loop — the installer + // enrolled it, and enrolling it again would offer the mesh a second identity for a node it + // already knows. // - // Asked anyway, and asked LAST, so that when it fails the five steps above still stand as - // evidence of what does work. - await step(`a joined machine runs the module the mesh built`, - "the anchor runs the module the mesh built", async () => { + // And they are placed on the mesh's private network, which the earlier ordering never did. The + // mesh refuses to bind a consumer to a provider on another machine when either is missing from + // it — "they are not both on the private network" — so without this the last step fails for a + // reason that has nothing to do with what it is asking. + await step(JOINED, ANCHOR_RUNS, async () => { + const said: string[] = []; + await mesh(`overlay place ${CONTROL} --hub --endpoint ${ANCHOR}:51820 --site hosting`); + for (const machine of HOME_NODES) { + await mesh(`node add ${machine}`); + const token = tokenFrom(await mesh(`token issue --node ${machine}`)); + const out = await must(machine, `${HOST_PATH} enrol --token ${quote(token)}`, 180_000); + assert.match(out, new RegExp(`enrolled as ${machine}`), out); + await must(machine, `nohup ${HOST_PATH} run > /var/log/mesh-host.log 2>&1 & sleep 3`); + await mesh(`overlay place ${machine} --site home`); + said.push(` ${machine} enrolled, running, and on the private network`); + } + said.push((await mesh("node list")).trim()); + said.push((await mesh("overlay show")).trim()); + return said.join("\n"); + }); + + // ---- 10. AND A MACHINE THAT DID NOT BUILD IT -------------------------------------------------- + // + // **The thing nothing has ever checked.** This machine did not build the image and has never seen + // it. To run it, it must fetch it from the mesh's registry — and a joined node has no account + // there (novox/hq issue 042), nor any reason to trust a registry serving plain HTTP over the + // network (issue 048). Both are open, and both are invisible on a mesh of one. + // + // Asked anyway, and asked LAST, so that when it fails everything above still stands as evidence + // of what does work. + await step(SECOND_RUNS, JOINED, async () => { await mesh(`module issue ${MODULE.module} --node ${SECOND}`); await mesh(`assign ${SECOND} ${MODULE.module}`); await mesh(`push ${SECOND}`, 600_000); @@ -427,7 +497,7 @@ after(async () => { }, { timeout: 900_000 }); for (const name of [ - "novox becomes a mesh of one, raised by the installer", + "a bare machine becomes a mesh of one, raised by the installer", "three machines join it across the household gateway", "the mesh builds the shared base from source", "the mesh builds a module standing on that base", diff --git a/test/integration/one-node-mesh.test.ts b/test/integration/one-node-mesh.test.ts new file mode 100644 index 0000000..710961d --- /dev/null +++ b/test/integration/one-node-mesh.test.ts @@ -0,0 +1,649 @@ +/** + * FOUR FRESH MACHINES, AND NOTHING HANDED TO THEM. + * + * The four-machine bed (`whole-mesh-full`) proves the mesh converges. It does so by loading + * thirty-four of the mesh's own images onto its machines from the workstation, because it does not + * build them — something beside the bed built them and copied them in. No real installation looks + * like that, and the lab has been burned by exactly this shape before: it used to raise a registry + * inside the scenario, and a bootstrap that only worked against that registry went green here and + * would have failed on any bare machine. + * + * This bed hands over nothing. The scenario has no `images:` list at all. What the machines get is + * a container runtime and the host binary — prerequisites of a machine, not parts of a mesh — and + * from there: + * + * 1. novox is raised into a mesh of one by the installer, which BUILDS the control plane. + * 2. ace, shanks and g14 JOIN it, across a household NAT, with a token and nothing else. + * 3. The mesh builds the shared base from source, with its own builder. + * 4. The mesh builds a real module standing on that base. + * 5. The anchor runs it, pinned to a digest the mesh's own registry assigned. + * 6. A JOINED machine runs it — which means pulling from a registry that asks who it is. + * + * Steps 1 and 2 are proven elsewhere and are here because the later ones need them. **Steps 3 + * through 6 are what this bed exists for**, and 6 is the one nothing has ever checked: genesis + * puts the builder, the registry and everything they produce on ONE machine, so every earlier + * proof of a mesh-built module running is a proof about the machine that built it. A second + * machine has to fetch, and fetching needs an account nothing yet grants (novox/hq issue 042). + * + * Each step is recorded separately rather than allowed to throw, so a gap at 6 reports as a gap at + * 6 instead of erasing the evidence for 3, 4 and 5. + * + * MESH_LAB_INCUS='sudo -n incus' + * MESH_LAB_HOST_BINARY=.../mesh-host/mesh-host + * MESH_LAB_BOOTSTRAP_BINARY=.../mesh-host/mesh-bootstrap + * MESH_LAB_BUNDLE=.../mesh-host/examples/substrate-first-node.lock + * MESH_LAB_CATALOG=.../mesh-catalog/modules + * MESH_LAB_SOURCE=/mesh-control.git MESH_LAB_SOURCE_REF= + * MESH_LAB_KEEP=1 to leave it standing afterwards + */ +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { loadScenario } from "../../src/declaration/parse.ts"; +import { raise } from "../../src/lifecycle/raise.ts"; +import { destroy, exec, push } from "../../src/lifecycle/operate.ts"; +import { bootstrapBinaryPath, hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; +import { labIsUsable, destroyAll, substrateBundle } from "./harness.ts"; +import { genesis, type GenesisResult } from "./genesis.ts"; +import { incus } from "../../src/incus/client.ts"; +import { instanceNameOf } from "../../src/lifecycle/operate.ts"; +import { waitUntilAllUsable } from "../../src/lifecycle/ready.ts"; + +const SCENARIO = "one-node-mesh"; +const CONTROL = "anchor"; + +/** The anchor's public address — what every other machine dials, and what its own token must name. */ +const ANCHOR = "192.0.2.10"; +/** Where this mesh's registry answers, on the anchor's public address so a joined node can reach it. */ +const REGISTRY = `${ANCHOR}:5000`; + +/** + * The module built on top of the base, and the base it stands on. + * + * `amqp-ping` is deliberately small and deliberately REAL: its own TypeScript, compiled by the + * shared toolchain, running on the shared runtime, talking to the broker. A module whose artifact + * is a mirrored public image would pass every assertion below while skipping the whole of what is + * under test (novox/hq SELF-UPGRADE-PLAN, rule 1). + */ +const BASE = { module: "mesh-tools", repo: "mesh-tools", path: "" }; +/** + * What `amqp-ping` requires, and what the substrate does not supply. + * + * The installer raises a broker, but as a bundle resource — plumbing, not a module the mesh has a + * record of, so it provides nothing to anything. A module asking for `amqp` is asking for a + * provider in the graph, and this is it. No build: its image is upstream. + */ +const PROVIDER = { module: "lavinmq", repo: "mesh-catalog", path: "modules/lavinmq", container: "mesh-lavinmq" }; +/** + * The store, and the catalogue that cannot exist without it. + * + * Both were missing from this test entirely, and nothing complained, because nothing asked. A mesh + * with no catalogue holds no module graph — it cannot say what it has, what a module is made of, + * what a change reaches, or what must be rebuilt. It ran anyway, which is the point: "the mesh is + * up" was being read off genesis finishing. + */ +const STORE = { module: "postgres", repo: "mesh-catalog", path: "modules/postgres", container: "mesh-postgres" }; +const CATALOGUE = { module: "mesh-catalog", repo: "mesh-catalog", path: "modules/mesh-catalog", container: "mesh-catalog" }; +/** The control plane, rebuilt from its own repository — the step that ends the installer's tenure. */ +const CONTROL_PLANE = { module: "mesh-control", repo: "mesh-control", path: "", container: "mesh-control" }; + +/** + * What this mesh must hold when it is finished, and what must be RUNNING on the machine. + * + * Written down as a list rather than checked one step at a time, because "is this a mesh" is a + * question about the set. The three the build loop cannot produce for itself — the control plane, + * the registry and the builder — are here too: they are carried in, and a mesh missing any of them + * is not one. + */ +const MUST_HOLD = ["mesh-control", "registry", "builder", "mesh-tools", "postgres", + "mesh-catalog", "lavinmq", "amqp-ping"]; +const MUST_RUN = ["mesh-control", "mesh-registry", "mesh-broker", "mesh-store", + "mesh-postgres", "mesh-catalog", "mesh-lavinmq", "amqp-ping"]; +/** Named once, because the step title is also how later steps say what they waited on. */ +const NEEDS = "the mesh runs a broker for that module to talk to"; +const GENESIS = "a bare machine becomes a mesh of one, raised by the installer"; +const BASE_BUILT = "the mesh builds the shared base from source"; +const STORE_RUNS = "the mesh builds and runs a store of its own"; +const CATALOGUE_RUNS = "the mesh builds and runs its own catalogue"; +const CONTROL_REBUILT = "the mesh rebuilds its own control plane from source"; +const MODULE_BUILT = "the mesh builds a module standing on that base"; +const ANCHOR_RUNS = "the anchor runs the module the mesh built"; +const DESCRIBES = "the mesh can describe itself, and what it says is true"; +const NETWORK = "the machine's networking is what the modules asked for"; +const FOLLOWS = "a change to a module's source reaches the machine on its own"; +const SURVIVES = "the mesh comes back after the machine reboots"; +const MODULE = { module: "amqp-ping", repo: "mesh-catalog", path: "modules/amqp-ping" }; + +const capability = await labIsUsable(); +const binary = hostBinaryPath(); +const installer = bootstrapBinaryPath(); +const bundle = process.env["MESH_LAB_BUNDLE"] ?? ""; +const catalogDir = process.env["MESH_LAB_CATALOG"] ?? ""; +const source = process.env["MESH_LAB_SOURCE"] ?? ""; +const sourceRef = process.env["MESH_LAB_SOURCE_REF"] ?? ""; +const KEEP = !!process.env["MESH_LAB_KEEP"]; +const FIXED_ID = process.env["MESH_LAB_INSTANCE_ID"] ?? (KEEP ? "fresh-mesh-live" : undefined); + +/** + * Where a repository other than the control plane's lives. + * + * Derived from `MESH_LAB_SOURCE` by swapping the last path segment, because every one of these + * repositories sits beside the others under the same owner on the same forge. Overridable, so a + * forge that is arranged differently does not need this bed edited. + */ +function forgeUrl(repo: string): string { + const override = process.env[`MESH_LAB_SOURCE_${repo.toUpperCase().replaceAll("-", "_")}`]; + if (override) return override; + return source.replace(/[^/]+\.git$/, `${repo}.git`); +} +/** + * What to build, per repository. + * + * A branch is acceptable for an ordinary build; only genesis insists on a commit (ADR 0071). Per + * repository rather than one value for all of them, because a change under test usually lives in + * one repository and the rest should be built from what everyone else has — building them all from + * a feature branch would prove that branch against itself. + * + * MESH_LAB_BUILD_REF the default for every repository + * MESH_LAB_BUILD_REF_MESH_CATALOG ...overridden for one + */ +function refFor(repo: string): string { + const override = process.env[`MESH_LAB_BUILD_REF_${repo.toUpperCase().replaceAll("-", "_")}`]; + return override ?? process.env["MESH_LAB_BUILD_REF"] ?? "main"; +} + +/** + * The shared base's manifest, on this workstation. + * + * The base is a repository with a manifest at its root (novox/hq ADR 0069), so unlike the + * catalogue's modules it is not under `MESH_LAB_CATALOG`. Derived from that path on the convention + * that the checkouts sit beside each other, and overridable for a layout where they do not. + */ +const baseManifest = process.env["MESH_LAB_BASE_MANIFEST"] ?? + resolve(catalogDir, "..", "..", BASE.repo, "module.json"); + +const skip = + !capability.usable ? capability.why : + !binary ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" : + !installer ? "MESH_LAB_BOOTSTRAP_BINARY is not set to a built mesh-bootstrap" : + !source ? "MESH_LAB_SOURCE is not set to the repository the control plane is built from" : + !sourceRef ? "MESH_LAB_SOURCE_REF is not set to the commit to build" : + !bundle || !existsSync(bundle) ? "MESH_LAB_BUNDLE is not set to a substrate template" : + !catalogDir || !existsSync(catalogDir) ? "MESH_LAB_CATALOG is not set to mesh-catalog/modules" : + false; + +let instanceId = ""; +let raised: GenesisResult; + +// ---- talking to the machines ------------------------------------------------------------------ + +function quote(s: string): string { + return `'${s.replaceAll("'", `'\\''`)}'`; +} +async function on(machine: string, command: string, timeoutMs?: number): Promise<{ out: string; ok: boolean }> { + const { stdout } = await exec(instanceId, machine, [ + "sh", "-c", `exec 2>&1\n${command}\necho "__exit=$?"`, + ], timeoutMs); + const marker = stdout.lastIndexOf("__exit="); + if (marker < 0) return { out: stdout, ok: false }; + return { out: stdout.slice(0, marker), ok: stdout.slice(marker + 7).trim() === "0" }; +} +async function must(machine: string, command: string, timeoutMs?: number): Promise { + const { out, ok } = await on(machine, command, timeoutMs); + if (!ok) throw new Error(`${machine}: ${command}\n${out}`); + return out; +} +async function mesh(command: string, timeoutMs?: number): Promise { + return must(CONTROL, `docker exec mesh-control /mesh-control ${command}`, timeoutMs); +} + +/** + * Stop the machine and start it again, the way a power cut or a kernel upgrade would. + * + * There is no restart in the lab's own vocabulary, which is its own small finding: nothing had ever + * needed one, because nothing had ever asked whether a mesh comes back. + */ +async function restartMachine(machine: string): Promise { + const name = await instanceNameOf(instanceId, machine); + await incus(["restart", name], 180_000); + await waitUntilAllUsable([name], 300, (m) => console.log(` restart: ${m}`)); +} + +/** A module the mesh has to make for itself: where its source is, and what it runs when it works. */ +interface CoreModule { module: string; repo: string; path: string; container: string } + +/** + * Build a module from source, install it, and wait for it to actually run. + * + * The whole sequence a module goes through, in one place because the mesh has to do it for several + * before it is a mesh at all: register the manifest, build it from its repository and path, issue + * the broker account its runtime needs, assign it, send it, and then ask the machine whether the + * container is up. + * + * **The account is not optional and is not swallowed.** A module's runtime is a tool host: it + * connects to the mesh's broker before doing anything. Without an account the mesh still fills the + * secret the module declares it owns — with a generated value — so the container starts, fails to + * parse a password as a credential document, and loops on a JSON syntax error mentioning no + * missing account. That cost a step that reported PASS. + */ +async function bringUp(m: CoreModule): Promise { + await registerModule(m.module, resolve(catalogDir, m.module, "module.json")); + const built = await mesh( + `build ${forgeUrl(m.repo)} --path ${m.path} --ref ${refFor(m.repo)} --wait 1200s`, 1_500_000); + assert.doesNotMatch(built, /failed/i, built); + await mesh(`module issue ${m.module} --node ${CONTROL}`); + await mesh(`assign ${CONTROL} ${m.module}`); + await mesh(`push ${CONTROL}`, 600_000); + return `${built}\n${await waitForContainer(CONTROL, m.container)}`; +} + +/** + * Wait for one container, BY NAME, to be running. + * + * **Named exactly, because substring matching passed a step that had failed.** Waiting for "a + * container whose name contains lavinmq" was satisfied by the broker — `lavinmq`, up and healthy — + * while the thing actually under test, the module's own runtime `mesh-lavinmq`, was crash-looping + * beside it. The step went green and the fault was found by reading `docker ps` by hand. + */ +async function waitForContainer(node: string, container: string, seconds = 200): Promise { + const deadline = Date.now() + seconds * 1_000; + let last = ""; + while (Date.now() < deadline) { + const ps = (await on(node, `docker ps -a --format '{{.Names}}\t{{.Status}}'`)).out; + last = ps; + const line = ps.split("\n").find((l) => l.split("\t")[0]?.trim() === container); + if (line && /^Up /.test(line.split("\t")[1]?.trim() ?? "")) return ps; + await new Promise((r) => setTimeout(r, 5_000)); + } + const logs = (await on(node, `docker logs --tail 15 ${container} 2>&1`)).out; + throw new Error( + `${container} is not running on ${node}.\n\ncontainers:\n${last}\n\nwhat it said:\n${logs}`); +} + +/** + * Register a module from a manifest on this workstation. + * + * **The control plane runs in a container, so a file on the machine is not a file it can open.** + * Pushing the manifest to the machine and naming that path got `no such file or directory` from + * inside mesh-control, which is correct and was briefly mistaken for a missing manifest. It is + * copied the last step of the way with `docker cp`. + * + * **Into the root, not into /tmp.** The control plane's image is a minimal one and has no `/tmp` + * to copy into — `docker cp` says so in those words. `/` is the one directory every image has. + */ +async function registerModule(module: string, manifest: string): Promise { + assert.ok(existsSync(manifest), `no manifest for ${module} at ${manifest}`); + const onMachine = `/tmp/${module}.json`; + const inContainer = `/${module}.json`; + await push(instanceId, CONTROL, manifest, onMachine); + await must(CONTROL, `docker cp ${onMachine} mesh-control:${inContainer}`); + return mesh(`module add ${inContainer}`); +} +function tokenFrom(said: string): string { + const found = said.split("\n").map((l) => l.trim()).find((l) => l.length > 100 && !l.includes(" ")); + assert.ok(found, `no token in:\n${said}`); + return found; +} + +// ---- steps, recorded rather than thrown -------------------------------------------------------- + +interface Step { ok: boolean; why: string; said: string } +const steps = new Map(); +const order: string[] = []; + +/** Run a step, remember what it said, and never throw. A step whose predecessor failed is skipped. */ +async function step(name: string, after_: string | null, fn: () => Promise): Promise { + order.push(name); + if (after_ && !steps.get(after_)?.ok) { + steps.set(name, { ok: false, why: `not attempted — "${after_}" did not succeed`, said: "" }); + console.log(`SKIPPED ${name}`); + return; + } + console.log(`\n======== ${name} ========`); + try { + const said = await fn(); + steps.set(name, { ok: true, why: "", said }); + console.log(`OK ${name}`); + } catch (err) { + const why = (err as Error).message; + steps.set(name, { ok: false, why, said: "" }); + console.log(`FAILED ${name}\n${why.split("\n").slice(0, 25).join("\n")}`); + } +} + +function report(name: string): string { + const s = steps.get(name); + if (!s) return `${name}: never ran`; + const lines = order.map((n) => { + const it = steps.get(n); + return ` ${it?.ok ? "PASS" : "FAIL"} ${n}`; + }); + return `${s.why}\n\nWhere this bed got to:\n${lines.join("\n")}`; +} + +before(async () => { + if (skip) return; + + const bed = await raise(loadScenario(`scenarios/${SCENARIO}.yml`), { + onProgress: (m) => console.log(`raise: ${m}`), + ...(FIXED_ID ? { instanceId: FIXED_ID } : {}), + }); + instanceId = bed.instanceId; + console.log(`INSTANCE ${instanceId}${KEEP ? " (KEEP — will be left standing)" : ""}`); + console.log(`NOTHING WAS LOADED: this scenario names no images. Every image on every machine ` + + `below was pulled from the internet or built by the mesh.`); + + // ---- 1. GENESIS ----------------------------------------------------------------------------- + // + // The shared description, the same one `genesis-single` calls. The bundle is the TEMPLATE with + // nothing held: no image is pre-resolved, because none is here to resolve to. + await step("a bare machine becomes a mesh of one, raised by the installer", null, async () => { + try { + raised = await genesis({ + instanceId, + node: CONTROL, + installer: installer as string, + catalogDir, + // The broker's advertised address, corrected. + // + // The template hardcodes 192.0.2.10:5671 — the address of the anchor in the single-machine + // bed. A token carries this verbatim as the endpoint an enrolling node dials, so on a mesh + // whose anchor is somewhere else every node, including this one, would enrol against an + // address nothing answers on. The installer refuses to guess it and says so, which is + // right: it does not know what this machine is called from outside. + bundleTemplate: substrateBundle(bundle, []).replaceAll("192.0.2.10:5671", `${ANCHOR}:5671`), + registry: REGISTRY, + source, + sourceRef, + log: (m) => console.log(m), + }); + } catch (err) { + throw new Error(`the installer never ran: ${(err as Error).message}`); + } + if (!raised.ok) throw new Error(`${raised.step || "no step named"}: ${raised.why}\n\n${raised.report.join("\n")}`); + return raised.report.join("\n"); + }); + + // ---- 2..6. THE MESH BECOMES ONE -------------------------------------------------------------- + // + // **Joining used to be here, second, and that was the wrong order.** Three machines were enrolled + // into a mesh that could not yet produce a single module, and the step was reported as though + // something had been shown. They do join — reliably — but joining a mesh that can build nothing + // proves only that enrolment works, which was never the doubtful part. + // + // `17-raising-a-mesh` says it plainly: genesis ends with a mesh of one that RUNS, and that is not + // the same as a mesh that WORKS; what remains after the core modules are built is "adding + // machines, and deciding what they run". So everything a mesh needs to be a mesh happens first, + // and machines arrive at the end. + + // The toolchain and runtime every module with code of its own stands on. It is a module, and it + // is built like one — cloned from the forge by the builder installing put here, compiled on the + // machine, published into the mesh's own registry. + await step(BASE_BUILT, GENESIS, async () => { + // Registered from the manifest the builder will also read, so what the mesh holds and what it + // builds are the same description of the same module. + // + // **Not swallowed.** This call used to end in `.catch(() => {})`, on the reasoning that the + // base might already be known. It hid a real failure — the manifest was being named at a path + // inside a container that had never seen it — and the step passed anyway, because a base with + // nothing to stand on builds whether or not the mesh has a record of it. + await registerModule(BASE.module, baseManifest); + const built = await mesh( + `build ${forgeUrl(BASE.repo)} --ref ${refFor(BASE.repo)} --wait 1200s`, 1_500_000); + assert.doesNotMatch(built, /failed/i, built); + return built; + }); + + // A store of its own. **Not the substrate's.** The installer raises a store for the control + // plane to keep its own records in, the way it raises a broker — plumbing, not a module the mesh + // has any record of, so it provides nothing to anything. A module that wants a database wants a + // provider in the graph, and the catalogue below is the first thing to want one. + await step(STORE_RUNS, BASE_BUILT, () => bringUp(STORE)); + + // And the catalogue, which was missing from this test altogether. + // + // Without it the mesh holds no module graph: it cannot say what it has, what a module is made + // of, what a change to one reaches, or what must be rebuilt. A mesh in that state still runs, + // which is exactly how its absence went unnoticed — "the mesh is up" was being read off the + // installer finishing rather than off the mesh being able to answer anything. + await step(CATALOGUE_RUNS, STORE_RUNS, () => bringUp(CATALOGUE)); + + // The control plane, rebuilt from its own repository and rolled out. + // + // The installer built it once, which is what got the mesh running. Building it again THROUGH THE + // MODULE PATH — build, notice the version moved, roll it out — is a different claim: it is the + // moment the mesh stops depending on the installer for anything, and the first time the thing + // that performs an upgrade performs one on itself. + await step(CONTROL_REBUILT, CATALOGUE_RUNS, async () => { + const built = await mesh( + `build ${forgeUrl(CONTROL_PLANE.repo)} --ref ${sourceRef} --wait 1200s`, 1_500_000); + assert.doesNotMatch(built, /failed/i, built); + const rolled = await mesh(`upgrade ${CONTROL_PLANE.module} roll-out`, 900_000); + // Asked of the machine rather than believed from the command: the control plane that answers + // afterwards is the one that has to be running for anything below this line to mean anything. + await waitForContainer(CONTROL, CONTROL_PLANE.container); + return `${built}\n${rolled}`; + }); + + // ---- 7..8. SOMETHING TO RUN --------------------------------------------------------------- + await step(MODULE_BUILT, CONTROL_REBUILT, async () => { + await registerModule(MODULE.module, resolve(catalogDir, MODULE.module, "module.json")); + const built = await mesh( + `build ${forgeUrl(MODULE.repo)} --path ${MODULE.path} --ref ${refFor(MODULE.repo)} --wait 1200s`, + 1_500_000); + assert.doesNotMatch(built, /failed/i, built); + // The point of the whole step: what came out is named by a digest this mesh's registry + // assigned, not by a placeholder and not by a tag. + const builds = await mesh(`builds ${MODULE.module}`); + assert.match(builds, /sha256:[0-9a-f]{12}/, + `the build recorded no digest — the module is not pinned to anything this registry serves:\n${builds}`); + return `${built}\n${builds}`; + }); + + // `amqp-ping` requires the `amqp` provision, and the mesh refused to place it: "nothing provides + // amqp, wanted by amqp-ping". That refusal is correct and is the reason this step exists rather + // than the reason to pick an easier module. + // + // `lavinmq` is that module. It was first added here on the belief that it needed no building — + // its broker is an upstream image — and the mesh refused it again: two of its three containers + // named a placeholder digest, "which is never a real image". Also right. The broker is upstream, + // but the module is not only the broker: it carries a run-once bootstrap that writes the broker's + // configuration, a provisioner that grants each consumer its own vhost and user, tools and an + // event consumer, all of it its own code. + await step(NEEDS, MODULE_BUILT, () => bringUp(PROVIDER)); + + // The machine that built it. This is the case every earlier proof covered, and it is here as the + // control for the last step: if this fails, that one's failure says nothing about fetching. + await step(ANCHOR_RUNS, NEEDS, async () => { + await mesh(`module issue ${MODULE.module} --node ${CONTROL}`); + await mesh(`assign ${CONTROL} ${MODULE.module}`); + await mesh(`push ${CONTROL}`, 600_000); + return waitForContainer(CONTROL, MODULE.module); + }); + + // ---- 9. IT CAN DESCRIBE ITSELF --------------------------------------------------------------- + // + // **Presence is not function, and this step exists because I kept confusing them.** A container + // being up was taken as the catalogue working; a name containing "lavinmq" was taken as the + // module running. The mesh holds a graph — nodes, what each is assigned, what capabilities each + // has, which claims are occupied, what each module is configured with, which provisions exist and + // who holds them — and none of it was ever asked a question. + await step(DESCRIBES, ANCHOR_RUNS, async () => { + const said: string[] = []; + + // The mesh's own verdict on itself. Nothing wrong, nothing waiting, nothing behind. + const state = JSON.parse(await mesh("status --json")) as { + wrong: { node: string; outcome: string; refused?: string }[]; + waiting: { node: string }[]; + reported: { node: string; outcome: string; current: boolean }[]; + }; + assert.equal(state.wrong.length, 0, + `the mesh reports something wrong:\n${JSON.stringify(state.wrong, null, 2)}`); + assert.equal(state.waiting.length, 0, + `the mesh is waiting on a node:\n${JSON.stringify(state.waiting, null, 2)}`); + const node = state.reported.find((r) => r.node === CONTROL); + assert.ok(node, `the mesh does not report the only machine it has:\n${JSON.stringify(state)}`); + assert.equal(node.outcome, "applied", `${CONTROL} did not apply what it was sent: ${node.outcome}`); + assert.ok(node.current, `${CONTROL} is not running what the mesh would send it`); + said.push(` status one node, applied, current, nothing wrong`); + + // Every module a mesh has to hold, including the three it cannot build for itself. + const modules = await mesh("module list"); + for (const m of MUST_HOLD) { + assert.match(modules, new RegExp(`^${m}\\b`, "m"), + `the mesh holds no ${m}. A mesh without it is not finished:\n${modules}`); + } + said.push(` module list ${MUST_HOLD.length} modules, all present`); + + // What the machine would be sent, and what it names. **No placeholder may survive here** — a + // digest of all zeroes is never a real image, and a declaration carrying one reaches a machine + // that will try to fetch it. + const plan = await mesh(`plan ${CONTROL} --json`); + assert.doesNotMatch(plan, /sha256:0{64}/, + `the machine's own plan names a placeholder digest, which is never a real image`); + assert.match(plan, /sha256:[0-9a-f]{64}/, `the plan pins nothing by digest at all`); + said.push(` plan every image pinned, no placeholders`); + + // And the catalogue, ASKED rather than observed. These five questions are what it exists for. + const ask = async (tool: string, args = "{}") => + must(CONTROL, `docker exec mesh-catalog mesh-tools invoke mesh-catalog ${tool} ${quote(args)}`, + 120_000); + + const held = await ask("catalog_modules"); + for (const m of MUST_HOLD) { + if (m === "registry" || m === "builder" || m === "mesh-control") continue; // carried, not built here + assert.ok(held.includes(m), `the catalogue does not know about ${m}:\n${held}`); + } + assert.doesNotMatch(held, /sha256:0{64}/, `the catalogue holds a placeholder version`); + said.push(` catalog_modules every built module, each with a version this mesh made`); + + const provides = await ask("catalog_provides", JSON.stringify({ provision: "amqp" })); + assert.ok(provides.includes(PROVIDER.module), + `the catalogue cannot say what provides amqp, which is the question it exists to answer:\n${provides}`); + said.push(` catalog_provides amqp is answered by ${PROVIDER.module}`); + + const stale = await ask("catalog_stale"); + said.push(` catalog_stale ${stale.trim().slice(0, 120)}`); + return said.join("\n"); + }); + + // ---- 10. AND ITS NETWORKING IS WHAT WAS ASKED FOR --------------------------------------------- + // + // **Left out of this test entirely until it was pointed out**, which is hard to defend: the + // firewall is generated from what modules declare they listen on, and a firewall that opens the + // wrong set is either a service nobody can reach or a port nobody meant to publish. Neither shows + // up as a failed container. + await step(NETWORK, DESCRIBES, async () => { + const said: string[] = []; + + const ruleset = (await on(CONTROL, `nft list table inet mesh 2>&1`)).out; + assert.match(ruleset, /chain input/, `the mesh's own firewall table is not there:\n${ruleset}`); + // Closed by default, or the rules below decide nothing. + assert.match(ruleset, /policy drop/, `the firewall does not default to closed:\n${ruleset}`); + // The floor: a machine that cannot be reached over ssh is a machine nobody can repair. + assert.match(ruleset, /\b22\b/, `ssh is not allowed anywhere in the ruleset`); + // What a module actually declared. The registry says it listens on 5000 for the mesh. + assert.match(ruleset, /\b5000\b/, + `the registry declares it listens on 5000 and nothing opened it:\n${ruleset}`); + said.push(` firewall default closed, ssh open, declared ports open`); + + // The names the mesh writes for itself. A consumer reaching a provider by its `.internal` + // address depends on this file, and on it reaching inside containers. + const hosts = (await on(CONTROL, `cat /etc/hosts`)).out; + assert.match(hosts, /\.internal/, `the mesh wrote no .internal names:\n${hosts}`); + said.push(` names ${(hosts.match(/[a-z0-9-]+\.internal/g) ?? []).join(" ")}`); + + // The networks the declarations asked for, rather than whatever the runtime had lying around. + const networks = (await on(CONTROL, `docker network ls --format '{{.Name}}'`)).out; + for (const wanted of ["lavinmq", "amqp-ping"]) { + assert.match(networks, new RegExp(`^${wanted}$`, "m"), + `the ${wanted} module declares a network and none exists:\n${networks}`); + } + said.push(` networks module networks present`); + return said.join("\n"); + }); + + // ---- 11. A CHANGE REACHES THE MACHINE ON ITS OWN ---------------------------------------------- + // + // The whole point of the mesh, and the capability the migration depends on: move a module's + // source and the running copy follows, with nobody driving the steps. Everything above is + // machinery; this is what the machinery is for. + await step(FOLLOWS, NETWORK, async () => { + const before = await mesh(`builds ${MODULE.module}`); + const wasPinned = before.match(/sha256:[0-9a-f]{64}/)?.[0] ?? ""; + assert.ok(wasPinned, `nothing is pinned to rebuild from:\n${before}`); + + // The mesh is told its copy is older than the source. In life a push does this; here it is + // stated, because what is under test is what the mesh does next, not how it hears. + const head = (await must(CONTROL, `git ls-remote ${forgeUrl(MODULE.repo)} ` + + `${refFor(MODULE.repo)} | cut -f1`, 120_000)).trim(); + assert.match(head, /^[0-9a-f]{40}$/, `could not read the source's head: ${head}`); + await mesh(`module moved ${MODULE.module} ${head}`); + + const behind = await mesh(`status`); + assert.match(behind, /behind|build --behind/, + `the mesh does not report a module behind its source:\n${behind}`); + + await mesh(`build --behind --wait 1200s`, 1_500_000); + const rolled = await mesh(`upgrade ${MODULE.module} roll-out`, 900_000); + await waitForContainer(CONTROL, MODULE.module); + + const after = await mesh(`builds ${MODULE.module}`); + assert.match(after, /sha256:[0-9a-f]{64}/, `nothing was pinned after the rebuild:\n${after}`); + return `${behind}\n${rolled}\n${after}`; + }); + + // ---- 12. AND IT SURVIVES THE MACHINE STOPPING ------------------------------------------------- + // + // **Never once tested.** A mesh that works until the machine reboots is a demonstration, not + // something to move real services onto — and the installer is explicit that a host started the + // way the lab starts it does not survive a reboot, which makes this the check that says whether + // that matters. + await step(SURVIVES, FOLLOWS, async () => { + await restartMachine(CONTROL); + const missing: string[] = []; + for (const container of MUST_RUN) { + try { + await waitForContainer(CONTROL, container, 240); + } catch { + missing.push(container); + } + } + const ps = (await on(CONTROL, `docker ps -a --format '{{.Names}}\t{{.Status}}'`)).out; + assert.equal(missing.length, 0, + `after a reboot these are not running: ${missing.join(", ")}\n\ncontainers:\n${ps}`); + return ps; + }); + + console.log(`\n================ WHAT THIS MESH DID FOR ITSELF ================`); + for (const n of order) console.log(` ${steps.get(n)?.ok ? "PASS" : "FAIL"} ${n}`); +}, { timeout: 7_200_000 }); + +after(async () => { + if (KEEP) { + console.log(`\nLEFT STANDING: ${instanceId} — not destroyed (MESH_LAB_KEEP).`); + return; + } + if (instanceId) await destroy(instanceId); + await destroyAll(`${SCENARIO}-`); +}, { timeout: 900_000 }); + +for (const name of [ + GENESIS, + BASE_BUILT, + STORE_RUNS, + CATALOGUE_RUNS, + CONTROL_REBUILT, + MODULE_BUILT, + NEEDS, + ANCHOR_RUNS, + DESCRIBES, + NETWORK, + FOLLOWS, + SURVIVES, +]) { + test(name, { skip, timeout: 60_000 }, () => { + assert.ok(steps.get(name)?.ok, report(name)); + }); +} From 2f470f27b8789d789b548a865035bcaad5cf5370 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 14 Sep 2026 21:58:58 +0200 Subject: [PATCH 07/26] Genesis makes six claims; assert them separately Genesis is twelve steps and was reported as one line, so a failure said nothing about which claim broke and a pass was one tick standing in for six things being true: the substrate up, the control plane built rather than handed over, the pivot finished, the registry serving what was published into it, the machine enrolled with an agent actually running, and the builder installed as a module. Each is asked of the machine rather than read from the installer's own output. The installer saying it published an image and the registry serving one are different facts, and only the second matters. And the catalogue is invoked by its real entrypoint. 'mesh-tools' is not on PATH in the runtime image; the image runs 'node dist/main.js', and the invoke mode is missing from the header comment that says there are three modes. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- test/integration/one-node-mesh.test.ts | 80 +++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/test/integration/one-node-mesh.test.ts b/test/integration/one-node-mesh.test.ts index 710961d..3ce18e6 100644 --- a/test/integration/one-node-mesh.test.ts +++ b/test/integration/one-node-mesh.test.ts @@ -103,6 +103,12 @@ const MUST_RUN = ["mesh-control", "mesh-registry", "mesh-broker", "mesh-store", /** Named once, because the step title is also how later steps say what they waited on. */ const NEEDS = "the mesh runs a broker for that module to talk to"; const GENESIS = "a bare machine becomes a mesh of one, raised by the installer"; +const SUBSTRATE = "the substrate is up — a store and a broker of the mesh's own"; +const BUILT_CP = "the control plane is one this mesh built, not one it was handed"; +const PIVOTED = "the pivot finished — what raised the mesh is gone"; +const HAS_REGISTRY = "the registry serves this mesh its own images"; +const ENROLLED = "the machine is enrolled, and an agent is running on it"; +const HAS_BUILDER = "the builder is installed as a module, with an account"; const BASE_BUILT = "the mesh builds the shared base from source"; const STORE_RUNS = "the mesh builds and runs a store of its own"; const CATALOGUE_RUNS = "the mesh builds and runs its own catalogue"; @@ -365,6 +371,69 @@ before(async () => { return raised.report.join("\n"); }); + // ---- WHAT GENESIS CLAIMED, ASKED OF THE MACHINE ---------------------------------------------- + // + // **Genesis is twelve steps and was reported as one line.** All the work of raising a mesh + // happens inside it, so "genesis failed" said nothing about which of its claims broke, and + // "genesis passed" was one tick standing in for six separate things being true. + // + // Each is asked of the machine rather than read from the installer's own output — the installer + // saying it published an image and the registry serving one are different facts, and it is the + // second that matters. + + await step(SUBSTRATE, GENESIS, async () => { + await waitForContainer(CONTROL, "mesh-store", 120); + await waitForContainer(CONTROL, "mesh-broker", 120); + return (await on(CONTROL, `docker ps --format '{{.Names}}\t{{.Status}}'`)).out; + }); + + // **The whole reason the installer carries a builder.** A carried control-plane image would + // satisfy "a control plane is running" equally well, which is what makes this worth asserting: + // the image has to be one this mesh's own registry serves. + await step(BUILT_CP, GENESIS, async () => { + const image = (await on(CONTROL, + `docker inspect -f '{{.Config.Image}}' mesh-control 2>&1`)).out.trim(); + assert.match(image, /@sha256:[0-9a-f]{64}/, + `the control plane names its image by tag, not by digest: ${image}`); + assert.ok(image.includes(":5000/"), + `the control plane runs an image no registry of this mesh served: ${image}`); + return image; + }); + + await step(PIVOTED, BUILT_CP, async () => { + const ps = (await on(CONTROL, `docker ps -a --format '{{.Names}}'`)).out; + assert.doesNotMatch(ps, /^temp-mesh-control$/m, + `the temporary control plane is still here, so the pivot did not finish:\n${ps}`); + return ps; + }); + + await step(HAS_REGISTRY, SUBSTRATE, async () => { + await waitForContainer(CONTROL, "mesh-registry", 120); + const held = (await on(CONTROL, + `curl -sS --max-time 15 http://127.0.0.1:5000/v2/_catalog`)).out; + assert.match(held, /mesh-control/, + `the registry serves no mesh-control, so nothing was published into it:\n${held}`); + return held.trim(); + }); + + await step(ENROLLED, GENESIS, async () => { + const nodes = await mesh("node list"); + assert.match(nodes, new RegExp(`^${CONTROL}\\b`, "m"), + `the mesh has not heard from the machine it is running on:\n${nodes}`); + // Heard from once is not an agent running, and the difference is the whole of joining. + const agent = (await on(CONTROL, `pgrep -af '[m]esh-host run' | head -3`)).out.trim(); + assert.ok(agent, `no host agent is running, so nothing would apply what the mesh sends`); + return `${nodes.trim()}\n${agent}`; + }); + + await step(HAS_BUILDER, HAS_REGISTRY, async () => { + await waitForContainer(CONTROL, "mesh-builder", 120); + const modules = await mesh("module list"); + assert.match(modules, /^builder\b/m, + `the builder is running but the mesh holds no record of it as a module:\n${modules}`); + return modules; + }); + // ---- 2..6. THE MESH BECOMES ONE -------------------------------------------------------------- // // **Joining used to be here, second, and that was the wrong order.** Three machines were enrolled @@ -380,7 +449,7 @@ before(async () => { // The toolchain and runtime every module with code of its own stands on. It is a module, and it // is built like one — cloned from the forge by the builder installing put here, compiled on the // machine, published into the mesh's own registry. - await step(BASE_BUILT, GENESIS, async () => { + await step(BASE_BUILT, HAS_BUILDER, async () => { // Registered from the manifest the builder will also read, so what the mesh holds and what it // builds are the same description of the same module. // @@ -507,7 +576,8 @@ before(async () => { // And the catalogue, ASKED rather than observed. These five questions are what it exists for. const ask = async (tool: string, args = "{}") => - must(CONTROL, `docker exec mesh-catalog mesh-tools invoke mesh-catalog ${tool} ${quote(args)}`, + must(CONTROL, `docker exec mesh-catalog node /app/dist/main.js invoke mesh-catalog ` + + `${tool} ${quote(args)}`, 120_000); const held = await ask("catalog_modules"); @@ -631,6 +701,12 @@ after(async () => { for (const name of [ GENESIS, + SUBSTRATE, + BUILT_CP, + PIVOTED, + HAS_REGISTRY, + ENROLLED, + HAS_BUILDER, BASE_BUILT, STORE_RUNS, CATALOGUE_RUNS, From 572aae2eb404a9f5f15c49f957126c104bdee55e Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 14 Sep 2026 22:00:13 +0200 Subject: [PATCH 08/26] A plan with codes, stated up front and reported against MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steps were identified by their own sentences, so 'which one failed' meant reading prose, and rewording a step silently made it a different step with no history. Each now carries a stable code: R for raising the mesh, P for it being able to produce, U for something being used on it, V for verifying what it says about itself, E for enduring — a change following on its own, and coming back after the machine stops. The plan is data, printed before anything is attempted, so a reader knows what the run intends to establish rather than inferring it from what happens to be printed. The run ends with a table and a JSON report, and distinguishes SKIP from FAIL: a step whose dependency failed was never asked, which is not the same as a step that was asked and said no. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- test/integration/one-node-mesh.test.ts | 150 +++++++++++++++++++------ 1 file changed, 117 insertions(+), 33 deletions(-) diff --git a/test/integration/one-node-mesh.test.ts b/test/integration/one-node-mesh.test.ts index 3ce18e6..5b73a83 100644 --- a/test/integration/one-node-mesh.test.ts +++ b/test/integration/one-node-mesh.test.ts @@ -38,7 +38,7 @@ */ import { test, before, after } from "node:test"; import assert from "node:assert/strict"; -import { existsSync } from "node:fs"; +import { existsSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; @@ -294,27 +294,45 @@ function tokenFrom(said: string): string { // ---- steps, recorded rather than thrown -------------------------------------------------------- -interface Step { ok: boolean; why: string; said: string } +interface Step { + code: string; title: string; ok: boolean; why: string; said: string; seconds: number; +} const steps = new Map(); const order: string[] = []; -/** Run a step, remember what it said, and never throw. A step whose predecessor failed is skipped. */ -async function step(name: string, after_: string | null, fn: () => Promise): Promise { - order.push(name); - if (after_ && !steps.get(after_)?.ok) { - steps.set(name, { ok: false, why: `not attempted — "${after_}" did not succeed`, said: "" }); - console.log(`SKIPPED ${name}`); +/** + * Run one step of the plan, remember what it said, and never throw. + * + * **Each step carries a code, and the code is the point.** A step used to be identified by its own + * sentence, so "which one failed" meant reading prose, and rewording a step silently made it a + * different step with no history. A code is stable, sorts, and can be pointed at: "V1 failed" says + * something that a whole sentence does not. + * + * A step whose dependency did not succeed is not attempted — its answer would be meaningless and + * its failure would be attributed to the wrong cause. The run still continues to the end, so the + * report says what was established and what was never asked, which are different things. + */ +async function step( + code: string, title: string, needs: string | null, fn: () => Promise, +): Promise { + order.push(title); + if (needs && !steps.get(needs)?.ok) { + steps.set(title, { code, title, ok: false, seconds: 0, said: "", + why: `not attempted — ${steps.get(needs)?.code ?? "?"} (${needs}) did not succeed` }); + console.log(`[${code}] SKIP ${title}`); return; } - console.log(`\n======== ${name} ========`); + console.log(`\n[${code}] ---- ${title} ----`); + const began = Date.now(); + const took = () => Math.round((Date.now() - began) / 1000); try { const said = await fn(); - steps.set(name, { ok: true, why: "", said }); - console.log(`OK ${name}`); + steps.set(title, { code, title, ok: true, why: "", said, seconds: took() }); + console.log(`[${code}] PASS ${title} (${took()}s)`); } catch (err) { const why = (err as Error).message; - steps.set(name, { ok: false, why, said: "" }); - console.log(`FAILED ${name}\n${why.split("\n").slice(0, 25).join("\n")}`); + steps.set(title, { code, title, ok: false, why, said: "", seconds: took() }); + console.log(`[${code}] FAIL ${title} (${took()}s)\n${why.split("\n").slice(0, 25).join("\n")}`); } } @@ -328,8 +346,75 @@ function report(name: string): string { return `${s.why}\n\nWhere this bed got to:\n${lines.join("\n")}`; } + +/** + * The plan, as data. + * + * Stated before any of it is attempted, so a reader knows what the run is trying to establish + * rather than inferring it from whatever happens to be printed. Codes are stable; titles may be + * reworded without the step losing its identity. + * + * Five phases, and the order is the argument: a mesh is RAISED, then it must be able to PRODUCE, + * then something is USED on it, then it is asked to describe itself and its networking is + * VERIFIED, and finally it has to ENDURE — a change following on its own, and coming back after + * the machine stops. + */ +const PLAN: { code: string; title: string }[] = [ + { code: "R1", title: GENESIS }, + { code: "R2", title: SUBSTRATE }, + { code: "R3", title: BUILT_CP }, + { code: "R4", title: PIVOTED }, + { code: "R5", title: HAS_REGISTRY }, + { code: "R6", title: ENROLLED }, + { code: "R7", title: HAS_BUILDER }, + { code: "P1", title: BASE_BUILT }, + { code: "P2", title: STORE_RUNS }, + { code: "P3", title: CATALOGUE_RUNS }, + { code: "P4", title: CONTROL_REBUILT }, + { code: "U1", title: MODULE_BUILT }, + { code: "U2", title: NEEDS }, + { code: "U3", title: ANCHOR_RUNS }, + { code: "V1", title: DESCRIBES }, + { code: "V2", title: NETWORK }, + { code: "E1", title: FOLLOWS }, + { code: "E2", title: SURVIVES }, +]; + +/** What this run intends to establish, said before any of it is attempted. */ +function statePlan(): void { + console.log(`\n================ THE PLAN ================`); + for (const s of PLAN) console.log(` ${s.code.padEnd(3)} ${s.title}`); + console.log(` ${PLAN.length} steps. A step whose dependency fails is not attempted.\n`); +} + +/** The run's verdict: a table, and a file something other than a person can read. */ +function stateOutcome(): void { + console.log(`\n================ WHAT THIS MESH DID FOR ITSELF ================`); + let established = 0; + for (const title of order) { + const s = steps.get(title); + if (!s) continue; + if (s.ok) established++; + const mark = s.ok ? "PASS" : s.why.startsWith("not attempted") ? "SKIP" : "FAIL"; + console.log(` ${s.code.padEnd(3)} ${mark} ${title}${s.seconds ? ` (${s.seconds}s)` : ""}`); + } + console.log(` ${established}/${PLAN.length} established.`); + const where = process.env["MESH_LAB_REPORT"] ?? "one-node-mesh-report.json"; + try { + writeFileSync(where, JSON.stringify({ scenario: SCENARIO, established, of: PLAN.length, + steps: PLAN.map(({ code, title }) => { + const s = steps.get(title); + return { code, title, status: !s ? "never-ran" + : s.ok ? "pass" : s.why.startsWith("not attempted") ? "skip" : "fail", + seconds: s?.seconds ?? 0, why: s?.ok ? "" : s?.why ?? "" }; + }) }, null, 2)); + console.log(` report written to ${where}`); + } catch { /* a report that cannot be written must not fail a run that passed */ } +} + before(async () => { if (skip) return; + statePlan(); const bed = await raise(loadScenario(`scenarios/${SCENARIO}.yml`), { onProgress: (m) => console.log(`raise: ${m}`), @@ -344,7 +429,7 @@ before(async () => { // // The shared description, the same one `genesis-single` calls. The bundle is the TEMPLATE with // nothing held: no image is pre-resolved, because none is here to resolve to. - await step("a bare machine becomes a mesh of one, raised by the installer", null, async () => { + await step("R1", GENESIS, null, async () => { try { raised = await genesis({ instanceId, @@ -381,7 +466,7 @@ before(async () => { // saying it published an image and the registry serving one are different facts, and it is the // second that matters. - await step(SUBSTRATE, GENESIS, async () => { + await step("R2", SUBSTRATE, GENESIS, async () => { await waitForContainer(CONTROL, "mesh-store", 120); await waitForContainer(CONTROL, "mesh-broker", 120); return (await on(CONTROL, `docker ps --format '{{.Names}}\t{{.Status}}'`)).out; @@ -390,7 +475,7 @@ before(async () => { // **The whole reason the installer carries a builder.** A carried control-plane image would // satisfy "a control plane is running" equally well, which is what makes this worth asserting: // the image has to be one this mesh's own registry serves. - await step(BUILT_CP, GENESIS, async () => { + await step("R3", BUILT_CP, GENESIS, async () => { const image = (await on(CONTROL, `docker inspect -f '{{.Config.Image}}' mesh-control 2>&1`)).out.trim(); assert.match(image, /@sha256:[0-9a-f]{64}/, @@ -400,14 +485,14 @@ before(async () => { return image; }); - await step(PIVOTED, BUILT_CP, async () => { + await step("R4", PIVOTED, BUILT_CP, async () => { const ps = (await on(CONTROL, `docker ps -a --format '{{.Names}}'`)).out; assert.doesNotMatch(ps, /^temp-mesh-control$/m, `the temporary control plane is still here, so the pivot did not finish:\n${ps}`); return ps; }); - await step(HAS_REGISTRY, SUBSTRATE, async () => { + await step("R5", HAS_REGISTRY, SUBSTRATE, async () => { await waitForContainer(CONTROL, "mesh-registry", 120); const held = (await on(CONTROL, `curl -sS --max-time 15 http://127.0.0.1:5000/v2/_catalog`)).out; @@ -416,7 +501,7 @@ before(async () => { return held.trim(); }); - await step(ENROLLED, GENESIS, async () => { + await step("R6", ENROLLED, GENESIS, async () => { const nodes = await mesh("node list"); assert.match(nodes, new RegExp(`^${CONTROL}\\b`, "m"), `the mesh has not heard from the machine it is running on:\n${nodes}`); @@ -426,7 +511,7 @@ before(async () => { return `${nodes.trim()}\n${agent}`; }); - await step(HAS_BUILDER, HAS_REGISTRY, async () => { + await step("R7", HAS_BUILDER, HAS_REGISTRY, async () => { await waitForContainer(CONTROL, "mesh-builder", 120); const modules = await mesh("module list"); assert.match(modules, /^builder\b/m, @@ -449,7 +534,7 @@ before(async () => { // The toolchain and runtime every module with code of its own stands on. It is a module, and it // is built like one — cloned from the forge by the builder installing put here, compiled on the // machine, published into the mesh's own registry. - await step(BASE_BUILT, HAS_BUILDER, async () => { + await step("P1", BASE_BUILT, HAS_BUILDER, async () => { // Registered from the manifest the builder will also read, so what the mesh holds and what it // builds are the same description of the same module. // @@ -468,7 +553,7 @@ before(async () => { // plane to keep its own records in, the way it raises a broker — plumbing, not a module the mesh // has any record of, so it provides nothing to anything. A module that wants a database wants a // provider in the graph, and the catalogue below is the first thing to want one. - await step(STORE_RUNS, BASE_BUILT, () => bringUp(STORE)); + await step("P2", STORE_RUNS, BASE_BUILT, () => bringUp(STORE)); // And the catalogue, which was missing from this test altogether. // @@ -476,7 +561,7 @@ before(async () => { // of, what a change to one reaches, or what must be rebuilt. A mesh in that state still runs, // which is exactly how its absence went unnoticed — "the mesh is up" was being read off the // installer finishing rather than off the mesh being able to answer anything. - await step(CATALOGUE_RUNS, STORE_RUNS, () => bringUp(CATALOGUE)); + await step("P3", CATALOGUE_RUNS, STORE_RUNS, () => bringUp(CATALOGUE)); // The control plane, rebuilt from its own repository and rolled out. // @@ -484,7 +569,7 @@ before(async () => { // MODULE PATH — build, notice the version moved, roll it out — is a different claim: it is the // moment the mesh stops depending on the installer for anything, and the first time the thing // that performs an upgrade performs one on itself. - await step(CONTROL_REBUILT, CATALOGUE_RUNS, async () => { + await step("P4", CONTROL_REBUILT, CATALOGUE_RUNS, async () => { const built = await mesh( `build ${forgeUrl(CONTROL_PLANE.repo)} --ref ${sourceRef} --wait 1200s`, 1_500_000); assert.doesNotMatch(built, /failed/i, built); @@ -496,7 +581,7 @@ before(async () => { }); // ---- 7..8. SOMETHING TO RUN --------------------------------------------------------------- - await step(MODULE_BUILT, CONTROL_REBUILT, async () => { + await step("U1", MODULE_BUILT, CONTROL_REBUILT, async () => { await registerModule(MODULE.module, resolve(catalogDir, MODULE.module, "module.json")); const built = await mesh( `build ${forgeUrl(MODULE.repo)} --path ${MODULE.path} --ref ${refFor(MODULE.repo)} --wait 1200s`, @@ -520,11 +605,11 @@ before(async () => { // but the module is not only the broker: it carries a run-once bootstrap that writes the broker's // configuration, a provisioner that grants each consumer its own vhost and user, tools and an // event consumer, all of it its own code. - await step(NEEDS, MODULE_BUILT, () => bringUp(PROVIDER)); + await step("U2", NEEDS, MODULE_BUILT, () => bringUp(PROVIDER)); // The machine that built it. This is the case every earlier proof covered, and it is here as the // control for the last step: if this fails, that one's failure says nothing about fetching. - await step(ANCHOR_RUNS, NEEDS, async () => { + await step("U3", ANCHOR_RUNS, NEEDS, async () => { await mesh(`module issue ${MODULE.module} --node ${CONTROL}`); await mesh(`assign ${CONTROL} ${MODULE.module}`); await mesh(`push ${CONTROL}`, 600_000); @@ -538,7 +623,7 @@ before(async () => { // module running. The mesh holds a graph — nodes, what each is assigned, what capabilities each // has, which claims are occupied, what each module is configured with, which provisions exist and // who holds them — and none of it was ever asked a question. - await step(DESCRIBES, ANCHOR_RUNS, async () => { + await step("V1", DESCRIBES, ANCHOR_RUNS, async () => { const said: string[] = []; // The mesh's own verdict on itself. Nothing wrong, nothing waiting, nothing behind. @@ -604,7 +689,7 @@ before(async () => { // firewall is generated from what modules declare they listen on, and a firewall that opens the // wrong set is either a service nobody can reach or a port nobody meant to publish. Neither shows // up as a failed container. - await step(NETWORK, DESCRIBES, async () => { + await step("V2", NETWORK, DESCRIBES, async () => { const said: string[] = []; const ruleset = (await on(CONTROL, `nft list table inet mesh 2>&1`)).out; @@ -639,7 +724,7 @@ before(async () => { // The whole point of the mesh, and the capability the migration depends on: move a module's // source and the running copy follows, with nobody driving the steps. Everything above is // machinery; this is what the machinery is for. - await step(FOLLOWS, NETWORK, async () => { + await step("E1", FOLLOWS, NETWORK, async () => { const before = await mesh(`builds ${MODULE.module}`); const wasPinned = before.match(/sha256:[0-9a-f]{64}/)?.[0] ?? ""; assert.ok(wasPinned, `nothing is pinned to rebuild from:\n${before}`); @@ -670,7 +755,7 @@ before(async () => { // something to move real services onto — and the installer is explicit that a host started the // way the lab starts it does not survive a reboot, which makes this the check that says whether // that matters. - await step(SURVIVES, FOLLOWS, async () => { + await step("E2", SURVIVES, FOLLOWS, async () => { await restartMachine(CONTROL); const missing: string[] = []; for (const container of MUST_RUN) { @@ -686,8 +771,7 @@ before(async () => { return ps; }); - console.log(`\n================ WHAT THIS MESH DID FOR ITSELF ================`); - for (const n of order) console.log(` ${steps.get(n)?.ok ? "PASS" : "FAIL"} ${n}`); + stateOutcome(); }, { timeout: 7_200_000 }); after(async () => { From 3690e17cf40bbee2a5ebc69a8cd6759ada663045 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 14 Sep 2026 22:08:52 +0200 Subject: [PATCH 09/26] Ask the catalogue as the only thing that is allowed to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Invoking a tool from inside the module's own container is refused: its account is scoped to what it emits and consumes, and a tool call needs a reply queue. Filed as novox/hq issue 049 — the account is right, the request is reasonable, and nothing can make it. Until that is decided the caller is the substrate's bootstrap admin over the broker's loopback, reached by joining its network namespace the way genesis reaches a substrate container. Recorded in the step as the workaround it is, rather than left looking like how a mesh is meant to be asked a question. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- test/integration/one-node-mesh.test.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/test/integration/one-node-mesh.test.ts b/test/integration/one-node-mesh.test.ts index 5b73a83..7785589 100644 --- a/test/integration/one-node-mesh.test.ts +++ b/test/integration/one-node-mesh.test.ts @@ -660,9 +660,25 @@ before(async () => { said.push(` plan every image pinned, no placeholders`); // And the catalogue, ASKED rather than observed. These five questions are what it exists for. + // **Asked as an operator would have to, which turns out to be nobody.** + // + // The obvious move — run the invoke inside the catalogue's own container — is refused by the + // broker: `User 'anchor-mesh-catalog' doesn't have permissions to queue 'amq.gen-…'`. A + // module's account is scoped to what it declares it emits and consumes, and calling a tool + // needs a temporary reply queue, which that scope does not cover. So a module can SERVE tools + // and cannot CALL them, and nothing issues an account to anyone who wants to ask (novox/hq + // issue 049). + // + // Until that is decided, the caller is the substrate's own admin account over the broker's + // loopback — the bootstrap case `mesh-tools` documents, reached the way genesis reaches a + // substrate container, by joining its network namespace. + const image = (await on(CONTROL, + `docker inspect -f '{{.Config.Image}}' mesh-catalog`)).out.trim(); const ask = async (tool: string, args = "{}") => - must(CONTROL, `docker exec mesh-catalog node /app/dist/main.js invoke mesh-catalog ` + - `${tool} ${quote(args)}`, + must(CONTROL, + `docker run --rm --network container:mesh-broker ` + + `-e MESH_BROKER_URL=amqp://guest:guest@127.0.0.1:5672/ ` + + `${image} invoke mesh-catalog ${tool} ${quote(args)}`, 120_000); const held = await ask("catalog_modules"); From d0d56782a0679b7e4c7e5d1a0929965069301050 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 14 Sep 2026 22:17:06 +0200 Subject: [PATCH 10/26] Split describing the mesh from the catalogue holding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two claims were bundled in one step: that the control plane can describe the mesh correctly, and that the catalogue holds a complete record of what was built. The first passes; the second is novox/hq issue 050. Bundled, one open fault stopped three later steps from ever being attempted, which is exactly the information the run existed to produce. The catalogue is now measured against what the control plane ordered rather than against a list written in the test — a catalogue cannot know what it was never told, so it has to be compared with something that does. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- one-node-mesh-report.json | 133 +++++++++++++++++++++++++ test/integration/one-node-mesh.test.ts | 90 +++++++++++++---- 2 files changed, 201 insertions(+), 22 deletions(-) create mode 100644 one-node-mesh-report.json diff --git a/one-node-mesh-report.json b/one-node-mesh-report.json new file mode 100644 index 0000000..462d8b2 --- /dev/null +++ b/one-node-mesh-report.json @@ -0,0 +1,133 @@ +{ + "scenario": "one-node-mesh", + "established": 14, + "of": 18, + "steps": [ + { + "code": "R1", + "title": "a bare machine becomes a mesh of one, raised by the installer", + "status": "pass", + "seconds": 132, + "why": "" + }, + { + "code": "R2", + "title": "the substrate is up — a store and a broker of the mesh's own", + "status": "pass", + "seconds": 1, + "why": "" + }, + { + "code": "R3", + "title": "the control plane is one this mesh built, not one it was handed", + "status": "pass", + "seconds": 0, + "why": "" + }, + { + "code": "R4", + "title": "the pivot finished — what raised the mesh is gone", + "status": "pass", + "seconds": 0, + "why": "" + }, + { + "code": "R5", + "title": "the registry serves this mesh its own images", + "status": "pass", + "seconds": 0, + "why": "" + }, + { + "code": "R6", + "title": "the machine is enrolled, and an agent is running on it", + "status": "pass", + "seconds": 0, + "why": "" + }, + { + "code": "R7", + "title": "the builder is installed as a module, with an account", + "status": "pass", + "seconds": 0, + "why": "" + }, + { + "code": "P1", + "title": "the mesh builds the shared base from source", + "status": "pass", + "seconds": 84, + "why": "" + }, + { + "code": "P2", + "title": "the mesh builds and runs a store of its own", + "status": "pass", + "seconds": 40, + "why": "" + }, + { + "code": "P3", + "title": "the mesh builds and runs its own catalogue", + "status": "pass", + "seconds": 34, + "why": "" + }, + { + "code": "P4", + "title": "the mesh rebuilds its own control plane from source", + "status": "pass", + "seconds": 36, + "why": "" + }, + { + "code": "U1", + "title": "the mesh builds a module standing on that base", + "status": "pass", + "seconds": 14, + "why": "" + }, + { + "code": "U2", + "title": "the mesh runs a broker for that module to talk to", + "status": "pass", + "seconds": 24, + "why": "" + }, + { + "code": "U3", + "title": "the anchor runs the module the mesh built", + "status": "pass", + "seconds": 6, + "why": "" + }, + { + "code": "V1", + "title": "the mesh can describe itself, and what it says is true", + "status": "fail", + "seconds": 2, + "why": "the catalogue does not know about mesh-tools:\n{\"modules\":[{\"module\":\"amqp-ping\",\"commit\":\"af1e3afa495bac11e74c2d76cb2cf7a78167f2f6\",\"repository\":\"https://git.novox.be/novox/mesh-catalog.git\",\"path\":\"modules/amqp-ping\"},{\"module\":\"lavinmq\",\"commit\":\"af1e3afa495bac11e74c2d76cb2cf7a78167f2f6\",\"repository\":\"https://git.novox.be/novox/mesh-catalog.git\",\"path\":\"modules/lavinmq\"},{\"module\":\"mesh-control\",\"commit\":\"5062c36fc9efe159aa9706c0ca2c873351ef1ce0\",\"repository\":\"https://git.novox.be/novox/mesh-control.git\",\"path\":\"\"}]}\n" + }, + { + "code": "V2", + "title": "the machine's networking is what the modules asked for", + "status": "skip", + "seconds": 0, + "why": "not attempted — V1 (the mesh can describe itself, and what it says is true) did not succeed" + }, + { + "code": "E1", + "title": "a change to a module's source reaches the machine on its own", + "status": "skip", + "seconds": 0, + "why": "not attempted — V2 (the machine's networking is what the modules asked for) did not succeed" + }, + { + "code": "E2", + "title": "the mesh comes back after the machine reboots", + "status": "skip", + "seconds": 0, + "why": "not attempted — E1 (a change to a module's source reaches the machine on its own) did not succeed" + } + ] +} \ No newline at end of file diff --git a/test/integration/one-node-mesh.test.ts b/test/integration/one-node-mesh.test.ts index 7785589..a79713a 100644 --- a/test/integration/one-node-mesh.test.ts +++ b/test/integration/one-node-mesh.test.ts @@ -83,6 +83,8 @@ const PROVIDER = { module: "lavinmq", repo: "mesh-catalog", path: "modules/lavin * what a change reaches, or what must be rebuilt. It ran anyway, which is the point: "the mesh is * up" was being read off genesis finishing. */ +/** The one word that puts a mesh on a private network and gives its machines names. */ +const NETWORK_MODULE = "networking"; const STORE = { module: "postgres", repo: "mesh-catalog", path: "modules/postgres", container: "mesh-postgres" }; const CATALOGUE = { module: "mesh-catalog", repo: "mesh-catalog", path: "modules/mesh-catalog", container: "mesh-catalog" }; /** The control plane, rebuilt from its own repository — the step that ends the installer's tenure. */ @@ -113,9 +115,11 @@ const BASE_BUILT = "the mesh builds the shared base from source"; const STORE_RUNS = "the mesh builds and runs a store of its own"; const CATALOGUE_RUNS = "the mesh builds and runs its own catalogue"; const CONTROL_REBUILT = "the mesh rebuilds its own control plane from source"; +const NETWORKED = "the mesh puts itself on a private network, and its machine has a name"; const MODULE_BUILT = "the mesh builds a module standing on that base"; const ANCHOR_RUNS = "the anchor runs the module the mesh built"; -const DESCRIBES = "the mesh can describe itself, and what it says is true"; +const DESCRIBES = "the control plane can describe the mesh, and what it says is true"; +const CATALOGUED = "the catalogue holds every module this mesh built"; const NETWORK = "the machine's networking is what the modules asked for"; const FOLLOWS = "a change to a module's source reaches the machine on its own"; const SURVIVES = "the mesh comes back after the machine reboots"; @@ -371,11 +375,13 @@ const PLAN: { code: string; title: string }[] = [ { code: "P2", title: STORE_RUNS }, { code: "P3", title: CATALOGUE_RUNS }, { code: "P4", title: CONTROL_REBUILT }, + { code: "N1", title: NETWORKED }, { code: "U1", title: MODULE_BUILT }, { code: "U2", title: NEEDS }, { code: "U3", title: ANCHOR_RUNS }, { code: "V1", title: DESCRIBES }, - { code: "V2", title: NETWORK }, + { code: "V2", title: CATALOGUED }, + { code: "V3", title: NETWORK }, { code: "E1", title: FOLLOWS }, { code: "E2", title: SURVIVES }, ]; @@ -580,8 +586,38 @@ before(async () => { return `${built}\n${rolled}`; }); + // ---- THE MESH'S OWN NETWORKING --------------------------------------------------------------- + // + // **Four modules the control plane computes were assigned to nothing, and nothing complained.** + // `networking`, `mesh-wireguard`, `mesh-names` and `mesh-resolver` all existed as records that + // had never been placed on a machine — so no names were written, no private network was raised, + // and `/etc/hosts` held nothing. A module is a definition until it is assigned; being generated + // by the control plane does not place it. + // + // One word, by design: `networking` has no files of its own and is requirements only, so + // assigning it finds one answer to each and takes them. The day the catalogue holds a second VPN + // there are two answers, the mesh refuses and names both, and choosing is assigning the one you + // want. + await step("N1", NETWORKED, CONTROL_REBUILT, async () => { + await mesh(`assign ${CONTROL} ${NETWORK_MODULE}`); + await mesh(`push ${CONTROL}`, 600_000); + // What it was assigned for. A machine on a private network with no name on it has had the + // harder half done and the visible half not. + const deadline = Date.now() + 120_000; + let hosts = ""; + while (Date.now() < deadline) { + hosts = (await on(CONTROL, `cat /etc/hosts`)).out; + if (/\.internal/.test(hosts)) break; + await new Promise((r) => setTimeout(r, 5_000)); + } + assert.match(hosts, /\.internal/, + `${NETWORK_MODULE} is assigned and no machine has a name:\n${hosts}`); + const modules = await mesh("module list"); + return `${hosts.trim()}\n\n${modules.trim()}`; + }); + // ---- 7..8. SOMETHING TO RUN --------------------------------------------------------------- - await step("U1", MODULE_BUILT, CONTROL_REBUILT, async () => { + await step("U1", MODULE_BUILT, NETWORKED, async () => { await registerModule(MODULE.module, resolve(catalogDir, MODULE.module, "module.json")); const built = await mesh( `build ${forgeUrl(MODULE.repo)} --path ${MODULE.path} --ref ${refFor(MODULE.repo)} --wait 1200s`, @@ -660,18 +696,24 @@ before(async () => { said.push(` plan every image pinned, no placeholders`); // And the catalogue, ASKED rather than observed. These five questions are what it exists for. + return said.join("\n"); + }); + + // ---- V2. AND THE CATALOGUE HOLDS WHAT WAS BUILT ----------------------------------------------- + // + // **Split from the step above, because they are two claims and only one of them fails.** The + // control plane describing the mesh correctly and the catalogue holding a complete record of it + // are different things, and bundling them meant one open fault stopped three later steps from + // ever being attempted. + await step("V2", CATALOGUED, DESCRIBES, async () => { // **Asked as an operator would have to, which turns out to be nobody.** // // The obvious move — run the invoke inside the catalogue's own container — is refused by the - // broker: `User 'anchor-mesh-catalog' doesn't have permissions to queue 'amq.gen-…'`. A - // module's account is scoped to what it declares it emits and consumes, and calling a tool - // needs a temporary reply queue, which that scope does not cover. So a module can SERVE tools + // broker: a module's account is scoped to what it declares it emits and consumes, and calling + // a tool needs a temporary reply queue that scope does not cover. So a module can SERVE tools // and cannot CALL them, and nothing issues an account to anyone who wants to ask (novox/hq - // issue 049). - // - // Until that is decided, the caller is the substrate's own admin account over the broker's - // loopback — the bootstrap case `mesh-tools` documents, reached the way genesis reaches a - // substrate container, by joining its network namespace. + // issue 049). Until that is decided the caller is the substrate's bootstrap admin over the + // broker's loopback, reached by joining its network namespace. const image = (await on(CONTROL, `docker inspect -f '{{.Config.Image}}' mesh-catalog`)).out.trim(); const ask = async (tool: string, args = "{}") => @@ -682,30 +724,32 @@ before(async () => { 120_000); const held = await ask("catalog_modules"); - for (const m of MUST_HOLD) { - if (m === "registry" || m === "builder" || m === "mesh-control") continue; // carried, not built here - assert.ok(held.includes(m), `the catalogue does not know about ${m}:\n${held}`); - } + // Compared against what the control plane ordered, rather than against a list written here: a + // catalogue cannot know what it was never told, so it must be measured against something that + // does. novox/hq issue 050 — on a fresh mesh the modules built before the catalogue existed + // are exactly the ones it needed in order to exist, so the hole is always the foundation. + const missing = MUST_HOLD.filter((m) => + !["registry", "builder"].includes(m) && !held.includes(m)); + assert.deepEqual(missing, [], + `the catalogue does not hold ${missing.join(", ")} — the mesh built them and its own ` + + `record has no trace of it (novox/hq issue 050):\n${held}`); assert.doesNotMatch(held, /sha256:0{64}/, `the catalogue holds a placeholder version`); - said.push(` catalog_modules every built module, each with a version this mesh made`); const provides = await ask("catalog_provides", JSON.stringify({ provision: "amqp" })); assert.ok(provides.includes(PROVIDER.module), - `the catalogue cannot say what provides amqp, which is the question it exists to answer:\n${provides}`); - said.push(` catalog_provides amqp is answered by ${PROVIDER.module}`); + `the catalogue cannot say what provides amqp, which is a question it exists for:\n${provides}`); const stale = await ask("catalog_stale"); - said.push(` catalog_stale ${stale.trim().slice(0, 120)}`); - return said.join("\n"); + return `${held}\n${provides}\n${stale}`; }); - // ---- 10. AND ITS NETWORKING IS WHAT WAS ASKED FOR --------------------------------------------- + // ---- V3. AND ITS NETWORKING IS WHAT WAS ASKED FOR --------------------------------------------- // // **Left out of this test entirely until it was pointed out**, which is hard to defend: the // firewall is generated from what modules declare they listen on, and a firewall that opens the // wrong set is either a service nobody can reach or a port nobody meant to publish. Neither shows // up as a failed container. - await step("V2", NETWORK, DESCRIBES, async () => { + await step("V3", NETWORK, DESCRIBES, async () => { const said: string[] = []; const ruleset = (await on(CONTROL, `nft list table inet mesh 2>&1`)).out; @@ -811,10 +855,12 @@ for (const name of [ STORE_RUNS, CATALOGUE_RUNS, CONTROL_REBUILT, + NETWORKED, MODULE_BUILT, NEEDS, ANCHOR_RUNS, DESCRIBES, + CATALOGUED, NETWORK, FOLLOWS, SURVIVES, From 6b482ee7dc9a0dde71792b9c4fc6275256628cc2 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 14 Sep 2026 22:26:04 +0200 Subject: [PATCH 11/26] Assigning networking is not being on the network MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit N1 assigned the module and stopped, and the mesh wrote a names file with no names in it. That is correct behaviour, not a bug: a node with no address on the network has no name, because a name resolving to nothing is worse than no name — a connection to an address that does not answer hangs, where a name that does not resolve fails at once and says so. The missing act is placement. Assigning installs the module that answers how machines reach each other; placing says where this machine is on the resulting network. The four-machine test did both and this one did neither, which is how the distinction stayed invisible. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- one-node-mesh-report.json | 56 ++++++++++++++++---------- test/integration/one-node-mesh.test.ts | 7 ++++ 2 files changed, 42 insertions(+), 21 deletions(-) diff --git a/one-node-mesh-report.json b/one-node-mesh-report.json index 462d8b2..c14987c 100644 --- a/one-node-mesh-report.json +++ b/one-node-mesh-report.json @@ -1,7 +1,7 @@ { "scenario": "one-node-mesh", - "established": 14, - "of": 18, + "established": 11, + "of": 20, "steps": [ { "code": "R1", @@ -56,71 +56,85 @@ "code": "P1", "title": "the mesh builds the shared base from source", "status": "pass", - "seconds": 84, + "seconds": 78, "why": "" }, { "code": "P2", "title": "the mesh builds and runs a store of its own", "status": "pass", - "seconds": 40, + "seconds": 50, "why": "" }, { "code": "P3", "title": "the mesh builds and runs its own catalogue", "status": "pass", - "seconds": 34, + "seconds": 25, "why": "" }, { "code": "P4", "title": "the mesh rebuilds its own control plane from source", "status": "pass", - "seconds": 36, + "seconds": 37, "why": "" }, + { + "code": "N1", + "title": "the mesh puts itself on a private network, and its machine has a name", + "status": "fail", + "seconds": 125, + "why": "networking is assigned and no machine has a name:\n# Generated by the mesh. Do not edit — this file is replaced whenever a node\n# joins or leaves, and an edit would survive until then and vanish.\n\n127.0.0.1\tlocalhost\n::1\t\tlocalhost ip6-localhost ip6-loopback\n127.0.1.1\tanchor\n" + }, { "code": "U1", "title": "the mesh builds a module standing on that base", - "status": "pass", - "seconds": 14, - "why": "" + "status": "skip", + "seconds": 0, + "why": "not attempted — N1 (the mesh puts itself on a private network, and its machine has a name) did not succeed" }, { "code": "U2", "title": "the mesh runs a broker for that module to talk to", - "status": "pass", - "seconds": 24, - "why": "" + "status": "skip", + "seconds": 0, + "why": "not attempted — U1 (the mesh builds a module standing on that base) did not succeed" }, { "code": "U3", "title": "the anchor runs the module the mesh built", - "status": "pass", - "seconds": 6, - "why": "" + "status": "skip", + "seconds": 0, + "why": "not attempted — U2 (the mesh runs a broker for that module to talk to) did not succeed" }, { "code": "V1", - "title": "the mesh can describe itself, and what it says is true", - "status": "fail", - "seconds": 2, - "why": "the catalogue does not know about mesh-tools:\n{\"modules\":[{\"module\":\"amqp-ping\",\"commit\":\"af1e3afa495bac11e74c2d76cb2cf7a78167f2f6\",\"repository\":\"https://git.novox.be/novox/mesh-catalog.git\",\"path\":\"modules/amqp-ping\"},{\"module\":\"lavinmq\",\"commit\":\"af1e3afa495bac11e74c2d76cb2cf7a78167f2f6\",\"repository\":\"https://git.novox.be/novox/mesh-catalog.git\",\"path\":\"modules/lavinmq\"},{\"module\":\"mesh-control\",\"commit\":\"5062c36fc9efe159aa9706c0ca2c873351ef1ce0\",\"repository\":\"https://git.novox.be/novox/mesh-control.git\",\"path\":\"\"}]}\n" + "title": "the control plane can describe the mesh, and what it says is true", + "status": "skip", + "seconds": 0, + "why": "not attempted — U3 (the anchor runs the module the mesh built) did not succeed" }, { "code": "V2", + "title": "the catalogue holds every module this mesh built", + "status": "skip", + "seconds": 0, + "why": "not attempted — V1 (the control plane can describe the mesh, and what it says is true) did not succeed" + }, + { + "code": "V3", "title": "the machine's networking is what the modules asked for", "status": "skip", "seconds": 0, - "why": "not attempted — V1 (the mesh can describe itself, and what it says is true) did not succeed" + "why": "not attempted — V1 (the control plane can describe the mesh, and what it says is true) did not succeed" }, { "code": "E1", "title": "a change to a module's source reaches the machine on its own", "status": "skip", "seconds": 0, - "why": "not attempted — V2 (the machine's networking is what the modules asked for) did not succeed" + "why": "not attempted — V3 (the machine's networking is what the modules asked for) did not succeed" }, { "code": "E2", diff --git a/test/integration/one-node-mesh.test.ts b/test/integration/one-node-mesh.test.ts index a79713a..fcb6d57 100644 --- a/test/integration/one-node-mesh.test.ts +++ b/test/integration/one-node-mesh.test.ts @@ -600,6 +600,13 @@ before(async () => { // want. await step("N1", NETWORKED, CONTROL_REBUILT, async () => { await mesh(`assign ${CONTROL} ${NETWORK_MODULE}`); + // **Assigned is not placed, and they are two different acts.** Assigning installs the module + // that answers "how do machines reach each other"; placing says where THIS machine is on the + // resulting network. Without the second the module runs and writes a names file with no names + // in it — deliberately, because "a node with no address on the network has no name here", and a + // name resolving to nothing is worse than no name: a connection to an address that does not + // answer hangs, where a name that does not resolve fails at once and says so. + await mesh(`overlay place ${CONTROL} --hub --endpoint ${ANCHOR}:51820 --site hosting`); await mesh(`push ${CONTROL}`, 600_000); // What it was assigned for. A machine on a private network with no name on it has had the // harder half done and the visible half not. From f132a4bcbde14f566c847112cf17b5b6d4b88207 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 14 Sep 2026 22:45:52 +0200 Subject: [PATCH 12/26] The packet filter is a module too, and it was assigned to nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V3 asked whether the machine's networking is what the modules asked for and found no mesh firewall table at all. The firewall is a module — it claims the packet-filter seat, installs the filter and loads the rules — and like networking before it, it had never been assigned to anything. So every rule the mesh generates from module listen declarations had never been applied to any machine in this test. Not open by accident: a mesh where that whole generation has never run. Assigned separately from networking because they answer different questions. One is how machines reach each other; the other is what may reach this one. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- one-node-mesh-report.json | 50 +++++++++++++------------- test/integration/one-node-mesh.test.ts | 35 +++++++++++++++++- 2 files changed, 59 insertions(+), 26 deletions(-) diff --git a/one-node-mesh-report.json b/one-node-mesh-report.json index c14987c..9136f06 100644 --- a/one-node-mesh-report.json +++ b/one-node-mesh-report.json @@ -1,13 +1,13 @@ { "scenario": "one-node-mesh", - "established": 11, + "established": 16, "of": 20, "steps": [ { "code": "R1", "title": "a bare machine becomes a mesh of one, raised by the installer", "status": "pass", - "seconds": 132, + "seconds": 140, "why": "" }, { @@ -56,14 +56,14 @@ "code": "P1", "title": "the mesh builds the shared base from source", "status": "pass", - "seconds": 78, + "seconds": 74, "why": "" }, { "code": "P2", "title": "the mesh builds and runs a store of its own", "status": "pass", - "seconds": 50, + "seconds": 53, "why": "" }, { @@ -77,57 +77,57 @@ "code": "P4", "title": "the mesh rebuilds its own control plane from source", "status": "pass", - "seconds": 37, + "seconds": 35, "why": "" }, { "code": "N1", "title": "the mesh puts itself on a private network, and its machine has a name", - "status": "fail", - "seconds": 125, - "why": "networking is assigned and no machine has a name:\n# Generated by the mesh. Do not edit — this file is replaced whenever a node\n# joins or leaves, and an edit would survive until then and vanish.\n\n127.0.0.1\tlocalhost\n::1\t\tlocalhost ip6-localhost ip6-loopback\n127.0.1.1\tanchor\n" + "status": "pass", + "seconds": 6, + "why": "" }, { "code": "U1", "title": "the mesh builds a module standing on that base", - "status": "skip", - "seconds": 0, - "why": "not attempted — N1 (the mesh puts itself on a private network, and its machine has a name) did not succeed" + "status": "pass", + "seconds": 14, + "why": "" }, { "code": "U2", "title": "the mesh runs a broker for that module to talk to", - "status": "skip", - "seconds": 0, - "why": "not attempted — U1 (the mesh builds a module standing on that base) did not succeed" + "status": "pass", + "seconds": 20, + "why": "" }, { "code": "U3", "title": "the anchor runs the module the mesh built", - "status": "skip", - "seconds": 0, - "why": "not attempted — U2 (the mesh runs a broker for that module to talk to) did not succeed" + "status": "pass", + "seconds": 6, + "why": "" }, { "code": "V1", "title": "the control plane can describe the mesh, and what it says is true", - "status": "skip", - "seconds": 0, - "why": "not attempted — U3 (the anchor runs the module the mesh built) did not succeed" + "status": "pass", + "seconds": 1, + "why": "" }, { "code": "V2", "title": "the catalogue holds every module this mesh built", - "status": "skip", - "seconds": 0, - "why": "not attempted — V1 (the control plane can describe the mesh, and what it says is true) did not succeed" + "status": "fail", + "seconds": 1, + "why": "the catalogue does not hold mesh-tools, postgres — the mesh built them and its own record has no trace of it (novox/hq issue 050):\n{\"modules\":[{\"module\":\"amqp-ping\",\"commit\":\"e0c92195d4240841bfcf4b4a9ef869d5afeca331\",\"repository\":\"https://git.novox.be/novox/mesh-catalog.git\",\"path\":\"modules/amqp-ping\"},{\"module\":\"lavinmq\",\"commit\":\"e0c92195d4240841bfcf4b4a9ef869d5afeca331\",\"repository\":\"https://git.novox.be/novox/mesh-catalog.git\",\"path\":\"modules/lavinmq\"},{\"module\":\"mesh-control\",\"commit\":\"5062c36fc9efe159aa9706c0ca2c873351ef1ce0\",\"repository\":\"https://git.novox.be/novox/mesh-control.git\",\"path\":\"\"}]}\n\n+ actual - expected\n\n+ [\n+ 'mesh-tools',\n+ 'postgres'\n+ ]\n- []\n" }, { "code": "V3", "title": "the machine's networking is what the modules asked for", - "status": "skip", + "status": "fail", "seconds": 0, - "why": "not attempted — V1 (the control plane can describe the mesh, and what it says is true) did not succeed" + "why": "the mesh's own firewall table is not there:\nError: No such file or directory\nlist table inet mesh\n ^^^^\n" }, { "code": "E1", diff --git a/test/integration/one-node-mesh.test.ts b/test/integration/one-node-mesh.test.ts index fcb6d57..11d800e 100644 --- a/test/integration/one-node-mesh.test.ts +++ b/test/integration/one-node-mesh.test.ts @@ -85,6 +85,15 @@ const PROVIDER = { module: "lavinmq", repo: "mesh-catalog", path: "modules/lavin */ /** The one word that puts a mesh on a private network and gives its machines names. */ const NETWORK_MODULE = "networking"; +/** + * The packet filter, which is a module too and was assigned to nothing. + * + * The rules are generated from what every module declares it listens on, so a mesh with no filter + * is not "open by accident" — it is a mesh where the whole of that generation has never run. It + * claims a seat (`the-packet-filter`) because a machine has one of these and two things writing + * rules is a coin toss about which survives. + */ +const FILTER_MODULE = "firewall"; const STORE = { module: "postgres", repo: "mesh-catalog", path: "modules/postgres", container: "mesh-postgres" }; const CATALOGUE = { module: "mesh-catalog", repo: "mesh-catalog", path: "modules/mesh-catalog", container: "mesh-catalog" }; /** The control plane, rebuilt from its own repository — the step that ends the installer's tenure. */ @@ -116,6 +125,7 @@ const STORE_RUNS = "the mesh builds and runs a store of its own"; const CATALOGUE_RUNS = "the mesh builds and runs its own catalogue"; const CONTROL_REBUILT = "the mesh rebuilds its own control plane from source"; const NETWORKED = "the mesh puts itself on a private network, and its machine has a name"; +const FILTERED = "the machine has a packet filter, loaded from what modules declared"; const MODULE_BUILT = "the mesh builds a module standing on that base"; const ANCHOR_RUNS = "the anchor runs the module the mesh built"; const DESCRIBES = "the control plane can describe the mesh, and what it says is true"; @@ -376,6 +386,7 @@ const PLAN: { code: string; title: string }[] = [ { code: "P3", title: CATALOGUE_RUNS }, { code: "P4", title: CONTROL_REBUILT }, { code: "N1", title: NETWORKED }, + { code: "N2", title: FILTERED }, { code: "U1", title: MODULE_BUILT }, { code: "U2", title: NEEDS }, { code: "U3", title: ANCHOR_RUNS }, @@ -623,8 +634,29 @@ before(async () => { return `${hosts.trim()}\n\n${modules.trim()}`; }); + // ---- THE PACKET FILTER ------------------------------------------------------------------------- + // + // Assigned separately from `networking` because they answer different questions: one is how + // machines reach each other, the other is what may reach this one. Both were assigned to nothing, + // and the second is the more alarming of the two — every rule the mesh generates from module + // declarations had never been applied to any machine in this test. + await step("N2", FILTERED, NETWORKED, async () => { + await mesh(`assign ${CONTROL} ${FILTER_MODULE}`); + await mesh(`push ${CONTROL}`, 600_000); + const deadline = Date.now() + 180_000; + let ruleset = ""; + while (Date.now() < deadline) { + ruleset = (await on(CONTROL, `nft list table inet mesh 2>&1`)).out; + if (/chain input/.test(ruleset)) break; + await new Promise((r) => setTimeout(r, 5_000)); + } + assert.match(ruleset, /chain input/, + `${FILTER_MODULE} is assigned and the machine has no mesh filter:\n${ruleset}`); + return ruleset; + }); + // ---- 7..8. SOMETHING TO RUN --------------------------------------------------------------- - await step("U1", MODULE_BUILT, NETWORKED, async () => { + await step("U1", MODULE_BUILT, FILTERED, async () => { await registerModule(MODULE.module, resolve(catalogDir, MODULE.module, "module.json")); const built = await mesh( `build ${forgeUrl(MODULE.repo)} --path ${MODULE.path} --ref ${refFor(MODULE.repo)} --wait 1200s`, @@ -863,6 +895,7 @@ for (const name of [ CATALOGUE_RUNS, CONTROL_REBUILT, NETWORKED, + FILTERED, MODULE_BUILT, NEEDS, ANCHOR_RUNS, From 475fd9a088a89f4a92ce170f88f8a18cfc317f1f Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 14 Sep 2026 22:52:26 +0200 Subject: [PATCH 13/26] Register the firewall before assigning it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'no module of that name: firewall'. The networking family is computed by the control plane, so the mesh knows those exist without anyone saying so; the firewall is an ordinary catalogue module and has to be added like any other. That is a second way for a module to be absent, and a less obvious one than being present and placed nowhere — the mesh does not hold it at all, so nothing can even report it unassigned. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- one-node-mesh-report.json | 55 +++++++++++++++----------- test/integration/one-node-mesh.test.ts | 8 ++++ 2 files changed, 39 insertions(+), 24 deletions(-) diff --git a/one-node-mesh-report.json b/one-node-mesh-report.json index 9136f06..9ac706f 100644 --- a/one-node-mesh-report.json +++ b/one-node-mesh-report.json @@ -1,13 +1,13 @@ { "scenario": "one-node-mesh", - "established": 16, - "of": 20, + "established": 12, + "of": 21, "steps": [ { "code": "R1", "title": "a bare machine becomes a mesh of one, raised by the installer", "status": "pass", - "seconds": 140, + "seconds": 135, "why": "" }, { @@ -56,21 +56,21 @@ "code": "P1", "title": "the mesh builds the shared base from source", "status": "pass", - "seconds": 74, + "seconds": 87, "why": "" }, { "code": "P2", "title": "the mesh builds and runs a store of its own", "status": "pass", - "seconds": 53, + "seconds": 41, "why": "" }, { "code": "P3", "title": "the mesh builds and runs its own catalogue", "status": "pass", - "seconds": 25, + "seconds": 37, "why": "" }, { @@ -84,50 +84,57 @@ "code": "N1", "title": "the mesh puts itself on a private network, and its machine has a name", "status": "pass", - "seconds": 6, + "seconds": 7, "why": "" }, + { + "code": "N2", + "title": "the machine has a packet filter, loaded from what modules declared", + "status": "fail", + "seconds": 0, + "why": "anchor: docker exec mesh-control /mesh-control assign anchor firewall\n\nmesh-control: no module of that name: firewall\n" + }, { "code": "U1", "title": "the mesh builds a module standing on that base", - "status": "pass", - "seconds": 14, - "why": "" + "status": "skip", + "seconds": 0, + "why": "not attempted — N2 (the machine has a packet filter, loaded from what modules declared) did not succeed" }, { "code": "U2", "title": "the mesh runs a broker for that module to talk to", - "status": "pass", - "seconds": 20, - "why": "" + "status": "skip", + "seconds": 0, + "why": "not attempted — U1 (the mesh builds a module standing on that base) did not succeed" }, { "code": "U3", "title": "the anchor runs the module the mesh built", - "status": "pass", - "seconds": 6, - "why": "" + "status": "skip", + "seconds": 0, + "why": "not attempted — U2 (the mesh runs a broker for that module to talk to) did not succeed" }, { "code": "V1", "title": "the control plane can describe the mesh, and what it says is true", - "status": "pass", - "seconds": 1, - "why": "" + "status": "skip", + "seconds": 0, + "why": "not attempted — U3 (the anchor runs the module the mesh built) did not succeed" }, { "code": "V2", "title": "the catalogue holds every module this mesh built", - "status": "fail", - "seconds": 1, - "why": "the catalogue does not hold mesh-tools, postgres — the mesh built them and its own record has no trace of it (novox/hq issue 050):\n{\"modules\":[{\"module\":\"amqp-ping\",\"commit\":\"e0c92195d4240841bfcf4b4a9ef869d5afeca331\",\"repository\":\"https://git.novox.be/novox/mesh-catalog.git\",\"path\":\"modules/amqp-ping\"},{\"module\":\"lavinmq\",\"commit\":\"e0c92195d4240841bfcf4b4a9ef869d5afeca331\",\"repository\":\"https://git.novox.be/novox/mesh-catalog.git\",\"path\":\"modules/lavinmq\"},{\"module\":\"mesh-control\",\"commit\":\"5062c36fc9efe159aa9706c0ca2c873351ef1ce0\",\"repository\":\"https://git.novox.be/novox/mesh-control.git\",\"path\":\"\"}]}\n\n+ actual - expected\n\n+ [\n+ 'mesh-tools',\n+ 'postgres'\n+ ]\n- []\n" + "status": "skip", + "seconds": 0, + "why": "not attempted — V1 (the control plane can describe the mesh, and what it says is true) did not succeed" }, { "code": "V3", "title": "the machine's networking is what the modules asked for", - "status": "fail", + "status": "skip", "seconds": 0, - "why": "the mesh's own firewall table is not there:\nError: No such file or directory\nlist table inet mesh\n ^^^^\n" + "why": "not attempted — V1 (the control plane can describe the mesh, and what it says is true) did not succeed" }, { "code": "E1", diff --git a/test/integration/one-node-mesh.test.ts b/test/integration/one-node-mesh.test.ts index 11d800e..c7c09bd 100644 --- a/test/integration/one-node-mesh.test.ts +++ b/test/integration/one-node-mesh.test.ts @@ -641,6 +641,14 @@ before(async () => { // and the second is the more alarming of the two — every rule the mesh generates from module // declarations had never been applied to any machine in this test. await step("N2", FILTERED, NETWORKED, async () => { + // **Registered first: the mesh had never heard of it.** The networking family is computed by + // the control plane, so the mesh knows those modules exist without anyone saying so. The + // firewall is an ordinary catalogue module and needs adding like any other — "no module of + // that name: firewall" — which is a second way for a module to be absent, and less obvious + // than being present and placed nowhere. + // + // No build: its resources are a package and a service, so there is nothing to compile. + await registerModule(FILTER_MODULE, resolve(catalogDir, FILTER_MODULE, "module.json")); await mesh(`assign ${CONTROL} ${FILTER_MODULE}`); await mesh(`push ${CONTROL}`, 600_000); const deadline = Date.now() + 180_000; From 9b8b21ac176feac195041d215a711237f55a0092 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 14 Sep 2026 23:36:05 +0200 Subject: [PATCH 14/26] E1 moves the module's source for real Reading the branch head and telling the mesh the source had moved there named the commit it had just built, so the mesh correctly answered that everything was current. Naming a different commit would not work either: staleness compares artifacts, not commits, deliberately, so that editing a comment in a shared base does not rebuild everything standing on it to arrive back where it started. So the step makes a real change and pushes it, and asserts the module comes back on a DIFFERENT artifact than it had. A test that writes to a branch is worth knowing about; the alternative is proving the loop by telling the mesh something untrue. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- one-node-mesh-report.json | 60 +++++++++++++------------- test/integration/one-node-mesh.test.ts | 52 ++++++++++++++++------ 2 files changed, 69 insertions(+), 43 deletions(-) diff --git a/one-node-mesh-report.json b/one-node-mesh-report.json index 9ac706f..a759bdd 100644 --- a/one-node-mesh-report.json +++ b/one-node-mesh-report.json @@ -1,13 +1,13 @@ { "scenario": "one-node-mesh", - "established": 12, + "established": 18, "of": 21, "steps": [ { "code": "R1", "title": "a bare machine becomes a mesh of one, raised by the installer", "status": "pass", - "seconds": 135, + "seconds": 133, "why": "" }, { @@ -56,28 +56,28 @@ "code": "P1", "title": "the mesh builds the shared base from source", "status": "pass", - "seconds": 87, + "seconds": 83, "why": "" }, { "code": "P2", "title": "the mesh builds and runs a store of its own", "status": "pass", - "seconds": 41, + "seconds": 47, "why": "" }, { "code": "P3", "title": "the mesh builds and runs its own catalogue", "status": "pass", - "seconds": 37, + "seconds": 26, "why": "" }, { "code": "P4", "title": "the mesh rebuilds its own control plane from source", "status": "pass", - "seconds": 35, + "seconds": 30, "why": "" }, { @@ -90,58 +90,58 @@ { "code": "N2", "title": "the machine has a packet filter, loaded from what modules declared", - "status": "fail", - "seconds": 0, - "why": "anchor: docker exec mesh-control /mesh-control assign anchor firewall\n\nmesh-control: no module of that name: firewall\n" + "status": "pass", + "seconds": 7, + "why": "" }, { "code": "U1", "title": "the mesh builds a module standing on that base", - "status": "skip", - "seconds": 0, - "why": "not attempted — N2 (the machine has a packet filter, loaded from what modules declared) did not succeed" + "status": "pass", + "seconds": 23, + "why": "" }, { "code": "U2", "title": "the mesh runs a broker for that module to talk to", - "status": "skip", - "seconds": 0, - "why": "not attempted — U1 (the mesh builds a module standing on that base) did not succeed" + "status": "pass", + "seconds": 20, + "why": "" }, { "code": "U3", "title": "the anchor runs the module the mesh built", - "status": "skip", - "seconds": 0, - "why": "not attempted — U2 (the mesh runs a broker for that module to talk to) did not succeed" + "status": "pass", + "seconds": 6, + "why": "" }, { "code": "V1", "title": "the control plane can describe the mesh, and what it says is true", - "status": "skip", - "seconds": 0, - "why": "not attempted — U3 (the anchor runs the module the mesh built) did not succeed" + "status": "pass", + "seconds": 1, + "why": "" }, { "code": "V2", "title": "the catalogue holds every module this mesh built", - "status": "skip", - "seconds": 0, - "why": "not attempted — V1 (the control plane can describe the mesh, and what it says is true) did not succeed" + "status": "fail", + "seconds": 1, + "why": "the catalogue does not hold mesh-tools, postgres — the mesh built them and its own record has no trace of it (novox/hq issue 050):\n{\"modules\":[{\"module\":\"amqp-ping\",\"commit\":\"e0c92195d4240841bfcf4b4a9ef869d5afeca331\",\"repository\":\"https://git.novox.be/novox/mesh-catalog.git\",\"path\":\"modules/amqp-ping\"},{\"module\":\"lavinmq\",\"commit\":\"e0c92195d4240841bfcf4b4a9ef869d5afeca331\",\"repository\":\"https://git.novox.be/novox/mesh-catalog.git\",\"path\":\"modules/lavinmq\"},{\"module\":\"mesh-control\",\"commit\":\"5062c36fc9efe159aa9706c0ca2c873351ef1ce0\",\"repository\":\"https://git.novox.be/novox/mesh-control.git\",\"path\":\"\"}]}\n\n+ actual - expected\n\n+ [\n+ 'mesh-tools',\n+ 'postgres'\n+ ]\n- []\n" }, { "code": "V3", "title": "the machine's networking is what the modules asked for", - "status": "skip", - "seconds": 0, - "why": "not attempted — V1 (the control plane can describe the mesh, and what it says is true) did not succeed" + "status": "pass", + "seconds": 1, + "why": "" }, { "code": "E1", "title": "a change to a module's source reaches the machine on its own", - "status": "skip", - "seconds": 0, - "why": "not attempted — V3 (the machine's networking is what the modules asked for) did not succeed" + "status": "fail", + "seconds": 1, + "why": "the mesh does not report a module behind its source:\n1 machine(s), all doing what they were told, all heard from, running what the mesh would send them, and every module current with its source\n" }, { "code": "E2", diff --git a/test/integration/one-node-mesh.test.ts b/test/integration/one-node-mesh.test.ts index c7c09bd..9f511cf 100644 --- a/test/integration/one-node-mesh.test.ts +++ b/test/integration/one-node-mesh.test.ts @@ -38,7 +38,8 @@ */ import { test, before, after } from "node:test"; import assert from "node:assert/strict"; -import { existsSync, writeFileSync } from "node:fs"; +import { existsSync, writeFileSync, appendFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; import { resolve } from "node:path"; import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; @@ -133,7 +134,7 @@ const CATALOGUED = "the catalogue holds every module this mesh built"; const NETWORK = "the machine's networking is what the modules asked for"; const FOLLOWS = "a change to a module's source reaches the machine on its own"; const SURVIVES = "the mesh comes back after the machine reboots"; -const MODULE = { module: "amqp-ping", repo: "mesh-catalog", path: "modules/amqp-ping" }; +const MODULE = { module: "amqp-ping", repo: "mesh-catalog", path: "modules/amqp-ping", container: "amqp-ping" }; const capability = await labIsUsable(); const binary = hostBinaryPath(); @@ -833,26 +834,51 @@ before(async () => { // machinery; this is what the machinery is for. await step("E1", FOLLOWS, NETWORK, async () => { const before = await mesh(`builds ${MODULE.module}`); - const wasPinned = before.match(/sha256:[0-9a-f]{64}/)?.[0] ?? ""; - assert.ok(wasPinned, `nothing is pinned to rebuild from:\n${before}`); + const was = before.match(/sha256:[0-9a-f]{64}/)?.[0] ?? ""; + assert.ok(was, `nothing is pinned to rebuild from:\n${before}`); - // The mesh is told its copy is older than the source. In life a push does this; here it is - // stated, because what is under test is what the mesh does next, not how it hears. - const head = (await must(CONTROL, `git ls-remote ${forgeUrl(MODULE.repo)} ` + - `${refFor(MODULE.repo)} | cut -f1`, 120_000)).trim(); - assert.match(head, /^[0-9a-f]{40}$/, `could not read the source's head: ${head}`); + // **The source has to actually move, and it cannot be faked.** + // + // The first version of this read the branch head and told the mesh the source had moved there + // — the same commit it had just built. The mesh answered, correctly, that everything was + // current. Naming some other commit would not work either: staleness compares ARTIFACTS, not + // commits, which is a deliberate choice so that editing a comment in a shared base does not + // rebuild everything standing on it to arrive back where it started. + // + // So this makes a real change to the module's source and pushes it. It is a test that writes + // to a branch, which is worth knowing about; the alternative is a test that proves the loop by + // telling the mesh something untrue. + const checkout = resolve(catalogDir, ".."); + const marker = `// changed by the one-node test at build ${was.slice(7, 19)}\n`; + const file = resolve(catalogDir, MODULE.module, "index.ts"); + await must(CONTROL, `true`); // keep the shape uniform; the change is made on this workstation + appendFileSync(file, marker); + // Path-scoped: `commit -am` would sweep whatever else is in the working tree into a commit + // this test is about to push. + execFileSync("git", ["-C", checkout, "add", file], { stdio: "pipe" }); + execFileSync("git", ["-C", checkout, "commit", "-q", "-m", + `Move ${MODULE.module}'s source, so the mesh has something to notice`], { stdio: "pipe" }); + execFileSync("git", ["-C", checkout, "push", "-q", "origin", refFor(MODULE.repo)], + { stdio: "pipe" }); + const head = execFileSync("git", ["-C", checkout, "rev-parse", "HEAD"], + { encoding: "utf8" }).trim(); + + // Now the mesh is told. In life a push notices itself; what is under test here is what the + // mesh does NEXT, not how it hears. await mesh(`module moved ${MODULE.module} ${head}`); - const behind = await mesh(`status`); assert.match(behind, /behind|build --behind/, - `the mesh does not report a module behind its source:\n${behind}`); + `the source moved and the mesh does not report the module behind it:\n${behind}`); await mesh(`build --behind --wait 1200s`, 1_500_000); const rolled = await mesh(`upgrade ${MODULE.module} roll-out`, 900_000); - await waitForContainer(CONTROL, MODULE.module); + await waitForContainer(CONTROL, MODULE.container); const after = await mesh(`builds ${MODULE.module}`); - assert.match(after, /sha256:[0-9a-f]{64}/, `nothing was pinned after the rebuild:\n${after}`); + const now = after.match(/sha256:[0-9a-f]{64}/)?.[0] ?? ""; + assert.notEqual(now, was, + `the module was rebuilt and came back on the same artifact, so nothing reached the machine:` + + `\n${after}`); return `${behind}\n${rolled}\n${after}`; }); From 3ebf0bb38cf990cea996aa13a504d3ffccf07755 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 14 Sep 2026 23:44:24 +0200 Subject: [PATCH 15/26] Containers coming back is not the mesh coming back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E2 asserted that every container was running after a reboot and stopped there. The runtime restarts containers by itself; what makes a machine part of a mesh is an agent listening for what it should be. A machine whose containers returned and whose agent did not looks healthy and cannot be told anything. The installer is explicit that a host started the way the lab starts it does not survive a reboot, so this may now fail — and if it does, it is the packaging gap the design already records under what is not yet true, not a fault in the mesh. Better a named failure than a pass that means less than it appears to. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- one-node-mesh-report.json | 24 ++++++++++++------------ test/integration/one-node-mesh.test.ts | 13 ++++++++++++- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/one-node-mesh-report.json b/one-node-mesh-report.json index a759bdd..92f0fa8 100644 --- a/one-node-mesh-report.json +++ b/one-node-mesh-report.json @@ -1,13 +1,13 @@ { "scenario": "one-node-mesh", - "established": 18, + "established": 20, "of": 21, "steps": [ { "code": "R1", "title": "a bare machine becomes a mesh of one, raised by the installer", "status": "pass", - "seconds": 133, + "seconds": 145, "why": "" }, { @@ -56,14 +56,14 @@ "code": "P1", "title": "the mesh builds the shared base from source", "status": "pass", - "seconds": 83, + "seconds": 72, "why": "" }, { "code": "P2", "title": "the mesh builds and runs a store of its own", "status": "pass", - "seconds": 47, + "seconds": 43, "why": "" }, { @@ -77,7 +77,7 @@ "code": "P4", "title": "the mesh rebuilds its own control plane from source", "status": "pass", - "seconds": 30, + "seconds": 33, "why": "" }, { @@ -98,7 +98,7 @@ "code": "U1", "title": "the mesh builds a module standing on that base", "status": "pass", - "seconds": 23, + "seconds": 14, "why": "" }, { @@ -139,16 +139,16 @@ { "code": "E1", "title": "a change to a module's source reaches the machine on its own", - "status": "fail", - "seconds": 1, - "why": "the mesh does not report a module behind its source:\n1 machine(s), all doing what they were told, all heard from, running what the mesh would send them, and every module current with its source\n" + "status": "pass", + "seconds": 14, + "why": "" }, { "code": "E2", "title": "the mesh comes back after the machine reboots", - "status": "skip", - "seconds": 0, - "why": "not attempted — E1 (a change to a module's source reaches the machine on its own) did not succeed" + "status": "pass", + "seconds": 32, + "why": "" } ] } \ No newline at end of file diff --git a/test/integration/one-node-mesh.test.ts b/test/integration/one-node-mesh.test.ts index 9f511cf..a90fd19 100644 --- a/test/integration/one-node-mesh.test.ts +++ b/test/integration/one-node-mesh.test.ts @@ -901,7 +901,18 @@ before(async () => { const ps = (await on(CONTROL, `docker ps -a --format '{{.Names}}\t{{.Status}}'`)).out; assert.equal(missing.length, 0, `after a reboot these are not running: ${missing.join(", ")}\n\ncontainers:\n${ps}`); - return ps; + + // **Containers coming back is not the mesh coming back.** The runtime restarts containers on + // its own; what makes a machine part of a mesh is an agent listening for what it should be. A + // machine whose containers returned and whose agent did not looks healthy and cannot be told + // anything — and the installer is explicit that a host started the way the lab starts it, with + // --host-in-background, does not survive a reboot. So this asks, and a failure here is the + // packaging gap the design already records rather than a fault in the mesh. + const agent = (await on(CONTROL, `pgrep -af '[m]esh-host run' | head -3`)).out.trim(); + assert.ok(agent, + `every container came back and no host agent did, so the machine is running the right ` + + `things and can no longer be told anything:\n${ps}`); + return `${ps}\n${agent}`; }); stateOutcome(); From 1425f5e7d4027594975e818d23383d6c1e3289f7 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 15 Sep 2026 00:46:05 +0200 Subject: [PATCH 16/26] Verify every resource kind, not the one I kept looking at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every check in this file asked about containers. A container is one resource kind out of ten — directory, file, user, network, access, archive, service, package, container, action — and a module is far more often the others: the firewall is a package and a service, the mesh's names are a file, a run-once step is an action or a container that exits. Asking only about containers is how a module with no container at all went unnoticed. V4 takes the declaration the machine was actually sent and verifies each resource in it, by kind, on the machine. Nothing is hand-picked — whatever the installed modules declared is what gets checked. And it reports which kinds were never exercised, rather than counting their absence as success. A vocabulary this test never sees is a vocabulary this test says nothing about, and saying so is the difference between a passing run and a meaningful one. The service check accepts a one-shot that has done its work and reports inactive, which is the reading that made the firewall module look broken on every machine for months. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- test/integration/one-node-mesh.test.ts | 96 +++++++++++++++++++++++++- 1 file changed, 95 insertions(+), 1 deletion(-) diff --git a/test/integration/one-node-mesh.test.ts b/test/integration/one-node-mesh.test.ts index a90fd19..742bef2 100644 --- a/test/integration/one-node-mesh.test.ts +++ b/test/integration/one-node-mesh.test.ts @@ -132,6 +132,7 @@ const ANCHOR_RUNS = "the anchor runs the module the mesh built"; const DESCRIBES = "the control plane can describe the mesh, and what it says is true"; const CATALOGUED = "the catalogue holds every module this mesh built"; const NETWORK = "the machine's networking is what the modules asked for"; +const DECLARED = "every resource the mesh declared is true on the machine"; const FOLLOWS = "a change to a module's source reaches the machine on its own"; const SURVIVES = "the mesh comes back after the machine reboots"; const MODULE = { module: "amqp-ping", repo: "mesh-catalog", path: "modules/amqp-ping", container: "amqp-ping" }; @@ -394,6 +395,7 @@ const PLAN: { code: string; title: string }[] = [ { code: "V1", title: DESCRIBES }, { code: "V2", title: CATALOGUED }, { code: "V3", title: NETWORK }, + { code: "V4", title: DECLARED }, { code: "E1", title: FOLLOWS }, { code: "E2", title: SURVIVES }, ]; @@ -827,12 +829,103 @@ before(async () => { return said.join("\n"); }); + // ---- V4. THE WHOLE VOCABULARY, NOT THE PART I KEPT LOOKING AT -------------------------------- + // + // **Every check in this file until now asked about containers.** A container is one resource kind + // out of ten — directory, file, user, network, access, archive, service, package, container, + // action — and a module is far more often the others: the firewall is a package and a service, + // the mesh's names are a file, a run-once step is an action or a container that exits. Asking + // only about containers is how a module with no container at all went unnoticed for this long. + // + // So this takes the declaration the machine was actually sent and verifies each resource in it, + // by kind, on the machine. Nothing is hand-picked: whatever the installed modules declared is + // what gets checked, and a kind nothing declared is REPORTED as unexercised rather than quietly + // counted as working. + await step("V4", DECLARED, NETWORK, async () => { + const plan = JSON.parse(await mesh(`plan ${CONTROL} --json`)) as { + resources: Record[]; + }; + const seen = new Map(); + const wrong: string[] = []; + const unchecked: string[] = []; + + for (const r of plan.resources) { + const kind = String(r["type"] ?? ""); + seen.set(kind, (seen.get(kind) ?? 0) + 1); + const id = String(r["id"] ?? kind); + const check = async (command: string, why: string) => { + if (!(await on(CONTROL, command)).ok) wrong.push(`${kind} ${id}: ${why}`); + }; + switch (kind) { + case "directory": + await check(`test -d ${quote(String(r["path"]))}`, `no directory at ${r["path"]}`); + break; + case "file": + await check(`test -f ${quote(String(r["path"]))}`, `no file at ${r["path"]}`); + break; + case "access": + await check(`test -e ${quote(String(r["path"]))}`, `nothing at ${r["path"]}`); + break; + case "archive": + await check(`test -e ${quote(String(r["path"]))}`, + `nothing unpacked at ${r["path"]} — the archive was never fetched`); + break; + case "package": + await check(`command -v pacman >/dev/null && pacman -Q ${quote(String(r["package"]))}`, + `the package ${r["package"]} is not installed`); + break; + case "service": { + // A unit the host put into a state. "running" is the state worth checking; a one-shot + // that has done its work reports inactive and that is correct (this is the reading that + // made the firewall module appear broken on every machine for months). + const unit = String(r["unit"]); + if (String(r["state"]) === "running") { + await check(`systemctl is-active ${quote(unit)} >/dev/null || ` + + `systemctl show -p ExecMainStatus --value ${quote(unit)} | grep -qx 0`, + `the unit ${unit} is neither active nor a one-shot that succeeded`); + } + break; + } + case "network": + await check(`docker network inspect ${quote(String(r["name"]))} >/dev/null 2>&1`, + `no network named ${r["name"]}`); + break; + case "container": { + const name = String(r["name"]); + if (r["run-once"] === true || r["schedule"]) { + // Not expected to be running: it ran, or it runs later. What matters is that it exists + // and, if it ran, that it succeeded. + await check(`docker inspect ${quote(name)} >/dev/null 2>&1`, + `the container ${name} was never created`); + } else { + await check(`docker ps --format '{{.Names}}' | grep -qx ${quote(name)}`, + `the container ${name} is not running`); + } + break; + } + default: + unchecked.push(`${kind} ${id}`); + } + } + + const kinds = [...seen.entries()].sort().map(([k, n]) => `${k}×${n}`).join(" "); + const never = ["directory", "file", "user", "network", "access", "archive", "service", + "package", "container", "action"].filter((k) => !seen.has(k)); + assert.deepEqual(wrong, [], + `the machine is not what the mesh said it should be:\n ${wrong.join("\n ")}`); + return [ + ` declared ${plan.resources.length} resources — ${kinds}`, + unchecked.length ? ` not verified here ${unchecked.join(", ")}` : ` every kind present was verified`, + never.length ? ` NOT EXERCISED ${never.join(", ")} — nothing installed here declares one` : ``, + ].filter(Boolean).join("\n"); + }); + // ---- 11. A CHANGE REACHES THE MACHINE ON ITS OWN ---------------------------------------------- // // The whole point of the mesh, and the capability the migration depends on: move a module's // source and the running copy follows, with nobody driving the steps. Everything above is // machinery; this is what the machinery is for. - await step("E1", FOLLOWS, NETWORK, async () => { + await step("E1", FOLLOWS, DECLARED, async () => { const before = await mesh(`builds ${MODULE.module}`); const was = before.match(/sha256:[0-9a-f]{64}/)?.[0] ?? ""; assert.ok(was, `nothing is pinned to rebuild from:\n${before}`); @@ -947,6 +1040,7 @@ for (const name of [ DESCRIBES, CATALOGUED, NETWORK, + DECLARED, FOLLOWS, SURVIVES, ]) { From fce554d15022093ff59b230626833b1b77fca5aa Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 15 Sep 2026 01:35:42 +0200 Subject: [PATCH 17/26] A run-once step leaves nothing to ask, and V4 asked anyway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V4's first run reported a working mesh as broken: it asserted that lavinmq's run-once bootstrap container existed, and the host removes an exited run-once container on purpose — so a later apply is not confused by a stopped one, keeping the record that it ran in its own store instead. So the check asserted the opposite of correct behaviour. The step had run; it is why the broker came up configured. Counted as unverified now rather than assumed good. What would verify a step is the host's own record of having run it, and this walks the machine rather than the host — so the honest answer is that this check says nothing about steps, and it now says so. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- one-node-mesh-report.json | 41 +++++++++++++++----------- test/integration/one-node-mesh.test.ts | 14 ++++++--- 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/one-node-mesh-report.json b/one-node-mesh-report.json index 92f0fa8..b69a603 100644 --- a/one-node-mesh-report.json +++ b/one-node-mesh-report.json @@ -1,13 +1,13 @@ { "scenario": "one-node-mesh", - "established": 20, - "of": 21, + "established": 19, + "of": 22, "steps": [ { "code": "R1", "title": "a bare machine becomes a mesh of one, raised by the installer", "status": "pass", - "seconds": 145, + "seconds": 140, "why": "" }, { @@ -56,21 +56,21 @@ "code": "P1", "title": "the mesh builds the shared base from source", "status": "pass", - "seconds": 72, + "seconds": 87, "why": "" }, { "code": "P2", "title": "the mesh builds and runs a store of its own", "status": "pass", - "seconds": 43, + "seconds": 39, "why": "" }, { "code": "P3", "title": "the mesh builds and runs its own catalogue", "status": "pass", - "seconds": 26, + "seconds": 32, "why": "" }, { @@ -105,14 +105,14 @@ "code": "U2", "title": "the mesh runs a broker for that module to talk to", "status": "pass", - "seconds": 20, + "seconds": 31, "why": "" }, { "code": "U3", "title": "the anchor runs the module the mesh built", "status": "pass", - "seconds": 6, + "seconds": 7, "why": "" }, { @@ -125,9 +125,9 @@ { "code": "V2", "title": "the catalogue holds every module this mesh built", - "status": "fail", - "seconds": 1, - "why": "the catalogue does not hold mesh-tools, postgres — the mesh built them and its own record has no trace of it (novox/hq issue 050):\n{\"modules\":[{\"module\":\"amqp-ping\",\"commit\":\"e0c92195d4240841bfcf4b4a9ef869d5afeca331\",\"repository\":\"https://git.novox.be/novox/mesh-catalog.git\",\"path\":\"modules/amqp-ping\"},{\"module\":\"lavinmq\",\"commit\":\"e0c92195d4240841bfcf4b4a9ef869d5afeca331\",\"repository\":\"https://git.novox.be/novox/mesh-catalog.git\",\"path\":\"modules/lavinmq\"},{\"module\":\"mesh-control\",\"commit\":\"5062c36fc9efe159aa9706c0ca2c873351ef1ce0\",\"repository\":\"https://git.novox.be/novox/mesh-control.git\",\"path\":\"\"}]}\n\n+ actual - expected\n\n+ [\n+ 'mesh-tools',\n+ 'postgres'\n+ ]\n- []\n" + "status": "pass", + "seconds": 3, + "why": "" }, { "code": "V3", @@ -136,19 +136,26 @@ "seconds": 1, "why": "" }, + { + "code": "V4", + "title": "every resource the mesh declared is true on the machine", + "status": "fail", + "seconds": 13, + "why": "the machine is not what the mesh said it should be:\n container lavinmq.bootstrap: the container lavinmq-bootstrap was never created\n+ actual - expected\n\n+ [\n+ 'container lavinmq.bootstrap: the container lavinmq-bootstrap was never created'\n+ ]\n- []\n" + }, { "code": "E1", "title": "a change to a module's source reaches the machine on its own", - "status": "pass", - "seconds": 14, - "why": "" + "status": "skip", + "seconds": 0, + "why": "not attempted — V4 (every resource the mesh declared is true on the machine) did not succeed" }, { "code": "E2", "title": "the mesh comes back after the machine reboots", - "status": "pass", - "seconds": 32, - "why": "" + "status": "skip", + "seconds": 0, + "why": "not attempted — E1 (a change to a module's source reaches the machine on its own) did not succeed" } ] } \ No newline at end of file diff --git a/test/integration/one-node-mesh.test.ts b/test/integration/one-node-mesh.test.ts index 742bef2..bcbc67a 100644 --- a/test/integration/one-node-mesh.test.ts +++ b/test/integration/one-node-mesh.test.ts @@ -893,10 +893,16 @@ before(async () => { case "container": { const name = String(r["name"]); if (r["run-once"] === true || r["schedule"]) { - // Not expected to be running: it ran, or it runs later. What matters is that it exists - // and, if it ran, that it succeeded. - await check(`docker inspect ${quote(name)} >/dev/null 2>&1`, - `the container ${name} was never created`); + // **A run-once step leaves nothing to ask, deliberately.** The host removes the exited + // container so a later apply is not confused by a stopped one, and keeps the record + // that it ran in its own store instead. So asserting the container exists asserts the + // opposite of correct behaviour — which this did, and reported a working mesh as + // broken on its first run. + // + // A scheduled step is the same between fires. Both are counted as unverified here + // rather than assumed good: what would verify them is the host's own record, and this + // asks the machine rather than the host. + unchecked.push(`${kind} ${id} (a step leaves nothing running to ask)`); } else { await check(`docker ps --format '{{.Names}}' | grep -qx ${quote(name)}`, `the container ${name} is not running`); From e451a9191a637f26a82b6f9177012391a404ee06 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 15 Sep 2026 20:51:00 +0200 Subject: [PATCH 18/26] The lab names the modules as the catalogue does Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- one-node-mesh-report.json | 34 +++++++++++++------------- test/integration/genesis.ts | 2 +- test/integration/one-node-mesh.test.ts | 17 ++++++++----- 3 files changed, 29 insertions(+), 24 deletions(-) diff --git a/one-node-mesh-report.json b/one-node-mesh-report.json index b69a603..1fbb267 100644 --- a/one-node-mesh-report.json +++ b/one-node-mesh-report.json @@ -1,13 +1,13 @@ { "scenario": "one-node-mesh", - "established": 19, + "established": 21, "of": 22, "steps": [ { "code": "R1", "title": "a bare machine becomes a mesh of one, raised by the installer", "status": "pass", - "seconds": 140, + "seconds": 141, "why": "" }, { @@ -56,21 +56,21 @@ "code": "P1", "title": "the mesh builds the shared base from source", "status": "pass", - "seconds": 87, + "seconds": 77, "why": "" }, { "code": "P2", "title": "the mesh builds and runs a store of its own", "status": "pass", - "seconds": 39, + "seconds": 49, "why": "" }, { "code": "P3", "title": "the mesh builds and runs its own catalogue", "status": "pass", - "seconds": 32, + "seconds": 26, "why": "" }, { @@ -84,7 +84,7 @@ "code": "N1", "title": "the mesh puts itself on a private network, and its machine has a name", "status": "pass", - "seconds": 7, + "seconds": 12, "why": "" }, { @@ -105,14 +105,14 @@ "code": "U2", "title": "the mesh runs a broker for that module to talk to", "status": "pass", - "seconds": 31, + "seconds": 20, "why": "" }, { "code": "U3", "title": "the anchor runs the module the mesh built", "status": "pass", - "seconds": 7, + "seconds": 6, "why": "" }, { @@ -139,23 +139,23 @@ { "code": "V4", "title": "every resource the mesh declared is true on the machine", - "status": "fail", - "seconds": 13, - "why": "the machine is not what the mesh said it should be:\n container lavinmq.bootstrap: the container lavinmq-bootstrap was never created\n+ actual - expected\n\n+ [\n+ 'container lavinmq.bootstrap: the container lavinmq-bootstrap was never created'\n+ ]\n- []\n" + "status": "pass", + "seconds": 12, + "why": "" }, { "code": "E1", "title": "a change to a module's source reaches the machine on its own", - "status": "skip", - "seconds": 0, - "why": "not attempted — V4 (every resource the mesh declared is true on the machine) did not succeed" + "status": "pass", + "seconds": 14, + "why": "" }, { "code": "E2", "title": "the mesh comes back after the machine reboots", - "status": "skip", - "seconds": 0, - "why": "not attempted — E1 (a change to a module's source reaches the machine on its own) did not succeed" + "status": "fail", + "seconds": 30, + "why": "every container came back and no host agent did, so the machine is running the right things and can no longer be told anything:\namqp-ping\tUp 3 seconds\nmesh-lavinmq\tUp 3 seconds\nlavinmq\tUp 3 seconds (health: starting)\nmesh-control\tUp 3 seconds\nmesh-catalog\tUp 3 seconds\nmesh-postgres\tUp 3 seconds\npostgres\tUp 3 seconds\nmesh-builder\tUp 3 seconds\nmesh-registry\tUp 3 seconds\nmesh-broker\tUp 4 seconds (health: starting)\nmesh-store\tUp 4 seconds\n" } ] } \ No newline at end of file diff --git a/test/integration/genesis.ts b/test/integration/genesis.ts index 44c988c..0f80175 100644 --- a/test/integration/genesis.ts +++ b/test/integration/genesis.ts @@ -64,7 +64,7 @@ export function stepIn(said: string): string { export async function genesis(o: GenesisOptions): Promise { const node = o.node; const registry = o.registry ?? "127.0.0.1:5000"; - const modules = o.catalogueModules ?? ["registry", "mesh-control", "builder"]; + const modules = o.catalogueModules ?? ["distribution", "mesh-control", "builder"]; const catalogueOnMachine = o.catalogueOnMachine ?? "/opt/mesh-catalog"; const log = o.log ?? (() => {}); diff --git a/test/integration/one-node-mesh.test.ts b/test/integration/one-node-mesh.test.ts index bcbc67a..bfa6130 100644 --- a/test/integration/one-node-mesh.test.ts +++ b/test/integration/one-node-mesh.test.ts @@ -94,7 +94,7 @@ const NETWORK_MODULE = "networking"; * claims a seat (`the-packet-filter`) because a machine has one of these and two things writing * rules is a coin toss about which survives. */ -const FILTER_MODULE = "firewall"; +const FILTER_MODULE = "nftables"; const STORE = { module: "postgres", repo: "mesh-catalog", path: "modules/postgres", container: "mesh-postgres" }; const CATALOGUE = { module: "mesh-catalog", repo: "mesh-catalog", path: "modules/mesh-catalog", container: "mesh-catalog" }; /** The control plane, rebuilt from its own repository — the step that ends the installer's tenure. */ @@ -108,7 +108,7 @@ const CONTROL_PLANE = { module: "mesh-control", repo: "mesh-control", path: "", * the registry and the builder — are here too: they are carried in, and a mesh missing any of them * is not one. */ -const MUST_HOLD = ["mesh-control", "registry", "builder", "mesh-tools", "postgres", +const MUST_HOLD = ["mesh-control", "distribution", "builder", "mesh-tools", "postgres", "mesh-catalog", "lavinmq", "amqp-ping"]; const MUST_RUN = ["mesh-control", "mesh-registry", "mesh-broker", "mesh-store", "mesh-postgres", "mesh-catalog", "mesh-lavinmq", "amqp-ping"]; @@ -779,7 +779,7 @@ before(async () => { // does. novox/hq issue 050 — on a fresh mesh the modules built before the catalogue existed // are exactly the ones it needed in order to exist, so the hole is always the foundation. const missing = MUST_HOLD.filter((m) => - !["registry", "builder"].includes(m) && !held.includes(m)); + !["distribution", "builder"].includes(m) && !held.includes(m)); assert.deepEqual(missing, [], `the catalogue does not hold ${missing.join(", ")} — the mesh built them and its own ` + `record has no trace of it (novox/hq issue 050):\n${held}`); @@ -1004,9 +1004,14 @@ before(async () => { // **Containers coming back is not the mesh coming back.** The runtime restarts containers on // its own; what makes a machine part of a mesh is an agent listening for what it should be. A // machine whose containers returned and whose agent did not looks healthy and cannot be told - // anything — and the installer is explicit that a host started the way the lab starts it, with - // --host-in-background, does not survive a reboot. So this asks, and a failure here is the - // packaging gap the design already records rather than a fault in the mesh. + // anything. + // + // **A failure here is this lab's arrangement, not the mesh's capability.** mesh-host ships + // `nox-mesh-host.service` and two companions in packaging/; the installer declines to place + // them because a unit file is a packaging decision, and the lab starts the host with + // --host-in-background, which says in its own help that it does not survive a reboot. So this + // check is honest and the thing it catches is the step nobody runs — not a mesh that cannot + // come back. const agent = (await on(CONTROL, `pgrep -af '[m]esh-host run' | head -3`)).out.trim(); assert.ok(agent, `every container came back and no host agent did, so the machine is running the right ` + From 09a22de78fa1eee57c84904d95e9aecdabc361d6 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 15 Sep 2026 21:57:26 +0200 Subject: [PATCH 19/26] The lab answers the installer's questions the unattended way MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The installer asks where a human must choose, and this bed has no human — so every choice arrives as a flag, and a required choice with no flag is the installer refusing, which is the behaviour rather than a lab problem. Both choices have one option today, so the flags are redundant on purpose: the day a second filter exists this bed keeps working instead of refusing, and choosing becomes a thing it visibly does. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- one-node-mesh-report.json | 24 ++++++++++++------------ test/integration/fresh-mesh.test.ts | 3 +++ test/integration/genesis-single.test.ts | 3 +++ test/integration/genesis.ts | 20 ++++++++++++++++++++ test/integration/one-node-mesh.test.ts | 3 +++ 5 files changed, 41 insertions(+), 12 deletions(-) diff --git a/one-node-mesh-report.json b/one-node-mesh-report.json index 1fbb267..f8756e9 100644 --- a/one-node-mesh-report.json +++ b/one-node-mesh-report.json @@ -7,7 +7,7 @@ "code": "R1", "title": "a bare machine becomes a mesh of one, raised by the installer", "status": "pass", - "seconds": 141, + "seconds": 122, "why": "" }, { @@ -56,35 +56,35 @@ "code": "P1", "title": "the mesh builds the shared base from source", "status": "pass", - "seconds": 77, + "seconds": 85, "why": "" }, { "code": "P2", "title": "the mesh builds and runs a store of its own", "status": "pass", - "seconds": 49, + "seconds": 38, "why": "" }, { "code": "P3", "title": "the mesh builds and runs its own catalogue", "status": "pass", - "seconds": 26, + "seconds": 31, "why": "" }, { "code": "P4", "title": "the mesh rebuilds its own control plane from source", "status": "pass", - "seconds": 33, + "seconds": 32, "why": "" }, { "code": "N1", "title": "the mesh puts itself on a private network, and its machine has a name", "status": "pass", - "seconds": 12, + "seconds": 6, "why": "" }, { @@ -98,14 +98,14 @@ "code": "U1", "title": "the mesh builds a module standing on that base", "status": "pass", - "seconds": 14, + "seconds": 13, "why": "" }, { "code": "U2", "title": "the mesh runs a broker for that module to talk to", "status": "pass", - "seconds": 20, + "seconds": 24, "why": "" }, { @@ -140,22 +140,22 @@ "code": "V4", "title": "every resource the mesh declared is true on the machine", "status": "pass", - "seconds": 12, + "seconds": 11, "why": "" }, { "code": "E1", "title": "a change to a module's source reaches the machine on its own", "status": "pass", - "seconds": 14, + "seconds": 13, "why": "" }, { "code": "E2", "title": "the mesh comes back after the machine reboots", "status": "fail", - "seconds": 30, - "why": "every container came back and no host agent did, so the machine is running the right things and can no longer be told anything:\namqp-ping\tUp 3 seconds\nmesh-lavinmq\tUp 3 seconds\nlavinmq\tUp 3 seconds (health: starting)\nmesh-control\tUp 3 seconds\nmesh-catalog\tUp 3 seconds\nmesh-postgres\tUp 3 seconds\npostgres\tUp 3 seconds\nmesh-builder\tUp 3 seconds\nmesh-registry\tUp 3 seconds\nmesh-broker\tUp 4 seconds (health: starting)\nmesh-store\tUp 4 seconds\n" + "seconds": 31, + "why": "every container came back and no host agent did, so the machine is running the right things and can no longer be told anything:\namqp-ping\tUp 3 seconds\nmesh-lavinmq\tUp 3 seconds\nlavinmq\tUp 4 seconds (health: starting)\nmesh-control\tUp 3 seconds\nmesh-catalog\tUp 1 second\nmesh-postgres\tUp 4 seconds\npostgres\tUp 3 seconds\nmesh-builder\tUp 2 seconds\nmesh-registry\tUp 3 seconds\nmesh-broker\tUp 3 seconds (health: starting)\nmesh-store\tUp 3 seconds\n" } ] } \ No newline at end of file diff --git a/test/integration/fresh-mesh.test.ts b/test/integration/fresh-mesh.test.ts index 4f30d54..6e940f9 100644 --- a/test/integration/fresh-mesh.test.ts +++ b/test/integration/fresh-mesh.test.ts @@ -329,6 +329,9 @@ before(async () => { registry: REGISTRY, source, sourceRef, + toolsSource: forgeUrl(BASE.repo), + catalogSource: forgeUrl(MODULE.repo), + catalogRef: refFor(MODULE.repo), log: (m) => console.log(m), }); } catch (err) { diff --git a/test/integration/genesis-single.test.ts b/test/integration/genesis-single.test.ts index 7d02ff1..42dc73a 100644 --- a/test/integration/genesis-single.test.ts +++ b/test/integration/genesis-single.test.ts @@ -81,6 +81,9 @@ before(async () => { bundleTemplate: substrateBundle(bundle, []), source, sourceRef, + // Phase two builds from the same forge; the repositories sit beside the control plane's. + toolsSource: source.replace(/[^/]+\.git$/, "mesh-tools.git"), + catalogSource: source.replace(/[^/]+\.git$/, "mesh-catalog.git"), log: (m) => console.log(m), }); } catch (err) { diff --git a/test/integration/genesis.ts b/test/integration/genesis.ts index 0f80175..0f25fba 100644 --- a/test/integration/genesis.ts +++ b/test/integration/genesis.ts @@ -53,6 +53,18 @@ export interface GenesisOptions { */ source: string; sourceRef: string; + /** + * Phase two: where the shared base and the catalogue's modules are built from. + * + * The installer no longer stops at a mesh that runs — it builds the base, a store, the + * catalogue, chooses the network and the filter, and asks a human where one must choose. This + * bed has no human, so every choice arrives as a flag, and a required choice with no flag is + * the installer refusing — which is the behaviour, not a lab problem. + */ + toolsSource: string; + catalogSource: string; + /** What of the catalogue to build. A branch under test is the usual reason this is not main. */ + catalogRef?: string; log?: (m: string) => void; } @@ -118,6 +130,14 @@ export async function genesis(o: GenesisOptions): Promise { `--catalog ${catalogueOnMachine}`, `--node ${node}`, `--registry ${registry}`, + `--tools-source ${o.toolsSource}`, + `--catalog-source ${o.catalogSource}`, + `--catalog-ref ${o.catalogRef ?? "main"}`, + // The choices, answered the unattended way. Both have one option today, so these are + // redundant on purpose: the day a second filter exists, this bed keeps working instead of + // refusing, and choosing becomes a thing it visibly does. + `--private-network wireguard`, + `--packet-filter nftables`, `--host ${HOST_PATH}`, // The lab has no unit to supervise the host with, and the installer refuses to invent one — a // unit file is a packaging decision. A host started this way does not survive a reboot. diff --git a/test/integration/one-node-mesh.test.ts b/test/integration/one-node-mesh.test.ts index bfa6130..c19beb0 100644 --- a/test/integration/one-node-mesh.test.ts +++ b/test/integration/one-node-mesh.test.ts @@ -467,6 +467,9 @@ before(async () => { registry: REGISTRY, source, sourceRef, + toolsSource: forgeUrl(BASE.repo), + catalogSource: forgeUrl(MODULE.repo), + catalogRef: refFor(MODULE.repo), log: (m) => console.log(m), }); } catch (err) { From 61abeb7f6b07ba15b8231e3d897f3e58816d48f4 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 16 Sep 2026 01:01:19 +0200 Subject: [PATCH 20/26] The lab stocks the full catalogue, not the bootstrap three MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase two of the installer reads each module's manifest from --catalog, which is documented as a checkout of the catalogue repository. Genesis stocked it with only the three modules the pivot needs, so step 14 failed reading postgres's manifest — a file nobody had put there. The installer code is right: --catalog is meant to be a full checkout. The lab was the shortcut. It now copies the whole modules tree once (tar, push, extract) rather than three files, and still checks the bootstrap three are present so a missing one fails at preparation rather than at step 8. Production's equivalent is an operator with a full checkout, or the installer cloning the repo. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- test/integration/genesis.ts | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/test/integration/genesis.ts b/test/integration/genesis.ts index 0f25fba..191101e 100644 --- a/test/integration/genesis.ts +++ b/test/integration/genesis.ts @@ -12,6 +12,7 @@ * description of genesis, and both the single-node bed and the four-node bed call it. */ import { existsSync, writeFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { exec, instanceNameOf, push } from "../../src/lifecycle/operate.ts"; @@ -110,13 +111,27 @@ export async function genesis(o: GenesisOptions): Promise { // The catalogue. `mesh-bootstrap --catalog` reads manifests from a CHECKOUT on the machine, // because at this moment the mesh has no forge, no build machine and — until the registry step // finishes — no registry. A manifest is a file, and somebody has to have put it there. - await must(`mkdir -p ${modules.map((m) => `${catalogueOnMachine}/modules/${m}`).join(" ")}`); + // + // **The WHOLE checkout, not the three genesis names.** `--catalog` is documented as a checkout + // of the catalogue repository, and phase two reads more from it than genesis does — postgres, + // mesh-catalog, the packet filter, whatever extras. Stocking only the bootstrap three left + // phase two unable to read a manifest that was never put there (novox/hq installer step 14). The + // production equivalent is an operator with a full checkout, or the installer cloning the repo; + // the lab stands in for that by copying the whole tree once. + const bundleTar = join(tmpdir(), `mesh-lab-catalogue-${process.pid}-${node}.tar`); + execFileSync("tar", ["-cf", bundleTar, "-C", o.catalogDir, "."]); + await must(`mkdir -p ${catalogueOnMachine}/modules`); + await push(o.instanceId, node, bundleTar, "/tmp/catalogue.tar"); + await must(`tar -xf /tmp/catalogue.tar -C ${catalogueOnMachine}/modules`); + // The three genesis itself needs must be present, or the pivot cannot even begin — checked here + // rather than discovered at step 8, where the message is about a missing file. for (const module of modules) { - const from = resolve(o.catalogDir, module, "module.json"); - if (!existsSync(from)) return stop("preparing the catalogue", `the catalogue has no ${module}/module.json at ${from}`); - await push(o.instanceId, node, from, `${catalogueOnMachine}/modules/${module}/module.json`); + const at = `${catalogueOnMachine}/modules/${module}/module.json`; + if (!(await on(`test -f ${at}`)).ok) { + return stop("preparing the catalogue", `the catalogue has no ${module}/module.json`); + } } - report.push(` catalogue ${modules.join(", ")} at ${catalogueOnMachine}`); + report.push(` catalogue full checkout at ${catalogueOnMachine} (${modules.join(", ")} + phase two)`); const local = join(tmpdir(), `mesh-lab-substrate-${process.pid}-${node}.lock`); writeFileSync(local, o.bundleTemplate); From eff91742e8784075a2e3c161c4f031acd55f4d68 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 16 Sep 2026 10:27:37 +0200 Subject: [PATCH 21/26] The bed publishes the SDK before the base build genesis passes --sdk-source so the installer raises the registry and publishes the SDK ahead of the base, which now resolves it by version. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- test/integration/genesis.ts | 5 +++++ test/integration/one-node-mesh.test.ts | 2 ++ 2 files changed, 7 insertions(+) diff --git a/test/integration/genesis.ts b/test/integration/genesis.ts index 191101e..4775048 100644 --- a/test/integration/genesis.ts +++ b/test/integration/genesis.ts @@ -64,6 +64,9 @@ export interface GenesisOptions { */ toolsSource: string; catalogSource: string; + /** Where the shared library is built from — published before the base resolves it (ADR 0076). */ + sdkSource: string; + sdkRef?: string; /** What of the catalogue to build. A branch under test is the usual reason this is not main. */ catalogRef?: string; log?: (m: string) => void; @@ -148,6 +151,8 @@ export async function genesis(o: GenesisOptions): Promise { `--tools-source ${o.toolsSource}`, `--catalog-source ${o.catalogSource}`, `--catalog-ref ${o.catalogRef ?? "main"}`, + `--sdk-source ${o.sdkSource}`, + `--sdk-ref ${o.sdkRef ?? "main"}`, // The choices, answered the unattended way. Both have one option today, so these are // redundant on purpose: the day a second filter exists, this bed keeps working instead of // refusing, and choosing becomes a thing it visibly does. diff --git a/test/integration/one-node-mesh.test.ts b/test/integration/one-node-mesh.test.ts index c19beb0..07d962c 100644 --- a/test/integration/one-node-mesh.test.ts +++ b/test/integration/one-node-mesh.test.ts @@ -470,6 +470,8 @@ before(async () => { toolsSource: forgeUrl(BASE.repo), catalogSource: forgeUrl(MODULE.repo), catalogRef: refFor(MODULE.repo), + sdkSource: forgeUrl("mesh-sdk"), + sdkRef: refFor("mesh-sdk"), log: (m) => console.log(m), }); } catch (err) { From f57e05e75e7d1bdda1137a3e1445fa74e16a59ed Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 16 Sep 2026 14:10:26 +0200 Subject: [PATCH 22/26] The one-node bed places at the site it operates, and tolerates the CP recreating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Genesis places the anchor at the same site the test re-places it at, so that step is a no-op rather than a change that recreates the control plane. And mesh() — which runs commands inside the control-plane container — retries a transient "container not running", because the control plane is a live mesh-managed container the mesh recreates when its declaration changes (e.g. its first .internal add-host). Also passes --sdk-source/--tools-ref for the SDK build. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- test/integration/genesis.ts | 7 +++++++ test/integration/one-node-mesh.test.ts | 22 +++++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/test/integration/genesis.ts b/test/integration/genesis.ts index 4775048..7a0be91 100644 --- a/test/integration/genesis.ts +++ b/test/integration/genesis.ts @@ -67,6 +67,11 @@ export interface GenesisOptions { /** Where the shared library is built from — published before the base resolves it (ADR 0076). */ sdkSource: string; sdkRef?: string; + /** What of the base repo to build. Defaults to main; a feature branch under test overrides it. */ + toolsRef?: string; + /** The site the anchor is placed at on the private network. Must match how the operator/test + * places it afterwards, or the first re-place changes the overlay and recreates the control plane. */ + site?: string; /** What of the catalogue to build. A branch under test is the usual reason this is not main. */ catalogRef?: string; log?: (m: string) => void; @@ -149,6 +154,7 @@ export async function genesis(o: GenesisOptions): Promise { `--node ${node}`, `--registry ${registry}`, `--tools-source ${o.toolsSource}`, + `--tools-ref ${o.toolsRef ?? "main"}`, `--catalog-source ${o.catalogSource}`, `--catalog-ref ${o.catalogRef ?? "main"}`, `--sdk-source ${o.sdkSource}`, @@ -156,6 +162,7 @@ export async function genesis(o: GenesisOptions): Promise { // The choices, answered the unattended way. Both have one option today, so these are // redundant on purpose: the day a second filter exists, this bed keeps working instead of // refusing, and choosing becomes a thing it visibly does. + `--site ${o.site ?? "main"}`, `--private-network wireguard`, `--packet-filter nftables`, `--host ${HOST_PATH}`, diff --git a/test/integration/one-node-mesh.test.ts b/test/integration/one-node-mesh.test.ts index 07d962c..8619c08 100644 --- a/test/integration/one-node-mesh.test.ts +++ b/test/integration/one-node-mesh.test.ts @@ -217,7 +217,23 @@ async function must(machine: string, command: string, timeoutMs?: number): Promi return out; } async function mesh(command: string, timeoutMs?: number): Promise { - return must(CONTROL, `docker exec mesh-control /mesh-control ${command}`, timeoutMs); + // The control plane is a live, mesh-MANAGED container: the mesh recreates it whenever its + // declaration changes — most visibly when the machine first gets its `.internal` name on the + // private network, which becomes the container's `--add-host` (containers are immutable, so a new + // spec is a new container). A `docker exec mesh-control` that lands in that brief recreate window + // fails with "container ... is not running". That is not the mesh being wrong — it is a command + // racing a legitimate restart — so it is retried until the control plane answers again. A real + // command failure (anything else) still throws at once. + const deadline = Date.now() + (timeoutMs ?? 120_000); + for (;;) { + const { out, ok } = await on(CONTROL, `docker exec mesh-control /mesh-control ${command}`, timeoutMs); + if (ok) return out; + if (/is not running|No such container/i.test(out) && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 2_000)); + continue; + } + throw new Error(`${CONTROL}: docker exec mesh-control /mesh-control ${command}\n${out}`); + } } /** @@ -468,10 +484,14 @@ before(async () => { source, sourceRef, toolsSource: forgeUrl(BASE.repo), + toolsRef: refFor(BASE.repo), catalogSource: forgeUrl(MODULE.repo), catalogRef: refFor(MODULE.repo), sdkSource: forgeUrl("mesh-sdk"), sdkRef: refFor("mesh-sdk"), + // The same site N1 re-places the anchor at, so N1 is a no-op and the control plane is not + // recreated mid-test (its --add-host would otherwise change). + site: "hosting", log: (m) => console.log(m), }); } catch (err) { From 49b80d8516caefa3079941073097d3a721124ba7 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 16 Sep 2026 16:20:44 +0200 Subject: [PATCH 23/26] The one-node bed supervises the host as a service, so reboot is a real check Installs the shipped nox-mesh-host launcher and unit in the machine and lets the installer's --host-service start and enable it, instead of --host-in-background which cannot survive a reboot. E2 now proves the mesh comes back on its own. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- src/lifecycle/place.ts | 23 +++++++++++++++++++++++ test/integration/genesis.ts | 24 ++++++++++++++++++++---- test/integration/one-node-mesh.test.ts | 3 +++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/lifecycle/place.ts b/src/lifecycle/place.ts index 8f2a785..90076de 100644 --- a/src/lifecycle/place.ts +++ b/src/lifecycle/place.ts @@ -626,3 +626,26 @@ export async function loadHeldImages( return held; } + +/** + * Install the host's systemd packaging, so the installer supervises it as a service rather than a + * background process — the path a real machine takes, and the only one that survives a reboot. The + * lab places the binary at HOST_PATH; the shipped launcher runs $MESH_HOST_BIN (default + * /usr/bin/nox-mesh-host), so a symlink points that at the binary rather than editing the packaged + * unit. The installer's --host-service then starts AND enables it (novox/hq ADR 0005). + */ +export async function installHostService( + instanceName: string, + machine: string, + packagingDir: string, + logMessage: (message: string) => void = () => {}, +): Promise { + const launcher = join(packagingDir, "nox-mesh-host-launch"); + const unit = join(packagingDir, "nox-mesh-host.service"); + await incus(["exec", instanceName, "--", "mkdir", "-p", "/usr/lib/nox-mesh-host"], 60_000); + await incus(["file", "push", launcher, `${instanceName}/usr/lib/nox-mesh-host/launch`, "--mode", "0755"], 120_000); + await incus(["file", "push", unit, `${instanceName}/etc/systemd/system/nox-mesh-host.service`, "--mode", "0644"], 120_000); + await incus(["exec", instanceName, "--", "ln", "-sf", HOST_PATH, "/usr/bin/nox-mesh-host"], 60_000); + await incus(["exec", instanceName, "--", "systemctl", "daemon-reload"], 60_000); + logMessage(`installed nox-mesh-host.service on ${machine}`); +} diff --git a/test/integration/genesis.ts b/test/integration/genesis.ts index 7a0be91..64222cb 100644 --- a/test/integration/genesis.ts +++ b/test/integration/genesis.ts @@ -16,7 +16,8 @@ import { execFileSync } from "node:child_process"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { exec, instanceNameOf, push } from "../../src/lifecycle/operate.ts"; -import { placeBootstrap, BOOTSTRAP_PATH, HOST_PATH } from "../../src/lifecycle/place.ts"; +import { placeBootstrap, installHostService, BOOTSTRAP_PATH, HOST_PATH } from "../../src/lifecycle/place.ts"; +import { dirname } from "node:path"; /** What genesis did, or where it stopped. */ export interface GenesisResult { @@ -72,6 +73,11 @@ export interface GenesisOptions { /** The site the anchor is placed at on the private network. Must match how the operator/test * places it afterwards, or the first re-place changes the overlay and recreates the control plane. */ site?: string; + /** Supervise the host as a systemd service (the real install path) instead of --host-in-background, + * so it survives a reboot. Needs hostBinary to locate the packaging. */ + hostService?: boolean; + /** The built mesh-host binary on this workstation; its repo's packaging/ dir supplies the unit. */ + hostBinary?: string; /** What of the catalogue to build. A branch under test is the usual reason this is not main. */ catalogRef?: string; log?: (m: string) => void; @@ -145,6 +151,16 @@ export async function genesis(o: GenesisOptions): Promise { writeFileSync(local, o.bundleTemplate); await push(o.instanceId, node, local, "/tmp/substrate-template.lock"); + // Supervise the host as a service (the real install path) when asked — the only way it survives a + // reboot. Installs the shipped packaging in the machine, then lets --host-service start+enable it. + if (o.hostService) { + if (!o.hostBinary) { + return stop("preparing the host service", "hostService needs hostBinary to find the packaging"); + } + await installHostService(name, node, join(dirname(o.hostBinary), "packaging"), (m) => log(`genesis:${m}`)); + report.push(` host supervised by nox-mesh-host.service`); + } + const command = [ BOOTSTRAP_PATH, `--source ${o.source}`, @@ -166,9 +182,9 @@ export async function genesis(o: GenesisOptions): Promise { `--private-network wireguard`, `--packet-filter nftables`, `--host ${HOST_PATH}`, - // The lab has no unit to supervise the host with, and the installer refuses to invent one — a - // unit file is a packaging decision. A host started this way does not survive a reboot. - `--host-in-background`, + // A service when the packaging was installed above (survives a reboot); otherwise the + // background process, which does not — the installer refuses to invent a unit either way. + ...(o.hostService ? [] as string[] : [`--host-in-background`]), ].join(" "); // Run up to three times. Not to paper over a failure — every attempt's failing step is printed — diff --git a/test/integration/one-node-mesh.test.ts b/test/integration/one-node-mesh.test.ts index 8619c08..4f17c66 100644 --- a/test/integration/one-node-mesh.test.ts +++ b/test/integration/one-node-mesh.test.ts @@ -492,6 +492,9 @@ before(async () => { // The same site N1 re-places the anchor at, so N1 is a no-op and the control plane is not // recreated mid-test (its --add-host would otherwise change). site: "hosting", + // Supervise the host as a service so it survives the reboot check (E2). + hostService: true, + hostBinary: binary, log: (m) => console.log(m), }); } catch (err) { From 5d6e8fbe7aab2f12ee8ce3fe0aae663abfc10e65 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 16 Sep 2026 18:40:40 +0200 Subject: [PATCH 24/26] Rename mesh-control -> mesh-controller, substrate -> foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One name per thing, per the HQ glossary: the module/container/image/binary/repo becomes mesh-controller, the seat the-controller, and the store+broker pair the foundation (embedded base bundles, default template and example lock renamed with their go:embed directives). No behaviour change — a pure vocabulary rename. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- README.md | 14 +-- one-node-mesh-report.json | 34 +++--- provisioners/README.md | 2 +- scenarios/anthropic-bed.yml | 6 +- scenarios/audit-node.yml | 4 +- scenarios/catalogue-apps.yml | 8 +- scenarios/catalogue-media.yml | 6 +- scenarios/catalogue-mqtt.yml | 4 +- scenarios/catalogue-small.yml | 8 +- scenarios/first-node.yml | 4 +- scenarios/fresh-mesh.yml | 8 +- scenarios/genesis-single.yml | 10 +- scenarios/grafana-node.yml | 2 +- scenarios/growing-mesh.yml | 2 +- scenarios/lavinmq-bed.yml | 16 +-- scenarios/local-model-bed.yml | 6 +- scenarios/minio-node.yml | 2 +- scenarios/model-usage-bed.yml | 16 +-- scenarios/one-node-mesh.yml | 10 +- scenarios/openai-bed.yml | 4 +- scenarios/plex-node.yml | 4 +- scenarios/postgres-node.yml | 2 +- scenarios/redis-node.yml | 2 +- scenarios/route-forwarding.yml | 8 +- scenarios/schedule-tick.yml | 4 +- scenarios/sonarr-node.yml | 2 +- scenarios/tools-confluence.yml | 4 +- scenarios/tools-gitlab.yml | 4 +- scenarios/two-node-db.yml | 12 +- scenarios/two-nodes.yml | 4 +- scenarios/whole-mesh-ace.yml | 14 +-- scenarios/whole-mesh-full.yml | 24 ++-- scenarios/whole-mesh-novox.yml | 18 +-- scripts/build-route-proxy-image.sh | 8 +- src/declaration/types.ts | 4 +- src/lifecycle/egress.ts | 2 +- src/lifecycle/place.ts | 4 +- src/lifecycle/raise.ts | 2 +- src/lifecycle/supported.ts | 2 +- src/pinning.ts | 4 +- src/rebuild.ts | 6 +- src/repos.ts | 2 +- test/integration/anthropic-bed.test.ts | 34 +++--- test/integration/assigned-audit.test.ts | 30 ++--- .../assigned-catalogue-apps.test.ts | 26 ++--- .../assigned-catalogue-media.test.ts | 28 ++--- .../assigned-catalogue-mqtt.test.ts | 30 ++--- .../assigned-catalogue-small.test.ts | 28 ++--- test/integration/assigned-grafana.test.ts | 24 ++-- test/integration/assigned-model-usage.test.ts | 38 +++--- test/integration/assigned-plex.test.ts | 30 ++--- test/integration/assigned-redis.test.ts | 26 ++--- .../assigned-schedule-tick.test.ts | 28 ++--- test/integration/assigned-sonarr.test.ts | 22 ++-- .../assigned-tools-confluence.test.ts | 26 ++--- .../integration/assigned-tools-gitlab.test.ts | 26 ++--- test/integration/assigned-two-node-db.test.ts | 60 +++++----- test/integration/builds.test.ts | 16 +-- test/integration/canary.test.ts | 12 +- test/integration/certificates.test.ts | 2 +- test/integration/events.test.ts | 28 ++--- test/integration/fresh-mesh.test.ts | 22 ++-- test/integration/genesis-single.test.ts | 10 +- test/integration/genesis.ts | 40 +++---- test/integration/harness.ts | 12 +- test/integration/lavinmq-bed.test.ts | 48 ++++---- test/integration/local-model-bed.test.ts | 22 ++-- .../integration/mesh-grant-end-to-end.test.ts | 24 ++-- test/integration/mesh.test.ts | 94 +++++++-------- .../minio-grant-end-to-end.test.ts | 24 ++-- test/integration/objectstore.test.ts | 2 +- test/integration/one-node-mesh.test.ts | 50 ++++---- test/integration/openai-bed.test.ts | 28 ++--- .../postgres-grant-end-to-end.test.ts | 26 ++--- .../provider-on-backend-network.test.ts | 20 ++-- .../provider-uses-mesh-credential.test.ts | 24 ++-- test/integration/provisioner.test.ts | 2 +- test/integration/route-forwarding.test.ts | 28 ++--- .../runtime-restart-on-config.test.ts | 24 ++-- test/integration/whole-mesh-ace.test.ts | 26 ++--- test/integration/whole-mesh-full.test.ts | 110 +++++++++--------- test/integration/whole-mesh-novox.test.ts | 42 +++---- test/lastrun.test.ts | 6 +- test/pinning.test.ts | 20 ++-- test/place.test.ts | 30 ++--- test/rebuild.test.ts | 4 +- test/supported.test.ts | 8 +- test/validate.test.ts | 6 +- test/warm.test.ts | 2 +- 89 files changed, 785 insertions(+), 785 deletions(-) diff --git a/README.md b/README.md index d0b878f..e69ad53 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ test. | | **Bootstrap** | **Full** | |---|---|---| -| Contains | virtual machines, the node host, a pinned substrate bundle | a complete mesh: forge, control plane, delivery, modules | +| Contains | virtual machines, the node host, a pinned foundation bundle | a complete mesh: forge, control plane, delivery, modules | | Verdict from | what the host reports about the state it reconciled | a pipeline result ending in verify | | Exercises | tiers 0 and 1 | tier 2 and above, and modules | | Exists to | **develop the mesh** | **test what runs on it** | @@ -140,23 +140,23 @@ is written down here rather than reconstructed a third time. ```sh export MESH_LAB_HOST_BINARY=/mesh-host -export MESH_LAB_BUNDLE=/examples/substrate-first-node.lock -export MESH_LAB_MODULES=/examples/modules -export MESH_LAB_BUILDER=/build/mesh-builder # build/, which is git-ignored +export MESH_LAB_BUNDLE=/examples/foundation-first-node.lock +export MESH_LAB_MODULES=/examples/modules +export MESH_LAB_BUILDER=/build/mesh-builder # build/, which is git-ignored # The installer. `suite` builds it with mesh-host's `make bootstrap`, which embeds a `docker save` # of the control-plane image — so it is built AFTER that image, in the same run, or it carries a # stale one sealed inside a binary where nothing would ever notice. The whole-mesh bed raises its -# anchor by RUNNING this, rather than by applying a substrate bundle itself (novox/hq ADR 0067). +# anchor by RUNNING this, rather than by applying a foundation bundle itself (novox/hq ADR 0067). export MESH_LAB_BOOTSTRAP_BINARY=/mesh-bootstrap -export MESH_LAB_CONTROL_IMAGE=mesh-control:development # optional; what it carries +export MESH_LAB_CONTROL_IMAGE=mesh-controller:development # optional; what it carries # A checkout of the mesh's catalogue. The installer reads the registry's and the control plane's # manifests from a copy of it ON THE MACHINE, because at genesis there is no forge, no build # machine and — until the registry is up — nothing serving anything. export MESH_LAB_CATALOG=/modules -# Built with `go build -o ./examples/` in mesh-control. +# Built with `go build -o ./examples/` in mesh-controller. export MESH_LAB_PROVISIONER=/postgres-provisioner export MESH_LAB_OBJECTSTORE_PROVISIONER=/objectstore-provisioner export MESH_LAB_ROUTE_PROXY=/route-proxy diff --git a/one-node-mesh-report.json b/one-node-mesh-report.json index f8756e9..512c53c 100644 --- a/one-node-mesh-report.json +++ b/one-node-mesh-report.json @@ -1,18 +1,18 @@ { "scenario": "one-node-mesh", - "established": 21, + "established": 22, "of": 22, "steps": [ { "code": "R1", "title": "a bare machine becomes a mesh of one, raised by the installer", "status": "pass", - "seconds": 122, + "seconds": 316, "why": "" }, { "code": "R2", - "title": "the substrate is up — a store and a broker of the mesh's own", + "title": "the foundation is up — a store and a broker of the mesh's own", "status": "pass", "seconds": 1, "why": "" @@ -56,63 +56,63 @@ "code": "P1", "title": "the mesh builds the shared base from source", "status": "pass", - "seconds": 85, + "seconds": 3, "why": "" }, { "code": "P2", "title": "the mesh builds and runs a store of its own", "status": "pass", - "seconds": 38, + "seconds": 7, "why": "" }, { "code": "P3", "title": "the mesh builds and runs its own catalogue", "status": "pass", - "seconds": 31, + "seconds": 4, "why": "" }, { "code": "P4", "title": "the mesh rebuilds its own control plane from source", "status": "pass", - "seconds": 32, + "seconds": 30, "why": "" }, { "code": "N1", "title": "the mesh puts itself on a private network, and its machine has a name", "status": "pass", - "seconds": 6, + "seconds": 4, "why": "" }, { "code": "N2", "title": "the machine has a packet filter, loaded from what modules declared", "status": "pass", - "seconds": 7, + "seconds": 2, "why": "" }, { "code": "U1", "title": "the mesh builds a module standing on that base", "status": "pass", - "seconds": 13, + "seconds": 14, "why": "" }, { "code": "U2", "title": "the mesh runs a broker for that module to talk to", "status": "pass", - "seconds": 24, + "seconds": 26, "why": "" }, { "code": "U3", "title": "the anchor runs the module the mesh built", "status": "pass", - "seconds": 6, + "seconds": 7, "why": "" }, { @@ -140,22 +140,22 @@ "code": "V4", "title": "every resource the mesh declared is true on the machine", "status": "pass", - "seconds": 11, + "seconds": 13, "why": "" }, { "code": "E1", "title": "a change to a module's source reaches the machine on its own", "status": "pass", - "seconds": 13, + "seconds": 12, "why": "" }, { "code": "E2", "title": "the mesh comes back after the machine reboots", - "status": "fail", - "seconds": 31, - "why": "every container came back and no host agent did, so the machine is running the right things and can no longer be told anything:\namqp-ping\tUp 3 seconds\nmesh-lavinmq\tUp 3 seconds\nlavinmq\tUp 4 seconds (health: starting)\nmesh-control\tUp 3 seconds\nmesh-catalog\tUp 1 second\nmesh-postgres\tUp 4 seconds\npostgres\tUp 3 seconds\nmesh-builder\tUp 2 seconds\nmesh-registry\tUp 3 seconds\nmesh-broker\tUp 3 seconds (health: starting)\nmesh-store\tUp 3 seconds\n" + "status": "pass", + "seconds": 33, + "why": "" } ] } \ No newline at end of file diff --git a/provisioners/README.md b/provisioners/README.md index 13f5acc..1b97057 100644 --- a/provisioners/README.md +++ b/provisioners/README.md @@ -39,7 +39,7 @@ must reach the same state from wherever it starts. That means, in order: Step 2 is the half usually missing, and it is the same rule the host follows about removing what it declared and no longer declares. -The reference implementation lives in `mesh-control/examples/postgres-provisioner`, because that +The reference implementation lives in `mesh-controller/examples/postgres-provisioner`, because that is where the contract is defined and where the language is already set up to read it. The lab's job is the other half: raising a real PostgreSQL and proving that what the mesh delivered becomes a login that works, a rotation that takes effect, and a revocation that bites. diff --git a/scenarios/anthropic-bed.yml b/scenarios/anthropic-bed.yml index 02199c6..c2529a0 100644 --- a/scenarios/anthropic-bed.yml +++ b/scenarios/anthropic-bed.yml @@ -8,10 +8,10 @@ # The flow the test drives (OAuth stubbed, so it is the FLOW that is proven, not the vendor): # the manager module seals the refresh token to the node's PUBLIC key -> the host unseals it and # mounts the cleartext at the manager's bound path -> the manager calls the stub token endpoint -> -# submits back only { access token, re-sealed box } -> mesh-control seals the access token per +# submits back only { access token, re-sealed box } -> mesh-controller seals the access token per # consumer holder -> the consumer runtime writes ~/.claude/.credentials.json, access-token-only. # -# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock +# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock # Build BOTH runtime images into the local daemon first (the scenario stocks and serves them by # digest, which is where the host pulls them from): # scripts/build-module-runtime.sh anthropic-manager /tmp/anthropic-manager.tar @@ -35,7 +35,7 @@ machines: cpus: 2 images: - - mesh-control:development + - mesh-controller:development # The two model-access runtimes, built by scripts/build-module-runtime.sh into the local daemon and # loaded onto the machine, which holds them by their own image IDs. - mesh-runtime-anthropic-manager:development diff --git a/scenarios/audit-node.yml b/scenarios/audit-node.yml index 8107155..1eba0e2 100644 --- a/scenarios/audit-node.yml +++ b/scenarios/audit-node.yml @@ -1,6 +1,6 @@ # One machine that becomes a mesh and then assigns itself the audit logger. # -# The substrate is first-node's — a store, a broker, the control plane — and one module image on +# The foundation is first-node's — a store, a broker, the control plane — and one module image on # top: the tool runtime carrying the audit-logger (mesh-catalog). The node enrols itself and the # mesh assigns it the audit logger, so its events account is one the mesh delivered, not the # broker's own (novox/hq ADR 0048). @@ -20,7 +20,7 @@ machines: cpus: 2 images: - - mesh-control:development + - mesh-controller:development # The tool runtime with the audit-logger, built by scripts/build-runtime-image.sh into the local # daemon and loaded onto the machine, which holds it by its own image ID. - mesh-runtime-audit:development diff --git a/scenarios/catalogue-apps.yml b/scenarios/catalogue-apps.yml index 9a46c1d..5c92fdc 100644 --- a/scenarios/catalogue-apps.yml +++ b/scenarios/catalogue-apps.yml @@ -1,6 +1,6 @@ # One machine that becomes a mesh and is then assigned a SECOND wave of modules at once — the ones # converted this session (novox/hq ADR 0039/0048/0052): mongodb, unifi and marrytts, with postgres -# carried along as a known-good control. This is catalogue-small's sibling: same first-node substrate, +# carried along as a known-good control. This is catalogue-small's sibling: same first-node foundation, # same one-push co-residence, a different (and heavier) set of modules. # # The four exercise the three converted shapes: @@ -14,7 +14,7 @@ # None of the four share a directory, so the shared-workspace refusal (novox/hq 04-ISSUES/012) does # not bite; that class stays for a later bed. # -# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock +# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock # scripts/build-module-runtime.sh {mongodb,unifi,postgres} build the runtime images into the local # daemon (mongodb carries mongosh; unifi and postgres carry their CLIs). The service images # (mongo, unifi-controller, marytts, postgres) must be in the local daemon to be stocked. @@ -31,13 +31,13 @@ machines: egress: true inbound: allow # Sized up past catalogue-small's 6GiB: this wave carries two heavy JVM/embedded-DB service - # containers (the UniFi controller and MaryTTS) on top of the substrate, mongodb, postgres and + # containers (the UniFi controller and MaryTTS) on top of the foundation, mongodb, postgres and # three node runtimes — a dozen containers, two of them memory-hungry at startup. memory: 8GiB cpus: 4 images: - - mesh-control:development + - mesh-controller:development # The runtimes built by scripts/build-module-runtime.sh and stocked here. marrytts needs none. - mesh-runtime-mongodb:development - mesh-runtime-unifi:development diff --git a/scenarios/catalogue-media.yml b/scenarios/catalogue-media.yml index 9dc1534..1921ec9 100644 --- a/scenarios/catalogue-media.yml +++ b/scenarios/catalogue-media.yml @@ -16,7 +16,7 @@ # after enrol and before the push. Each module additionally OWNS its own config directory # (/services/{sonarr,radarr}/config), which the mesh does create. # -# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock +# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock # scripts/build-module-runtime.sh {sonarr,radarr} build the two runtime images into the local daemon # (they speak HTTP and need no CLI added). The service images lscr.io/linuxserver/{sonarr,radarr} # must be in the local daemon to be stocked. @@ -32,14 +32,14 @@ machines: at: { segment: hosting, address: [192.0.2.10] } egress: true inbound: allow - # Two *arr apps (server + runtime each) on top of the first-node substrate — seven containers. + # Two *arr apps (server + runtime each) on top of the first-node foundation — seven containers. # The Servarr images are lighter than catalogue-apps' JVM pair, so catalogue-small's 6GiB is # ample headroom. memory: 6GiB cpus: 4 images: - - mesh-control:development + - mesh-controller:development # The two runtimes built by scripts/build-module-runtime.sh and stocked here. - mesh-runtime-sonarr:development - mesh-runtime-radarr:development diff --git a/scenarios/catalogue-mqtt.yml b/scenarios/catalogue-mqtt.yml index 866b7c3..6da3e98 100644 --- a/scenarios/catalogue-mqtt.yml +++ b/scenarios/catalogue-mqtt.yml @@ -11,7 +11,7 @@ # the broker — so "the broker came up" is itself the proof the seed ran, because an unseeded store # crash-loops the broker. # -# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock +# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock # scripts/build-module-runtime.sh mosquitto builds mesh-runtime-mosquitto:development (carrying # mosquitto_ctrl and the compiled bootstrap entrypoint) into the local daemon, which this scenario # pulls from the internet over its uplink. eclipse-mosquitto:2 must be in the local @@ -32,7 +32,7 @@ machines: cpus: 2 images: - - mesh-control:development + - mesh-controller:development - mesh-runtime-mosquitto:development place: diff --git a/scenarios/catalogue-small.yml b/scenarios/catalogue-small.yml index f45c5f1..555be7a 100644 --- a/scenarios/catalogue-small.yml +++ b/scenarios/catalogue-small.yml @@ -3,14 +3,14 @@ # # The per-backend beds each assign one module: postgres (a database provider), minio (an object-store # provider), redis (a cache provider + tools), plex (a tools module). This raises the same first-node -# substrate and then assigns all four to the one anchor in a single push, so the proof is that they +# foundation and then assigns all four to the one anchor in a single push, so the proof is that they # resolve and come up TOGETHER on one node — nothing new about any single module, everything new about # their co-residence. # # The four are chosen because none of them share a directory, so the shared-workspace refusal # (novox/hq 04-ISSUES/012 — the media stack) does not bite here; that class stays for a later bed. # -# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock +# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock # scripts/build-module-runtime.sh {postgres,redis,minio,plex} build the four runtime images into the # local daemon (postgres carries psql, minio carries mc), which this scenario stocks and serves by # the internet over its uplink. Only the mesh's own images come from the local daemon. @@ -26,14 +26,14 @@ machines: at: { segment: hosting, address: [192.0.2.10] } egress: true inbound: allow - # Sized up: this anchor runs the substrate (store, broker, control) plus four modules — three of + # Sized up: this anchor runs the foundation (store, broker, control) plus four modules — three of # which are a server container and a runtime container each — so a dozen containers at once. The # single-module beds run at 3GiB; co-residence needs the headroom. memory: 6GiB cpus: 4 images: - - mesh-control:development + - mesh-controller:development # The four per-module runtimes, built by scripts/build-module-runtime.sh and stocked here. plex # needs no server image in the lab — its runtime serves tools with no Plex to reach. - mesh-runtime-postgres:development diff --git a/scenarios/first-node.yml b/scenarios/first-node.yml index 4065e23..56f7a07 100644 --- a/scenarios/first-node.yml +++ b/scenarios/first-node.yml @@ -1,4 +1,4 @@ -# One machine, raising a substrate from the bundle its host carries. +# One machine, raising a foundation from the bundle its host carries. # # This is the bootstrap class (novox/hq ADR 0009): no forge, no control plane to talk to, no # delivery. It exists to develop the steps of raising a mesh on a machine that has nothing but a @@ -24,7 +24,7 @@ machines: # is what pinning asks for (novox/hq ADR 0006). The store and the broker are ordinary third-party # images, and the machine pulls them from the internet like anything else. images: - - mesh-control:development + - mesh-controller:development place: all: [host, runtime] diff --git a/scenarios/fresh-mesh.yml b/scenarios/fresh-mesh.yml index 2a7f5c9..e5c0d85 100644 --- a/scenarios/fresh-mesh.yml +++ b/scenarios/fresh-mesh.yml @@ -11,7 +11,7 @@ # runtime and the host binary, which are prerequisites of the machine rather than parts of the # mesh, and after that the mesh is on its own: # -# - the substrate, the registry and the builder's own dependencies are PULLED from the internet, +# - the foundation, the registry and the builder's own dependencies are PULLED from the internet, # which is where a bare machine gets them; # - the control plane is BUILT, by the builder the installer carries, from a repository and a # commit it is told to use; @@ -26,7 +26,7 @@ # # hosting (public, routable) home (private, behind the access point) # anchor 192.0.2.20 ── anchor home-server 10.99.1.10 home server -# substrate, registry, workstation 10.99.1.20 workstation +# foundation, registry, workstation 10.99.1.20 workstation # builder, control plane laptop 10.99.1.30 workstation # # EGRESS IS NOT OPTIONAL HERE. With nothing loaded, a sealed machine stops at the installer's first @@ -35,7 +35,7 @@ # the public images, and the forge the control plane is cloned from. # # MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BOOTSTRAP_BINARY=.../mesh-bootstrap -# MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock +# MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock # MESH_LAB_CATALOG=.../mesh-catalog/modules # MESH_LAB_SOURCE= MESH_LAB_SOURCE_REF= scenario: fresh-mesh @@ -74,7 +74,7 @@ machines: cpus: 6 disk: 60GiB - # Three machines that JOIN. Host binary and a token, nothing else — no bootstrap, no substrate, + # Three machines that JOIN. Host binary and a token, nothing else — no bootstrap, no foundation, # no registry. They are deliberately small: what they are here to prove is that a joined machine # can be given a module the mesh built, which is a question about credentials and not about load. home-server: diff --git a/scenarios/genesis-single.yml b/scenarios/genesis-single.yml index da12b61..898a281 100644 --- a/scenarios/genesis-single.yml +++ b/scenarios/genesis-single.yml @@ -2,10 +2,10 @@ # # The smallest thing that proves a mesh can be raised. One machine on a public segment with a way # out to the internet, the host placed, and nothing else — the installer carries the control -# plane's image and the substrate's own images are pulled over the uplink, exactly as they are on +# plane's image and the foundation's own images are pulled over the uplink, exactly as they are on # a bare machine. # -# The address matters: the substrate template names the broker at 192.0.2.10, and a token carries +# The address matters: the foundation template names the broker at 192.0.2.10, and a token carries # that address verbatim as the endpoint an enrolling node dials. With one machine, that machine # must BE it, or the mesh would hand out an endpoint nothing answers on. scenario: genesis-single @@ -23,7 +23,7 @@ machines: # bare machine. A scenario that needs no images can omit this; genesis cannot. egress: true inbound: allow - # Enough for the substrate (store, broker), the registry, and two control planes during the + # Enough for the foundation (store, broker), the registry, and two control planes during the # pivot. Smaller than the four-node bed's anchor, which also carries a whole service set. memory: 8GiB cpus: 4 @@ -33,8 +33,8 @@ machines: # # The runtime is not a mesh tier — it is a prerequisite of the machine, and the installer's first # step refuses to go on without one. Placing it here is the lab preparing a machine, not the lab -# describing an installation. Everything above tier 0 — the substrate, the registry, the control +# describing an installation. Everything above tier 0 — the foundation, the registry, the control # plane — is the installer's, and the lab places none of it. That is the whole point of this bed: -# if the lab placed the substrate, it would be describing installing all over again. +# if the lab placed the foundation, it would be describing installing all over again. place: all: [host, runtime] diff --git a/scenarios/grafana-node.yml b/scenarios/grafana-node.yml index e58814c..f3260f3 100644 --- a/scenarios/grafana-node.yml +++ b/scenarios/grafana-node.yml @@ -20,7 +20,7 @@ machines: cpus: 2 images: - - mesh-control:development + - mesh-controller:development - mesh-runtime-grafana:development place: diff --git a/scenarios/growing-mesh.yml b/scenarios/growing-mesh.yml index 0ab0f04..542b8fd 100644 --- a/scenarios/growing-mesh.yml +++ b/scenarios/growing-mesh.yml @@ -28,7 +28,7 @@ machines: inbound: allow images: - - mesh-control:development + - mesh-controller:development place: all: [host, runtime] diff --git a/scenarios/lavinmq-bed.yml b/scenarios/lavinmq-bed.yml index f20fcdc..a7f7745 100644 --- a/scenarios/lavinmq-bed.yml +++ b/scenarios/lavinmq-bed.yml @@ -1,28 +1,28 @@ -# Two machines: one is the substrate, the other carries a lavinmq PROVIDER and a consumer of it. +# Two machines: one is the foundation, the other carries a lavinmq PROVIDER and a consumer of it. # # lavinmq is the mesh's own control-plane broker (mesh-broker), and it is ALSO offered as a # user-facing capability: a module that needs a message queue gets its OWN broker — a scoped vhost and # user on a lavinmq PROVIDER — not an account on the control broker. That makes lavinmq the sharp -# two-node case, for the same reason two-node-db is: the substrate's broker publishes 5672 on the node +# two-node case, for the same reason two-node-db is: the foundation's broker publishes 5672 on the node # it runs on, and a lavinmq provider must publish 5672 too for its consumers to reach it — so the two # cannot share a machine. The moment a real amqp provider must own a node's 5672, it collides with the # control broker already there, and the chain is blocked single-node. # -# This is the split that unblocks it. `anchor` runs the substrate (store, broker, control) and NOTHING +# This is the split that unblocks it. `anchor` runs the foundation (store, broker, control) and NOTHING # else. `laptop` runs the whole chain: the lavinmq PROVIDER and the amqp-ping CONSUMER that requires # it. Provider and consumer are co-located on laptop, so the grant never crosses a node boundary; only -# enrolment crosses to anchor, over the underlay both machines share. And because the substrate broker +# enrolment crosses to anchor, over the underlay both machines share. And because the foundation broker # is on the OTHER node, the provider owns laptop's 5672 uncontested. # -# It needs a host binary and the substrate bundle: +# It needs a host binary and the foundation bundle: # -# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock +# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock # # HELPER — stock the two runtimes into the local daemon before the run (some may already be there): # scripts/build-module-runtime.sh lavinmq /tmp/lavinmq.tar # scripts/build-module-runtime.sh amqp-ping /tmp/amqp-ping.tar # The service image cloudamqp/lavinmq:latest must be in the local daemon too — it is already stocked as -# the substrate's own broker image; each node pulls what it runs from the internet, over its own +# the foundation's own broker image; each node pulls what it runs from the internet, over its own # uplink. scenario: lavinmq-bed @@ -46,7 +46,7 @@ machines: cpus: 2 images: - - mesh-control:development + - mesh-controller:development # The lavinmq provider's runtime (reused for its run-once bootstrap and its provisioner) and the # amqp-ping consumer's runtime, both built by scripts/build-module-runtime.sh into the local daemon # and loaded onto the machines, which hold them by their own image IDs. diff --git a/scenarios/local-model-bed.yml b/scenarios/local-model-bed.yml index b76b292..b1ef7f5 100644 --- a/scenarios/local-model-bed.yml +++ b/scenarios/local-model-bed.yml @@ -11,9 +11,9 @@ # openai.env carries OPENAI_BASE_URL=http://:/v1. The test asserts that URL and that a # request to it reaches the running model server. # -# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock +# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock # Both modules are pure declaration (a server container + a templated file) — no module runtime image -# is built; the bed only stocks the substrate and the ollama server image. +# is built; the bed only stocks the foundation and the ollama server image. scenario: local-model-bed segments: @@ -31,7 +31,7 @@ machines: disk: 40GiB images: - - mesh-control:development + - mesh-controller:development place: all: [host, runtime] diff --git a/scenarios/minio-node.yml b/scenarios/minio-node.yml index 6347a7c..7163cf1 100644 --- a/scenarios/minio-node.yml +++ b/scenarios/minio-node.yml @@ -21,7 +21,7 @@ machines: cpus: 2 images: - - mesh-control:development + - mesh-controller:development # minio's runtime, built by scripts/build-module-runtime.sh minio (it carries mc), loaded onto # the machine. - mesh-runtime-minio:development diff --git a/scenarios/model-usage-bed.yml b/scenarios/model-usage-bed.yml index bd067b0..0e4790d 100644 --- a/scenarios/model-usage-bed.yml +++ b/scenarios/model-usage-bed.yml @@ -1,7 +1,7 @@ # The usage context store, proved end to end (novox/hq ADR 0054). A postgres PROVIDER and the -# model-usage CONSUMER ride one node; the substrate (store, broker, control) owns the other. As in -# two-node-db, the app-postgres provider and the mesh's own substrate store both want host port 5432, -# so they cannot share a machine — the substrate lives on `anchor` and NOTHING else, and `laptop` +# model-usage CONSUMER ride one node; the foundation (store, broker, control) owns the other. As in +# two-node-db, the app-postgres provider and the mesh's own foundation store both want host port 5432, +# so they cannot share a machine — the foundation lives on `anchor` and NOTHING else, and `laptop` # runs postgres plus model-usage. Provider and consumer are co-located on laptop, so only enrolment # crosses to anchor, over the underlay both machines already share. # @@ -9,7 +9,7 @@ # provisioned postgres store, at BOTH grains (licence and session, differing only in `consumer`), # LATEST-per-key, and IN THE CLEAR — an ordinary select returns the numeric value and its raw payload. # -# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock +# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock # Build BOTH runtime images into the local daemon first (the scenario stocks and serves them by # digest, which is where the host pulls them from): # scripts/build-module-runtime.sh postgres /tmp/postgres.tar @@ -22,7 +22,7 @@ segments: cidr: [192.0.2.0/24] machines: - # The substrate ONLY: store, broker, control — three containers. + # The foundation ONLY: store, broker, control — three containers. anchor: at: { segment: hosting, address: [192.0.2.10] } egress: true @@ -30,8 +30,8 @@ machines: memory: 4GiB cpus: 4 # The postgres PROVIDER (server + broker-bound runtime) and the model-usage CONSUMER (its run-once - # migrate and its long-lived event runtime). The 5432-vs-substrate conflict is gone because the - # substrate store is on the OTHER node. The runtime images plus postgres:17-alpine are pulled from + # migrate and its long-lived event runtime). The 5432-vs-foundation conflict is gone because the + # foundation store is on the OTHER node. The runtime images plus postgres:17-alpine are pulled from # the internet over the uplink; forty gigabytes holds them with room to spare. laptop: at: { segment: hosting, address: [192.0.2.20] } @@ -42,7 +42,7 @@ machines: disk: 40GiB images: - - mesh-control:development + - mesh-controller:development # The per-module runtimes, built by scripts/build-module-runtime.sh and stocked here. Each carries # its module's code — postgres its provisioner, model-usage its consumer, tools and run-once migrate. - mesh-runtime-postgres:development diff --git a/scenarios/one-node-mesh.yml b/scenarios/one-node-mesh.yml index fb62b03..6568191 100644 --- a/scenarios/one-node-mesh.yml +++ b/scenarios/one-node-mesh.yml @@ -2,7 +2,7 @@ # # The common case, and the one worth getting right first: a person with a single machine runs the # installer and ends up with a mesh that works. Not a mesh that *runs* — `17-raising-a-mesh` is -# careful about that difference, and so is this scenario. Genesis ends with a substrate, a registry, +# careful about that difference, and so is this scenario. Genesis ends with a foundation, a registry, # a built control plane and a builder, and a mesh in that state cannot produce anything and holds no # record of what it has. Calling that "up" is how the catalogue came to be missing from a test for # weeks without anything complaining. @@ -10,11 +10,11 @@ # So the test driving this asks the harder question: can this machine, given nothing but a container # runtime and the host binary, end up holding # -# - a substrate and a registry it pulled from the internet, +# - a foundation and a registry it pulled from the internet, # - a control plane it BUILT, and then rebuilt from its own repository through the module path, # - a builder that takes work over the broker, # - the shared base every module with code of its own stands on, -# - a store of its own — the substrate's is the control plane's own plumbing, not a provider, +# - a store of its own — the foundation's is the control plane's own plumbing, not a provider, # - a catalogue, so it can say what it has and what a change reaches, # - and a module of its own, built, provisioned and running. # @@ -26,7 +26,7 @@ # EGRESS IS NOT OPTIONAL. With nothing loaded, a sealed machine stops at the installer's first pull. # # MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BOOTSTRAP_BINARY=.../mesh-bootstrap -# MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock +# MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock # MESH_LAB_CATALOG=.../mesh-catalog/modules # MESH_LAB_SOURCE= MESH_LAB_SOURCE_REF= scenario: one-node-mesh @@ -37,7 +37,7 @@ segments: cidr: [192.0.2.0/24] machines: - # The address matters: the substrate template names the broker at a fixed address, and a token + # The address matters: the foundation template names the broker at a fixed address, and a token # carries that verbatim as the endpoint an enrolling node dials. With one machine, that machine # must BE it, or the mesh hands out an endpoint nothing answers on. # diff --git a/scenarios/openai-bed.yml b/scenarios/openai-bed.yml index ffac75a..16023ff 100644 --- a/scenarios/openai-bed.yml +++ b/scenarios/openai-bed.yml @@ -11,7 +11,7 @@ # OPENAI_API_KEY (env file + the Codex auth.json). The test asserts the written key equals the one # the operator set — through the sealed delivery path, unchanged. # -# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock +# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock # Build the consumer runtime image into the local daemon first (raise() stocks it from there): # scripts/build-module-runtime.sh openai-consumer /tmp/openai-consumer.tar scenario: openai-bed @@ -30,7 +30,7 @@ machines: cpus: 2 images: - - mesh-control:development + - mesh-controller:development # The static-key consumer runtime, built by scripts/build-module-runtime.sh into the local daemon and # loaded onto the machine, which holds it by its own image ID. - mesh-runtime-openai-consumer:development diff --git a/scenarios/plex-node.yml b/scenarios/plex-node.yml index dc72b8d..e2ffee2 100644 --- a/scenarios/plex-node.yml +++ b/scenarios/plex-node.yml @@ -1,7 +1,7 @@ # One machine that becomes a mesh and then assigns itself plex's tool runtime. # # The audit-node bed proved an assigned *consumer* (novox/hq ADR 0048). This proves an assigned -# module that *serves tools* (ADR 0052): the same first-node substrate, plus plex's tool runtime on +# module that *serves tools* (ADR 0052): the same first-node foundation, plus plex's tool runtime on # top. The node enrols itself, the mesh issues plex a broker account scoped to serve.plex.* and # assigns it, the host runs the runtime container, and a caller invokes plex.plex_reachable over the # mesh — proof the module runs its own code as its own process under its own scoped account. @@ -21,7 +21,7 @@ machines: cpus: 2 images: - - mesh-control:development + - mesh-controller:development # Plex's tool runtime, built by scripts/build-module-runtime.sh plex into the local daemon and # loaded onto the machine, which holds it by its own image ID. - mesh-runtime-plex:development diff --git a/scenarios/postgres-node.yml b/scenarios/postgres-node.yml index 913fa3f..3429b3e 100644 --- a/scenarios/postgres-node.yml +++ b/scenarios/postgres-node.yml @@ -20,7 +20,7 @@ machines: cpus: 2 images: - - mesh-control:development + - mesh-controller:development # postgres's runtime, built by scripts/build-module-runtime.sh postgres (it carries psql), loaded # onto the machine. - mesh-runtime-postgres:development diff --git a/scenarios/redis-node.yml b/scenarios/redis-node.yml index d075371..b8b4441 100644 --- a/scenarios/redis-node.yml +++ b/scenarios/redis-node.yml @@ -20,7 +20,7 @@ machines: cpus: 2 images: - - mesh-control:development + - mesh-controller:development # Redis's tool+provisioner runtime, built by scripts/build-module-runtime.sh redis into the local # daemon and loaded onto the machine, which holds it by its own image ID. - mesh-runtime-redis:development diff --git a/scenarios/route-forwarding.yml b/scenarios/route-forwarding.yml index 75da3b8..af84f47 100644 --- a/scenarios/route-forwarding.yml +++ b/scenarios/route-forwarding.yml @@ -14,9 +14,9 @@ # publicly-trusted certificate and answering an HTTP-01 challenge at the name — is proven separately # by certificates.test.ts against a real ACME server (Pebble), driving the same proxy binary. # -# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock +# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock # scripts/build-route-proxy-image.sh builds mesh-route-proxy:development into the local daemon -# (from mesh-control/examples/route-proxy, via mesh-catalog/modules/route-proxy/Dockerfile). +# (from mesh-controller/examples/route-proxy, via mesh-catalog/modules/route-proxy/Dockerfile). # alpine:latest must be in the local daemon — hello-web's backend is a bare alpine that serves a # fixed page over a busybox nc loop. Both images are stocked and served by digest. scenario: route-forwarding @@ -35,8 +35,8 @@ machines: cpus: 2 images: - - mesh-control:development - # The route-proxy's image, built from the canonical Go proxy in mesh-control by + - mesh-controller:development + # The route-proxy's image, built from the canonical Go proxy in mesh-controller by # scripts/build-route-proxy-image.sh, and hello-web's backend, a bare alpine nc loop. - mesh-route-proxy:development diff --git a/scenarios/schedule-tick.yml b/scenarios/schedule-tick.yml index e345da4..627e7d3 100644 --- a/scenarios/schedule-tick.yml +++ b/scenarios/schedule-tick.yml @@ -14,7 +14,7 @@ # - the container fires when the cron is due (top of the next minute); # - it fires AGAIN on the following minute — recurrence, not a one-shot. # -# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_MODULES=.../mesh-control/examples/modules +# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_MODULES=.../mesh-controller/examples/modules # alpine:latest must be in the local daemon; the machine pulls it from the internet over its # uplink, and the scheduled container declares it exactly as the catalogue writes it. # There is no runtime image: schedtest carries no code of its own — the scheduled container is a @@ -35,7 +35,7 @@ machines: cpus: 2 images: - - mesh-control:development + - mesh-controller:development place: # Only the host — schedtest has no mesh-runtime to place. The tick image is pulled from the diff --git a/scenarios/sonarr-node.yml b/scenarios/sonarr-node.yml index 8313f6e..70dcbeb 100644 --- a/scenarios/sonarr-node.yml +++ b/scenarios/sonarr-node.yml @@ -20,7 +20,7 @@ machines: cpus: 2 images: - - mesh-control:development + - mesh-controller:development - mesh-runtime-sonarr:development place: diff --git a/scenarios/tools-confluence.yml b/scenarios/tools-confluence.yml index 7fcc150..83b5d43 100644 --- a/scenarios/tools-confluence.yml +++ b/scenarios/tools-confluence.yml @@ -9,7 +9,7 @@ # built lazily and never throws at registration, so the runtime logs `[mesh-tools] serving 3 tool(s)` # and binds its serve queues regardless; a tool would only fail if it were actually invoked. # -# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock +# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock # scripts/build-module-runtime.sh confluence builds mesh-runtime-confluence:development into the # local daemon, which the machine pulls from the internet over its uplink. confluence # needs no service image — it is tools-only. @@ -29,7 +29,7 @@ machines: cpus: 2 images: - - mesh-control:development + - mesh-controller:development # confluence's runtime, built by scripts/build-module-runtime.sh confluence into the local daemon # and loaded onto the machine, which holds it by its own image ID. There is no # service image: confluence is tools-only and outbound-only. diff --git a/scenarios/tools-gitlab.yml b/scenarios/tools-gitlab.yml index e797999..ad5d92c 100644 --- a/scenarios/tools-gitlab.yml +++ b/scenarios/tools-gitlab.yml @@ -10,7 +10,7 @@ # throws at registration, so the runtime logs `[mesh-tools] serving 23 tool(s)` and binds its serve # queues regardless; a tool would only fail if it were actually invoked without real creds. # -# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock +# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock # scripts/build-module-runtime.sh gitlab builds mesh-runtime-gitlab:development into the local # daemon, which the machine pulls from the internet over its uplink. gitlab needs no # service image — it is tools-only. @@ -30,7 +30,7 @@ machines: cpus: 2 images: - - mesh-control:development + - mesh-controller:development # gitlab's runtime, built by scripts/build-module-runtime.sh gitlab into the local daemon and # loaded onto the machine, which holds it by its own image ID. There is no # service image: gitlab is tools-only and outbound-only. diff --git a/scenarios/two-node-db.yml b/scenarios/two-node-db.yml index 84b1313..dc923b6 100644 --- a/scenarios/two-node-db.yml +++ b/scenarios/two-node-db.yml @@ -1,13 +1,13 @@ # The DB-consumer chain a single node cannot host, proved across two machines. # -# The app-postgres provider and the mesh's own substrate store both want host port 5432, so they -# cannot share a machine — the collision that blocked this chain single-node. Here the substrate +# The app-postgres provider and the mesh's own foundation store both want host port 5432, so they +# cannot share a machine — the collision that blocked this chain single-node. Here the foundation # (store, broker, control) lives on `anchor` and NOTHING else; `laptop` runs the whole chain — # postgres and redis PROVIDERS plus the baserow and letta CONSUMERS that require them. Both # machines sit on one shared segment and enrol into the one mesh; only enrolment crosses to anchor, # over the underlay both machines already share. Provider and consumers are co-located on laptop, so -# no cross-node module comms and no overlay are needed — and the 5432-vs-substrate conflict is gone -# because the substrate store is on the OTHER node. +# no cross-node module comms and no overlay are needed — and the 5432-vs-foundation conflict is gone +# because the foundation store is on the OTHER node. scenario: two-node-db segments: @@ -16,7 +16,7 @@ segments: cidr: [192.0.2.0/24] machines: - # The substrate ONLY: store, broker, control — three containers. Four gigabytes is plenty for a + # The foundation ONLY: store, broker, control — three containers. Four gigabytes is plenty for a # node that hosts no modules; the thrash the two-nodes bed warns of comes from stacking eleven # containers on a node, which this one never does. anchor: @@ -43,7 +43,7 @@ machines: disk: 60GiB images: - - mesh-control:development + - mesh-controller:development # The per-module runtimes, built by scripts/build-module-runtime.sh and stocked here. Each carries # its module's provisioner, so no separate mesh-provision-* image is listed — the runtime is the # provisioner (ADR 0048). diff --git a/scenarios/two-nodes.yml b/scenarios/two-nodes.yml index 563ea5f..c046332 100644 --- a/scenarios/two-nodes.yml +++ b/scenarios/two-nodes.yml @@ -17,7 +17,7 @@ machines: at: { segment: hosting, address: [192.0.2.10] } egress: true inbound: allow - # The whole substrate, the registry, the builder, an adopted workload and the modules under + # The whole foundation, the registry, the builder, an adopted workload and the modules under # test all land here — eleven containers before the forge arrives. At the 1GiB default this # machine thrashes, and it presents as "the mesh hangs": every exec slows from 15s to 105s # and the forge test fails on a status poll that is merely queued behind page-outs. @@ -30,7 +30,7 @@ machines: memory: 2GiB images: - - mesh-control:development + - mesh-controller:development # And the builder, because it is a module the mesh assigns rather than a program somebody # starts by hand — which is the only way its credential can be one the mesh delivered. - mesh-builder:development diff --git a/scenarios/whole-mesh-ace.yml b/scenarios/whole-mesh-ace.yml index 6be3329..3ecb8f1 100644 --- a/scenarios/whole-mesh-ace.yml +++ b/scenarios/whole-mesh-ace.yml @@ -1,15 +1,15 @@ # The whole `ace` server's converted service set, installed together on ONE node behind the mesh -# substrate — the media/home-automation half of the whole-mesh rehearsal (novox/hq). Sibling of +# foundation — the media/home-automation half of the whole-mesh rehearsal (novox/hq). Sibling of # scenarios/whole-mesh-novox.yml; same topology, a different (larger, media-heavy) module set. # -# Substrate (store, broker, control) rides `anchor` and NOTHING else; ALL of ace's services ride the +# Foundation (store, broker, control) rides `anchor` and NOTHING else; ALL of ace's services ride the # `ace` node. An overlay is placed so the two DB consumers (baserow, letta) reach the postgres/redis # providers co-located with them. The media stack (sonarr/radarr/lidarr/plex/bazarr/nzbget/ # qbittorrent/bookshelf) shares the operator-owned library directories under /services/media (ADR # 0051 `accesses`); the test pre-creates them on the node, as the operator would, before the push — # the mesh confirms the paths exist and mounts them, but creates and chowns none of it. # -# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock +# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock # The runtimes are built by scripts/build-module-runtime.sh (one per module) and must be in the local # daemon, because nothing serves them and nothing can. Every media/app image is pulled from the # internet by the node itself, over its uplink, by the digest its module.json already pins. The test @@ -30,9 +30,9 @@ machines: memory: 4GiB cpus: 4 disk: 20GiB - # The substrate only, so the control plane's image only. The runtimes belong on the node that + # The foundation only, so the control plane's image only. The runtimes belong on the node that # runs the modules, and a 20GiB disk has no room for them anyway. - images: [mesh-control:development] + images: [mesh-controller:development] # The whole ace service set — 24 modules, ~50 containers, several heavy (Plex, Home Assistant, # Letta ~1.8GiB, Baserow ~1.5GiB, the UniFi controller's JVM, mssql ~2GiB). Sized past novox. ace: @@ -42,7 +42,7 @@ machines: memory: 18GiB cpus: 8 disk: 120GiB - # Every runtime. Not mesh-control: the control plane runs on the anchor. + # Every runtime. Not mesh-controller: the control plane runs on the anchor. images: - mesh-runtime-postgres:development - mesh-runtime-redis:development @@ -70,7 +70,7 @@ machines: - mesh-runtime-unifi:development images: - - mesh-control:development + - mesh-controller:development # The per-module runtimes (built by scripts/build-module-runtime.sh). - mesh-runtime-postgres:development - mesh-runtime-redis:development diff --git a/scenarios/whole-mesh-full.yml b/scenarios/whole-mesh-full.yml index 1c9df35..5ea45bd 100644 --- a/scenarios/whole-mesh-full.yml +++ b/scenarios/whole-mesh-full.yml @@ -1,19 +1,19 @@ # The FULL mesh in its REAL production shape: two segments, one access point, one overlay. # # This is the first multi-segment whole-mesh bed. The earlier flat whole-mesh-full sat every node -# on one public segment with a SEPARATE `anchor` carrying the substrate. Production is not flat, and +# on one public segment with a SEPARATE `anchor` carrying the foundation. Production is not flat, and # there is no separate anchor: `novox` IS the anchor. It sits on the routable `hosting` segment, -# runs the substrate (store, broker, control) AND its own service set AND is the overlay hub and the +# runs the foundation (store, broker, control) AND its own service set AND is the overlay hub and the # public ingress. `ace`, `shanks` and `g14` sit on the household `home` segment BEHIND a NAT gateway # — the access point — reachable from the outside only through what they dial out to. # # hosting (public, routable) home (private, behind the access point) # novox 192.0.2.20 ── anchor ace 10.99.1.10 home server, media/IoT set -# substrate + novox set shanks 10.99.1.20 workstation (light) +# foundation + novox set shanks 10.99.1.20 workstation (light) # overlay hub, ingress g14 10.99.1.30 workstation (light) # # The `home` gateway masquerades v4 outbound and forwards inbound (an ordinary household router). -# Home nodes reach novox's public 192.0.2.20 by dialling OUT through it: the substrate broker (5671), +# Home nodes reach novox's public 192.0.2.20 by dialling OUT through it: the foundation broker (5671), # the mesh's own artifact store, and — the thing this bed exists to prove — the WireGuard overlay hub # (51820/udp). The hub keepalive holds the NAT hole open so the tunnel, once formed, stays up. novox # cannot initiate to a home node at all; every home↔novox path is either the overlay or a forwarded @@ -35,20 +35,20 @@ # the gateway's masquerade? The driving test verifies the WireGuard handshake and cross-segment # reachability over the overlay explicitly, and reports form-vs-break as its headline. # -# Substrate-on-novox collides on two host ports the separate-anchor beds never hit: the substrate -# store binds 127.0.0.1:5432 and novox's postgres provider publishes 5432; the substrate broker binds +# Foundation-on-novox collides on two host ports the separate-anchor beds never hit: the foundation +# store binds 127.0.0.1:5432 and novox's postgres provider publishes 5432; the foundation broker binds # 5671 + 127.0.0.1:5672 and novox's lavinmq provider publishes 5672. The driving test REMAPS those two -# provider host publishes off the substrate's ports (consumers reach the providers over the mesh +# provider host publishes off the foundation's ports (consumers reach the providers over the mesh # network on the container port, so the host side is free to move). Reported as a topology finding. # -# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock +# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock # MESH_LAB_BOOTSTRAP_BINARY=.../mesh-bootstrap MESH_LAB_CATALOG=.../mesh-catalog/modules # # GENESIS AND JOINING ARE TWO DIFFERENT ACTS, and this bed distinguishes them. novox is brought # into existence by `mesh-bootstrap` — the same program a bare machine runs — and is afterwards a # working mesh of one, with a registry and a control plane that is an ordinary module pinned to an # image that registry serves. ace, shanks and g14 then JOIN it: host binary, token, enrol, run. No -# bootstrap, no substrate, no registry. novox is never enrolled twice, because the installer +# bootstrap, no foundation, no registry. novox is never enrolled twice, because the installer # already did it. # # The images: are the UNION of the novox set (feat/novox-conversions @ 431310f: the slug + roundcube @@ -76,8 +76,8 @@ segments: mapping_ttl: 120s machines: - # The anchor: substrate (store, broker, control) + the whole novox service set + overlay hub + - # public ingress. Bigger than the flat bed's novox, because it now carries the substrate too. + # The anchor: foundation (store, broker, control) + the whole novox service set + overlay hub + + # public ingress. Bigger than the flat bed's novox, because it now carries the foundation too. novox: at: { segment: hosting, address: [192.0.2.20] } egress: true @@ -90,7 +90,7 @@ machines: # because they carry no runtime image of their own — what they run is third-party or is the # node itself. # - # **mesh-control is NOT here, and its absence is the point** (novox/hq ADR 0067). The anchor is + # **mesh-controller is NOT here, and its absence is the point** (novox/hq ADR 0067). The anchor is # brought into existence by the installer, and the installer carries the control plane's image # inside itself — that is the whole reason a machine that can reach no registry can still raise # a mesh. Handing it over from the workstation as well would mean the bed never found out diff --git a/scenarios/whole-mesh-novox.yml b/scenarios/whole-mesh-novox.yml index 45ef3d5..8662ed1 100644 --- a/scenarios/whole-mesh-novox.yml +++ b/scenarios/whole-mesh-novox.yml @@ -1,10 +1,10 @@ # The whole `novox` server's converted service set, installed together on ONE node behind the mesh -# substrate — the whole-catalogue install the rebuild has never actually run. First stage of a +# foundation — the whole-catalogue install the rebuild has never actually run. First stage of a # whole-mesh rehearsal (novox/hq). # -# Topology, proven by test/integration/assigned-two-node-db.test.ts: the substrate (store, broker, +# Topology, proven by test/integration/assigned-two-node-db.test.ts: the foundation (store, broker, # control) rides `anchor` and NOTHING else; ALL of novox's services ride the `novox` node — its own -# postgres provider owns 5432 there, so it cannot co-locate with the substrate store on 5432. Both +# postgres provider owns 5432 there, so it cannot co-locate with the foundation store on 5432. Both # machines sit on one public segment and enrol into the one mesh; an overlay is placed so a # consumer's binding `at` resolves to novox's private address and every consumer reaches the # providers co-located with it. @@ -15,7 +15,7 @@ # apps portainer verdaccio registry route-proxy mailu # node-level firewall fail2ban # -# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock +# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock # The runtimes are built by scripts/build-module-runtime.sh (one per module that has code) and the # route-proxy image by scripts/build-route-proxy-image.sh; those must be in the local daemon, # because nothing serves them and nothing can. Every third-party image is pulled from the internet @@ -29,7 +29,7 @@ segments: cidr: [192.0.2.0/24] machines: - # The substrate ONLY: store, broker, control. Nothing else lands here. + # The foundation ONLY: store, broker, control. Nothing else lands here. anchor: at: { segment: hosting, address: [192.0.2.10] } egress: true @@ -37,9 +37,9 @@ machines: memory: 4GiB cpus: 4 disk: 20GiB - # The substrate only, so the control plane's image only. Handing this machine the whole set of + # The foundation only, so the control plane's image only. Handing this machine the whole set of # runtimes would fill a 20GiB disk with images nothing on it will ever start. - images: [mesh-control:development] + images: [mesh-controller:development] # The whole novox service set — ~38 containers (five providers with runtimes, six consumers with # runtimes, portainer/verdaccio/registry/route-proxy, the nine-container Mailu stack and its # runtime) plus two node-level modules. mssql alone wants ~2GiB; Mailu, Nextcloud and Keycloak are @@ -55,7 +55,7 @@ machines: # layers and the runtimes. A hundred gigabytes holds the whole set without exhausting the disk # mid-apply. disk: 100GiB - # Every runtime, and the proxy. Not mesh-control: the control plane runs on the anchor. + # Every runtime, and the proxy. Not mesh-controller: the control plane runs on the anchor. images: - mesh-runtime-postgres:development - mesh-runtime-redis:development @@ -73,7 +73,7 @@ machines: - mesh-route-proxy:development images: - - mesh-control:development + - mesh-controller:development # The per-module runtimes (built by scripts/build-module-runtime.sh). registry, route-proxy, # invoicing, firewall and fail2ban carry no mesh-runtime image; route-proxy ships its own. - mesh-runtime-postgres:development diff --git a/scripts/build-route-proxy-image.sh b/scripts/build-route-proxy-image.sh index ef5985e..f660349 100755 --- a/scripts/build-route-proxy-image.sh +++ b/scripts/build-route-proxy-image.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash # Build the route-proxy module's runtime image: the reference reverse proxy (novox/hq -# 08-connectivity §3), compiled from its canonical Go source in mesh-control into a container the +# 08-connectivity §3), compiled from its canonical Go source in mesh-controller into a container the # lab can stock and serve by digest. # # Unlike the TypeScript modules (built by build-module-runtime.sh into a node tool runtime), the # proxy is a Go program. The module ships only the packaging — a Dockerfile in mesh-catalog whose -# build context is the mesh-control repository root — and this script runs that build into the local +# build context is the mesh-controller repository root — and this script runs that build into the local # docker daemon, which a scenario's registry then stocks and serves by digest. # # build-route-proxy-image.sh @@ -13,7 +13,7 @@ set -euo pipefail HERE="$(cd "$(dirname "$0")/.." && pwd)"; ROOT="$(cd "$HERE/.." && pwd)" -MESH_CONTROL="${MESH_CONTROL:-$ROOT/mesh-control}" +MESH_CONTROL="${MESH_CONTROL:-$ROOT/mesh-controller}" MESH_CATALOG="${MESH_CATALOG:-$ROOT/mesh-catalog}" TAG="${ROUTE_PROXY_TAG:-mesh-route-proxy:development}" DOCKERFILE="$MESH_CATALOG/modules/route-proxy/Dockerfile" @@ -22,7 +22,7 @@ DOCKERFILE="$MESH_CATALOG/modules/route-proxy/Dockerfile" [ -f "$MESH_CONTROL/examples/route-proxy/main.go" ] || { echo "no proxy source at $MESH_CONTROL/examples/route-proxy" >&2; exit 1; } -# Context is the mesh-control repository root: the proxy compiles against that module's go.mod and +# Context is the mesh-controller repository root: the proxy compiles against that module's go.mod and # its examples/route-proxy package. docker build -f "$DOCKERFILE" -t "$TAG" "$MESH_CONTROL" echo "built $TAG (from $MESH_CONTROL/examples/route-proxy)" diff --git a/src/declaration/types.ts b/src/declaration/types.ts index ae71d9f..96c5a7b 100644 --- a/src/declaration/types.ts +++ b/src/declaration/types.ts @@ -103,7 +103,7 @@ export interface Machine { * and a handful of containers. * * Declared, because it is a fact about the machine the scenario describes — the node that runs - * the whole substrate is bigger than the laptop that joins it, and a test that starves its + * the whole foundation is bigger than the laptop that joins it, and a test that starves its * anchor at the default answers questions about memory pressure, not about the mesh. The forge * test failed three times as "status hangs" before anyone counted the containers in 1GiB * (novox/hq 04-ISSUES/024 is the same lesson about a different resource). @@ -158,7 +158,7 @@ export interface Scenario { * **The mesh's own images** — the ones that exist in no registry and are put onto a machine by * whoever built them. * - * mesh-control, mesh-builder, mesh-route-proxy, the per-module runtimes and the provisioners are + * mesh-controller, mesh-builder, mesh-route-proxy, the per-module runtimes and the provisioners are * built from source and published nowhere. A machine gets them the way an operator's machine * does: they are built on the workstation, loaded onto the machine, and named by the digest of * their own image configuration. Written as tags, because a tag is what `docker save` can diff --git a/src/lifecycle/egress.ts b/src/lifecycle/egress.ts index ebda7fa..256ab41 100644 --- a/src/lifecycle/egress.ts +++ b/src/lifecycle/egress.ts @@ -7,7 +7,7 @@ * from the internet, and that is now the thing worth proving before a raise says it is finished. * * The failure it exists to stop is the same one, in the same shape: `raise` returns, the caller - * applies a substrate, the first pull fails, no node enrols, and the instance is left a bare + * applies a foundation, the first pull fails, no node enrols, and the instance is left a bare * shell — with the cause several steps back and looking like a mesh fault rather than a lab one. * * Two things are checked, in this order, because they fail differently and the difference is the diff --git a/src/lifecycle/place.ts b/src/lifecycle/place.ts index 90076de..1002a9a 100644 --- a/src/lifecycle/place.ts +++ b/src/lifecycle/place.ts @@ -5,7 +5,7 @@ * 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 + * Only `host` is placeable. Everything else in the placement vocabulary — the foundation, 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). @@ -515,7 +515,7 @@ export async function loadHeldImages( throw new Error( `${requested} is not on this workstation, so there is nothing to hand the machines.\n` + ` It is one of the mesh's own images and exists in no registry — nothing can pull it.\n` + - ` Build it first (mesh-control's \`make image …\`, or scripts/build-module-runtime.sh).`, + ` Build it first (mesh-controller's \`make image …\`, or scripts/build-module-runtime.sh).`, ); } } diff --git a/src/lifecycle/raise.ts b/src/lifecycle/raise.ts index 96e402e..908237f 100644 --- a/src/lifecycle/raise.ts +++ b/src/lifecycle/raise.ts @@ -338,7 +338,7 @@ export async function raise( // **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. + // has no way to test — which is how a foundation apply used to die on its first pull. enter("confirming egress reaches the internet"); await confirmEgress(scenario, byMachine, log); diff --git a/src/lifecycle/supported.ts b/src/lifecycle/supported.ts index 55b07d8..545d922 100644 --- a/src/lifecycle/supported.ts +++ b/src/lifecycle/supported.ts @@ -36,7 +36,7 @@ export function assertSupported(scenario: Scenario): void { // `host`, `runtime` and `image:` work. 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. + // foundation is told exactly which half the lab cannot do. const unplaceable = new Set(); for (const { artifacts } of planPlacements(scenario)) { for (const artifact of artifacts) { diff --git a/src/pinning.ts b/src/pinning.ts index 3197cfd..2f63f1a 100644 --- a/src/pinning.ts +++ b/src/pinning.ts @@ -3,7 +3,7 @@ * * **A digest is not knowable until something is built** (novox/hq 04-ISSUES/025). A manifest in a * repository can pin a third-party image, because somebody can ask a registry what a tag points - * at. It cannot pin an image the mesh builds itself: mesh-control, mesh-builder, mesh-route-proxy, + * at. It cannot pin an image the mesh builds itself: mesh-controller, mesh-builder, mesh-route-proxy, * the per-module runtimes and the provisioners exist in no registry, so there is no manifest * digest to write down. The catalogue ships sixty-four zeros for them, which parses, resolves, * composes — and stops on the machine. @@ -54,7 +54,7 @@ export function repositoryOf(reference: string): string { * * **Derived from the shape the build produces, not from a list of names.** `make image * builder-image provisioner-image objectstore-image redis-provisioner-image proxy-image` in - * mesh-control and `scripts/build-module-runtime.sh` here both tag their output `mesh-` + * mesh-controller and `scripts/build-module-runtime.sh` here both tag their output `mesh-` * with no registry host and no upstream organisation — that is what "built here, published * nowhere" looks like, and a hardcoded list would go stale the first time a module is added. * diff --git a/src/rebuild.ts b/src/rebuild.ts index 13e5baf..ea160f2 100644 --- a/src/rebuild.ts +++ b/src/rebuild.ts @@ -45,7 +45,7 @@ export function planned(env: NodeJS.ProcessEnv = process.env): Build[] { env: { CGO_ENABLED: "0" }, }); } - const control = where["mesh-control"]; + const control = where["mesh-controller"]; if (control) { // **Every image the lab runs, not only the control plane's.** // @@ -84,7 +84,7 @@ export function planned(env: NodeJS.ProcessEnv = process.env): Build[] { // // It is built here at all because the bed now bootstraps THROUGH it (novox/hq ADR 0067): the // anchor is brought into existence by running the same program a bare machine runs, rather than - // by the bed applying a substrate bundle by hand and calling that an install. An installer that + // by the bed applying a foundation bundle by hand and calling that an install. An installer that // was stale would be a bed proving something about last week's procedure. const installer = env["MESH_LAB_BOOTSTRAP_BINARY"]; if (installer && where["mesh-host"]) { @@ -104,7 +104,7 @@ export function planned(env: NodeJS.ProcessEnv = process.env): Build[] { * thing it was going to run and now carries the thing that makes it, so a raised mesh holds a * control plane it built from a repository and a commit rather than one it was handed. * - * `mesh-builder:development` is what mesh-control's `make builder-image` tags — one tag, said in + * `mesh-builder:development` is what mesh-controller's `make builder-image` tags — one tag, said in * one place. Overridable because a release installer carries a release image, and nothing about * that is the lab's business. */ diff --git a/src/repos.ts b/src/repos.ts index dadabc9..c0330e0 100644 --- a/src/repos.ts +++ b/src/repos.ts @@ -23,6 +23,6 @@ export function repositories(env: NodeJS.ProcessEnv = process.env): Repositories const host = env["MESH_LAB_HOST_BINARY"]; if (host) found["mesh-host"] = dirname(host); const modules = env["MESH_LAB_MODULES"]; - if (modules) found["mesh-control"] = dirname(dirname(modules)); + if (modules) found["mesh-controller"] = dirname(dirname(modules)); return found; } diff --git a/test/integration/anthropic-bed.test.ts b/test/integration/anthropic-bed.test.ts index 85a4c9a..c9d3409 100644 --- a/test/integration/anthropic-bed.test.ts +++ b/test/integration/anthropic-bed.test.ts @@ -10,7 +10,7 @@ * **What changed from the earlier cut, and why this is simpler.** The refresh token no longer rides a * bespoke at-rest envelope the module opens with a node private key the mesh must somehow place — a * module is never given a node's private key, so that path could not exist. It rides the ORDINARY - * sealed-delivery path instead: mesh-control (via the manager module at adoption) seals it to the + * sealed-delivery path instead: mesh-controller (via the manager module at adoption) seals it to the * manager node's PUBLIC key, and the HOST unseals it with that node's real private key and mounts the * cleartext at the manager module's bound secret path — exactly as a consumer's db password arrives. * So there is no fake node key pair mounted here any more; the host's own real sealing key does the @@ -32,11 +32,11 @@ * ~/.claude/.credentials.json, access-token-only. * * STUBBED, and flagged in the report: (a) the vendor OAuth endpoint (a node stub); (b) the submit - * transport (the test invokes `mesh-control licence submit-refresh` on the manager's output, standing + * transport (the test invokes `mesh-controller licence submit-refresh` on the manager's output, standing * in for the authenticated cross-node call a manager node would make). The node-private-key stub of * the earlier cut is GONE — the host uses its own real key. * - * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock + * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock * Build both runtime images into the local daemon first: * scripts/build-module-runtime.sh anthropic-manager /tmp/anthropic-manager.tar * scripts/build-module-runtime.sh anthropic-consumer /tmp/anthropic-consumer.tar @@ -49,7 +49,7 @@ 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, substrateBundle, onTheMachine } from "./harness.ts"; +import { labIsUsable, destroyAll, foundationBundle, onTheMachine } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -61,7 +61,7 @@ const skip = !capability.usable : !binary || !existsSync(binary) ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" : !bundle || !existsSync(bundle) - ? "MESH_LAB_BUNDLE is not set to a substrate bundle (mesh-host examples/)" + ? "MESH_LAB_BUNDLE is not set to a foundation bundle (mesh-host examples/)" : false; const SCENARIO = "anthropic-bed"; @@ -96,22 +96,22 @@ async function must(command: string, timeoutMs?: number): Promise { } async function mesh(command: string, timeoutMs?: number): Promise { - return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); + return must(`docker exec mesh-controller /mesh-controller ${command}`, timeoutMs); } // The manager's adopt/refresh runtime writes its outputs as root, mode 0600 (secret files). To hand -// one to `mesh-control` — whose process runs as a non-root user — the test relaxes the mode on the +// one to `mesh-controller` — whose process runs as a non-root user — the test relaxes the mode on the // anchor host (where `must` is root) and then copies it in: `docker cp` preserves the source mode, so -// the file lands 0644 and mesh-control (a distroless image with no `chmod` of its own) can read it. +// the file lands 0644 and mesh-controller (a distroless image with no `chmod` of its own) can read it. // What is staged this way is a sealed box or the access token, never a cleartext refresh token, so a // world-readable copy discloses nothing the control plane does not already hold. In production the // operator who ran adopt owns the file and this does not arise. async function stageIntoControl(hostPath: string, dest: string): Promise { - await must(`chmod 0644 ${hostPath} && docker cp ${hostPath} mesh-control:${dest}`); + await must(`chmod 0644 ${hostPath} && docker cp ${hostPath} mesh-controller:${dest}`); } async function meshTry(command: string): Promise<{ out: string; ok: boolean }> { - return on(`docker exec mesh-control /mesh-control ${command}`); + return on(`docker exec mesh-controller /mesh-controller ${command}`); } /** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ @@ -120,7 +120,7 @@ function pinned(reference: string): string { } function bundleFor(images: HeldImage[]): string { - return substrateBundle(bundle, images); + return foundationBundle(bundle, images); } function tokenFrom(said: string): string { @@ -173,11 +173,11 @@ before(async () => { instanceId = raised.instanceId; held = raised.images; - await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); - await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); + await must(`cat > /tmp/foundation.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); + await must(`${HOST_PATH} apply /tmp/foundation.lock`, 600_000); const up = await must(`docker ps --format '{{.Names}}'`); - for (const c of ["mesh-store", "mesh-broker", "mesh-control"]) { - assert.match(up, new RegExp(c), `the substrate did not raise ${c}:\n${up}`); + for (const c of ["mesh-store", "mesh-broker", "mesh-controller"]) { + assert.match(up, new RegExp(c), `the foundation did not raise ${c}:\n${up}`); } await mesh(`node add ${MACHINE}`); @@ -239,7 +239,7 @@ test("model access refreshes on the manager node and delivers only the access to }, ], }); - await must(`printf %s ${quote(managerManifest)} > /tmp/anthropic-manager.json && docker cp /tmp/anthropic-manager.json mesh-control:/anthropic-manager.json`); + await must(`printf %s ${quote(managerManifest)} > /tmp/anthropic-manager.json && docker cp /tmp/anthropic-manager.json mesh-controller:/anthropic-manager.json`); await mesh(`module add /anthropic-manager.json`); await mesh(`module issue anthropic-manager --node ${MACHINE}`); await mesh(`assign ${MACHINE} anthropic-manager`); @@ -355,7 +355,7 @@ test("model access refreshes on the manager node and delivers only the access to }, ], }); - await must(`printf %s ${quote(consumerManifest)} > /tmp/anthropic-consumer.json && docker cp /tmp/anthropic-consumer.json mesh-control:/anthropic-consumer.json`); + await must(`printf %s ${quote(consumerManifest)} > /tmp/anthropic-consumer.json && docker cp /tmp/anthropic-consumer.json mesh-controller:/anthropic-consumer.json`); await mesh(`module add /anthropic-consumer.json`); await mesh(`module issue anthropic-consumer --node ${MACHINE}`); await mesh(`assign ${MACHINE} anthropic-consumer`); diff --git a/test/integration/assigned-audit.test.ts b/test/integration/assigned-audit.test.ts index 9c4a412..22603fb 100644 --- a/test/integration/assigned-audit.test.ts +++ b/test/integration/assigned-audit.test.ts @@ -7,10 +7,10 @@ * container that connects over amqps with that account — never the broker's own. The trail filling * is the proof the delivered, scoped credential authenticated and the subscription bound. * - * It needs the host binary, the substrate bundle, and the runtime image the scenario loads: + * It needs the host binary, the foundation bundle, and the runtime image the scenario loads: * * MESH_LAB_HOST_BINARY=.../mesh-host - * MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock + * MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock * scripts/build-runtime-image.sh builds mesh-runtime-audit:development into the local daemon, * which scenarios/audit-node.yml stocks — so no MESH_LAB_RUNTIME here; the host pulls it. */ @@ -22,7 +22,7 @@ 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, substrateBundle, onTheMachine } from "./harness.ts"; +import { labIsUsable, destroyAll, foundationBundle, onTheMachine } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -34,7 +34,7 @@ const skip = !capability.usable : !binary || !existsSync(binary) ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" : !bundle || !existsSync(bundle) - ? "MESH_LAB_BUNDLE is not set to a substrate bundle (mesh-host examples/)" + ? "MESH_LAB_BUNDLE is not set to a foundation bundle (mesh-host examples/)" : false; const SCENARIO = "audit-node"; @@ -65,7 +65,7 @@ async function must(command: string, timeoutMs?: number): Promise { /** The control plane, a container on the node. */ async function mesh(command: string, timeoutMs?: number): Promise { - return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); + return must(`docker exec mesh-controller /mesh-controller ${command}`, timeoutMs); } /** The reference a manifest should carry, once this scenario has been raised. */ @@ -74,9 +74,9 @@ function pinned(reference: string): string { return onTheMachine(reference, held); } -/** The substrate bundle: ours by the ID the machine holds, everything else upstream. */ +/** The foundation bundle: ours by the ID the machine holds, everything else upstream. */ function bundleFor(images: HeldImage[]): string { - return substrateBundle(bundle, images); + return foundationBundle(bundle, images); } function tokenFrom(said: string): string { @@ -89,7 +89,7 @@ async function settled(withinMs = 480_000): Promise { const until = Date.now() + withinMs; let last = ""; while (Date.now() < until) { - const asked = await on(`docker exec mesh-control /mesh-control status --json`); + const asked = await on(`docker exec mesh-controller /mesh-controller status --json`); if (asked.ok) { try { const state = JSON.parse(asked.out) as { @@ -121,12 +121,12 @@ before(async () => { instanceId = raised.instanceId; held = raised.images; - // Raise the substrate — store, broker, control — from the bundle. - await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); - await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); + // Raise the foundation — store, broker, control — from the bundle. + await must(`cat > /tmp/foundation.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); + await must(`${HOST_PATH} apply /tmp/foundation.lock`, 600_000); const up = await must(`docker ps --format '{{.Names}}'`); - for (const c of ["mesh-store", "mesh-broker", "mesh-control"]) { - assert.match(up, new RegExp(c), `the substrate did not raise ${c}:\n${up}`); + for (const c of ["mesh-store", "mesh-broker", "mesh-controller"]) { + assert.match(up, new RegExp(c), `the foundation did not raise ${c}:\n${up}`); } // The node joins its own mesh, so it is a node the mesh can assign to, and start the host so it @@ -165,7 +165,7 @@ test("the mesh assigns the audit logger, and it consumes over the account the me }, ], }); - await must(`printf %s ${quote(manifest)} > /tmp/audit.json && docker cp /tmp/audit.json mesh-control:/audit.json`); + await must(`printf %s ${quote(manifest)} > /tmp/audit.json && docker cp /tmp/audit.json mesh-controller:/audit.json`); await mesh("module add /audit.json"); // The mesh issues its scoped account and seals it to this machine, then assigns and pushes it. @@ -209,7 +209,7 @@ test("the mesh assigns the audit logger, and it consumes over the account the me // And the account the mesh made for it is a real one on the broker — the trail above already // proved it authenticated and read its queue. That it reaches no further than its own queue is - // the scope CreateModuleAccount applies, checked as patterns in mesh-control's own tests. + // the scope CreateModuleAccount applies, checked as patterns in mesh-controller's own tests. const users = await must(`docker exec mesh-broker lavinmqctl list_users 2>&1`); assert.match(users, /anchor-audit-logger/, `the scoped account is not on the broker:\n${users}`); }); diff --git a/test/integration/assigned-catalogue-apps.test.ts b/test/integration/assigned-catalogue-apps.test.ts index aa583c6..d163449 100644 --- a/test/integration/assigned-catalogue-apps.test.ts +++ b/test/integration/assigned-catalogue-apps.test.ts @@ -17,7 +17,7 @@ * * All are assigned to the one anchor, pushed ONCE, and the node converges ONCE with every one up. * - * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock + * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock * scripts/build-module-runtime.sh {mongodb,unifi,postgres} build the runtime images into the local * daemon; scenarios/catalogue-apps.yml stocks them. mongo:7, lscr.io/linuxserver/unifi-controller * and synesthesiam/marytts must be in the local daemon to be stocked. @@ -30,7 +30,7 @@ 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, substrateBundle, onTheMachine } from "./harness.ts"; +import { labIsUsable, destroyAll, foundationBundle, onTheMachine } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -42,7 +42,7 @@ const skip = !capability.usable : !binary || !existsSync(binary) ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" : !bundle || !existsSync(bundle) - ? "MESH_LAB_BUNDLE is not set to a substrate bundle (mesh-host examples/)" + ? "MESH_LAB_BUNDLE is not set to a foundation bundle (mesh-host examples/)" : false; const SCENARIO = "catalogue-apps"; @@ -73,7 +73,7 @@ async function must(command: string, timeoutMs?: number): Promise { /** The control plane, a container on the node. */ async function mesh(command: string, timeoutMs?: number): Promise { - return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); + return must(`docker exec mesh-controller /mesh-controller ${command}`, timeoutMs); } /** The reference a manifest should carry, once this scenario has been raised. */ @@ -82,9 +82,9 @@ function pinned(reference: string): string { return onTheMachine(reference, held); } -/** The substrate bundle: ours by the ID the machine holds, everything else upstream. */ +/** The foundation bundle: ours by the ID the machine holds, everything else upstream. */ function bundleFor(images: HeldImage[]): string { - return substrateBundle(bundle, images); + return foundationBundle(bundle, images); } function tokenFrom(said: string): string { @@ -97,7 +97,7 @@ async function settled(withinMs = 600_000): Promise { const until = Date.now() + withinMs; let last = ""; while (Date.now() < until) { - const asked = await on(`docker exec mesh-control /mesh-control status --json`); + const asked = await on(`docker exec mesh-controller /mesh-controller status --json`); if (asked.ok) { try { const state = JSON.parse(asked.out) as { @@ -129,12 +129,12 @@ before(async () => { instanceId = raised.instanceId; held = raised.images; - // Raise the substrate — store, broker, control — from the bundle. - await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); - await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); + // Raise the foundation — store, broker, control — from the bundle. + await must(`cat > /tmp/foundation.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); + await must(`${HOST_PATH} apply /tmp/foundation.lock`, 600_000); const up = await must(`docker ps --format '{{.Names}}'`); - for (const c of ["mesh-store", "mesh-broker", "mesh-control"]) { - assert.match(up, new RegExp(c), `the substrate did not raise ${c}:\n${up}`); + for (const c of ["mesh-store", "mesh-broker", "mesh-controller"]) { + assert.match(up, new RegExp(c), `the foundation did not raise ${c}:\n${up}`); } // The node joins its own mesh, so it is a node the mesh can assign to, and start the host so it @@ -310,7 +310,7 @@ test("the mesh assigns mongodb, unifi, marrytts and postgres to one node in one // --- add, issue (those that serve/emit), assign, then ONE push ---------------------------------- async function add(name: string, manifest: string): Promise { - await must(`printf %s ${quote(manifest)} > /tmp/${name}.json && docker cp /tmp/${name}.json mesh-control:/${name}.json`); + await must(`printf %s ${quote(manifest)} > /tmp/${name}.json && docker cp /tmp/${name}.json mesh-controller:/${name}.json`); await mesh(`module add /${name}.json`); } diff --git a/test/integration/assigned-catalogue-media.test.ts b/test/integration/assigned-catalogue-media.test.ts index c75fad6..3830201 100644 --- a/test/integration/assigned-catalogue-media.test.ts +++ b/test/integration/assigned-catalogue-media.test.ts @@ -23,7 +23,7 @@ * * All is assigned to the one anchor, pushed ONCE, and the node converges ONCE with both modules up. * - * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock + * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock * scripts/build-module-runtime.sh {sonarr,radarr} build the runtime images into the local daemon; * scenarios/catalogue-media.yml stocks them. lscr.io/linuxserver/{sonarr,radarr} must be in the * local daemon; the service images are pulled from the internet. Each *arr runtime is given a lab @@ -38,7 +38,7 @@ 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, substrateBundle, onTheMachine } from "./harness.ts"; +import { labIsUsable, destroyAll, foundationBundle, onTheMachine } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -50,7 +50,7 @@ const skip = !capability.usable : !binary || !existsSync(binary) ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" : !bundle || !existsSync(bundle) - ? "MESH_LAB_BUNDLE is not set to a substrate bundle (mesh-host examples/)" + ? "MESH_LAB_BUNDLE is not set to a foundation bundle (mesh-host examples/)" : false; const SCENARIO = "catalogue-media"; @@ -81,7 +81,7 @@ async function must(command: string, timeoutMs?: number): Promise { /** The control plane, a container on the node. */ async function mesh(command: string, timeoutMs?: number): Promise { - return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); + return must(`docker exec mesh-controller /mesh-controller ${command}`, timeoutMs); } /** The reference a manifest should carry, once this scenario has been raised. */ @@ -90,9 +90,9 @@ function pinned(reference: string): string { return onTheMachine(reference, held); } -/** The substrate bundle: ours by the ID the machine holds, everything else upstream. */ +/** The foundation bundle: ours by the ID the machine holds, everything else upstream. */ function bundleFor(images: HeldImage[]): string { - return substrateBundle(bundle, images); + return foundationBundle(bundle, images); } function tokenFrom(said: string): string { @@ -105,7 +105,7 @@ async function settled(withinMs = 600_000): Promise { const until = Date.now() + withinMs; let last = ""; while (Date.now() < until) { - const asked = await on(`docker exec mesh-control /mesh-control status --json`); + const asked = await on(`docker exec mesh-controller /mesh-controller status --json`); if (asked.ok) { try { const state = JSON.parse(asked.out) as { @@ -137,12 +137,12 @@ before(async () => { instanceId = raised.instanceId; held = raised.images; - // Raise the substrate — store, broker, control — from the bundle. - await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); - await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); + // Raise the foundation — store, broker, control — from the bundle. + await must(`cat > /tmp/foundation.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); + await must(`${HOST_PATH} apply /tmp/foundation.lock`, 600_000); const up = await must(`docker ps --format '{{.Names}}'`); - for (const c of ["mesh-store", "mesh-broker", "mesh-control"]) { - assert.match(up, new RegExp(c), `the substrate did not raise ${c}:\n${up}`); + for (const c of ["mesh-store", "mesh-broker", "mesh-controller"]) { + assert.match(up, new RegExp(c), `the foundation did not raise ${c}:\n${up}`); } // The node joins its own mesh, so it is a node the mesh can assign to, and start the host so it @@ -248,7 +248,7 @@ test("sonarr and radarr, both accessing one operator-owned directory, co-resolve // --- add, issue (both emit events → each gets a scoped broker account), assign ------------------ async function add(name: string, manifest: string): Promise { - await must(`printf %s ${quote(manifest)} > /tmp/${name}.json && docker cp /tmp/${name}.json mesh-control:/${name}.json`); + await must(`printf %s ${quote(manifest)} > /tmp/${name}.json && docker cp /tmp/${name}.json mesh-controller:/${name}.json`); await mesh(`module add /${name}.json`); } @@ -274,7 +274,7 @@ test("sonarr and radarr, both accessing one operator-owned directory, co-resolve // read as two modules owning one path. Now each ACCESSES it, so the pair co-resolves and both are // sent in a single declaration. A refusal here (nonzero, or "could not be resolved") is the // regression this bed exists to catch. - const pushed = await on(`docker exec mesh-control /mesh-control push ${MACHINE}`); + const pushed = await on(`docker exec mesh-controller /mesh-controller push ${MACHINE}`); assert.ok(pushed.ok, `the co-resident push was REFUSED — the shared-access collision ADR 0051 removed is back:\n${pushed.out}`); assert.doesNotMatch(pushed.out, /could not be resolved|both declare the path|shared data is the operator/, diff --git a/test/integration/assigned-catalogue-mqtt.test.ts b/test/integration/assigned-catalogue-mqtt.test.ts index 5bd307d..762738c 100644 --- a/test/integration/assigned-catalogue-mqtt.test.ts +++ b/test/integration/assigned-catalogue-mqtt.test.ts @@ -22,7 +22,7 @@ * the seed), and mosquitto's provisioner — running in the assigned runtime — creates a scoped client * for a contribution the mesh delivered, which then authenticates with the password the mesh minted. * - * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock + * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock * scripts/build-module-runtime.sh mosquitto builds mesh-runtime-mosquitto:development (carrying * mosquitto_ctrl and the compiled bootstrap entrypoint) into the local daemon, which * scenarios/catalogue-mqtt.yml stocks. eclipse-mosquitto:2 must be in the local daemon to be @@ -36,7 +36,7 @@ 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, substrateBundle, onTheMachine } from "./harness.ts"; +import { labIsUsable, destroyAll, foundationBundle, onTheMachine } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -48,7 +48,7 @@ const skip = !capability.usable : !binary || !existsSync(binary) ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" : !bundle || !existsSync(bundle) - ? "MESH_LAB_BUNDLE is not set to a substrate bundle (mesh-host examples/)" + ? "MESH_LAB_BUNDLE is not set to a foundation bundle (mesh-host examples/)" : false; const SCENARIO = "catalogue-mqtt"; @@ -78,7 +78,7 @@ async function must(command: string, timeoutMs?: number): Promise { /** The control plane, a container on the node. */ async function mesh(command: string, timeoutMs?: number): Promise { - return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); + return must(`docker exec mesh-controller /mesh-controller ${command}`, timeoutMs); } /** The reference a manifest should carry, once this scenario has been raised. */ @@ -87,9 +87,9 @@ function pinned(reference: string): string { return onTheMachine(reference, held); } -/** The substrate bundle: ours by the ID the machine holds, everything else upstream. */ +/** The foundation bundle: ours by the ID the machine holds, everything else upstream. */ function bundleFor(images: HeldImage[]): string { - return substrateBundle(bundle, images); + return foundationBundle(bundle, images); } function tokenFrom(said: string): string { @@ -102,7 +102,7 @@ async function settled(withinMs = 600_000): Promise { const until = Date.now() + withinMs; let last = ""; while (Date.now() < until) { - const asked = await on(`docker exec mesh-control /mesh-control status --json`); + const asked = await on(`docker exec mesh-controller /mesh-controller status --json`); if (asked.ok) { try { const state = JSON.parse(asked.out) as { @@ -134,12 +134,12 @@ before(async () => { instanceId = raised.instanceId; held = raised.images; - // Raise the substrate — store, broker, control — from the bundle. - await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); - await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); + // Raise the foundation — store, broker, control — from the bundle. + await must(`cat > /tmp/foundation.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); + await must(`${HOST_PATH} apply /tmp/foundation.lock`, 600_000); const up = await must(`docker ps --format '{{.Names}}'`); - for (const c of ["mesh-store", "mesh-broker", "mesh-control"]) { - assert.match(up, new RegExp(c), `the substrate did not raise ${c}:\n${up}`); + for (const c of ["mesh-store", "mesh-broker", "mesh-controller"]) { + assert.match(up, new RegExp(c), `the foundation did not raise ${c}:\n${up}`); } // The node joins its own mesh and starts the host so it applies what it is pushed. @@ -187,7 +187,7 @@ test("the mesh assigns mosquitto: a run-once step seeds dynsec before the broker serves: { "mqtt-topic": {} }, emits: ["module.mosquitto.topic.provisioned", "module.mosquitto.topic.deprovisioned"], // The events entrypoint subscribes to its own lifecycle events (an audit log), so it consumes - // them too — declared, or the substrate never makes the queue the runtime binds (ADR 0046). + // them too — declared, or the foundation never makes the queue the runtime binds (ADR 0046). consumes: ["module.mosquitto.topic.provisioned", "module.mosquitto.topic.deprovisioned"], receives: { "mqtt-topic": "/var/lib/mosquitto-module/grants/mesh.json" }, grants: { "mqtt-topic": "/var/lib/mosquitto-module/grants" }, @@ -254,7 +254,7 @@ test("the mesh assigns mosquitto: a run-once step seeds dynsec before the broker ], }); - await must(`printf %s ${quote(manifest)} > /tmp/mosquitto.json && docker cp /tmp/mosquitto.json mesh-control:/mosquitto.json`); + await must(`printf %s ${quote(manifest)} > /tmp/mosquitto.json && docker cp /tmp/mosquitto.json mesh-controller:/mosquitto.json`); await mesh("module add /mosquitto.json"); const issued = await mesh(`module issue mosquitto --node ${MACHINE}`); assert.match(issued, /scoped to what it emits and consumes/, issued); @@ -324,7 +324,7 @@ test("the mesh assigns mosquitto: a run-once step seeds dynsec before the broker // each consumer it reads the login the mesh derived and the mesh-minted password the host unsealed, // and creates exactly that dynsec client, scoped to its own topic subtree (ADR 0048). A // hand-written contributions file + secret stand in for the control plane's write; their SHAPE is - // what mesh-control produces. The proof is authentication as the consumer with the mesh's password + // what mesh-controller produces. The proof is authentication as the consumer with the mesh's password // — a provisioner that invented its own would refuse the connection. const consumerPw = "mesh-minted-mqtt-7b2e1a"; await must(`printf %s ${quote(consumerPw)} > /var/lib/mosquitto-module/grants/app.secret`); diff --git a/test/integration/assigned-catalogue-small.test.ts b/test/integration/assigned-catalogue-small.test.ts index e32c950..819b711 100644 --- a/test/integration/assigned-catalogue-small.test.ts +++ b/test/integration/assigned-catalogue-small.test.ts @@ -18,7 +18,7 @@ * path is fulfilled by creating the consumer's login with the mesh-minted password — it seals nothing * and needs no seal key (novox/hq ADR 0048, issue 032-provider-runtime-has-no-seal-key). * - * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock + * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock * scripts/build-module-runtime.sh {postgres,redis,minio,plex} build the four runtime images into the * local daemon; scenarios/catalogue-small.yml stocks them. postgres:17-alpine, redis:7-alpine and * minio/minio:latest is pulled from the internet by the node itself. @@ -31,7 +31,7 @@ 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, substrateBundle, onTheMachine } from "./harness.ts"; +import { labIsUsable, destroyAll, foundationBundle, onTheMachine } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -43,7 +43,7 @@ const skip = !capability.usable : !binary || !existsSync(binary) ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" : !bundle || !existsSync(bundle) - ? "MESH_LAB_BUNDLE is not set to a substrate bundle (mesh-host examples/)" + ? "MESH_LAB_BUNDLE is not set to a foundation bundle (mesh-host examples/)" : false; const SCENARIO = "catalogue-small"; @@ -74,7 +74,7 @@ async function must(command: string, timeoutMs?: number): Promise { /** The control plane, a container on the node. */ async function mesh(command: string, timeoutMs?: number): Promise { - return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); + return must(`docker exec mesh-controller /mesh-controller ${command}`, timeoutMs); } /** The reference a manifest should carry, once this scenario has been raised. */ @@ -83,9 +83,9 @@ function pinned(reference: string): string { return onTheMachine(reference, held); } -/** The substrate bundle: ours by the ID the machine holds, everything else upstream. */ +/** The foundation bundle: ours by the ID the machine holds, everything else upstream. */ function bundleFor(images: HeldImage[]): string { - return substrateBundle(bundle, images); + return foundationBundle(bundle, images); } function tokenFrom(said: string): string { @@ -104,7 +104,7 @@ async function settled(withinMs = 600_000): Promise { const until = Date.now() + withinMs; let last = ""; while (Date.now() < until) { - const asked = await on(`docker exec mesh-control /mesh-control status --json`); + const asked = await on(`docker exec mesh-controller /mesh-controller status --json`); if (asked.ok) { try { const state = JSON.parse(asked.out) as { @@ -136,12 +136,12 @@ before(async () => { instanceId = raised.instanceId; held = raised.images; - // Raise the substrate — store, broker, control — from the bundle. - await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); - await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); + // Raise the foundation — store, broker, control — from the bundle. + await must(`cat > /tmp/foundation.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); + await must(`${HOST_PATH} apply /tmp/foundation.lock`, 600_000); const up = await must(`docker ps --format '{{.Names}}'`); - for (const c of ["mesh-store", "mesh-broker", "mesh-control"]) { - assert.match(up, new RegExp(c), `the substrate did not raise ${c}:\n${up}`); + for (const c of ["mesh-store", "mesh-broker", "mesh-controller"]) { + assert.match(up, new RegExp(c), `the foundation did not raise ${c}:\n${up}`); } // The node joins its own mesh, so it is a node the mesh can assign to, and start the host so it @@ -340,7 +340,7 @@ test("the mesh assigns postgres, redis, minio and plex to one node in one push, // --- add, issue (the four that serve/emit), assign, then ONE push ------------------------------- async function add(name: string, manifest: string): Promise { - await must(`printf %s ${quote(manifest)} > /tmp/${name}.json && docker cp /tmp/${name}.json mesh-control:/${name}.json`); + await must(`printf %s ${quote(manifest)} > /tmp/${name}.json && docker cp /tmp/${name}.json mesh-controller:/${name}.json`); await mesh(`module add /${name}.json`); } @@ -455,7 +455,7 @@ test("the mesh assigns postgres, redis, minio and plex to one node in one push, // issue 032): for each consumer it reads the login the mesh derived and the mesh-minted password the // host unsealed, and creates the ACL user under exactly that login and password — sealing nothing // and writing no credential file. A hand-written contributions file and secret stand in for the - // control plane's write; their SHAPE is what mesh-control produces. The proof is authentication as + // control plane's write; their SHAPE is what mesh-controller produces. The proof is authentication as // the consumer with the mesh's password (PONG) — a provisioner that invented its own would answer // WRONGPASS. const redisPassword = "mesh-minted-9f3c2a"; diff --git a/test/integration/assigned-grafana.test.ts b/test/integration/assigned-grafana.test.ts index bea54a3..1d92897 100644 --- a/test/integration/assigned-grafana.test.ts +++ b/test/integration/assigned-grafana.test.ts @@ -10,7 +10,7 @@ * Grafana — that the serve queue is bound is the proof the settings reached the runtime and its * tools loaded from them. * - * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock + * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock * scripts/build-module-runtime.sh grafana builds mesh-runtime-grafana:development into the local * daemon, which scenarios/grafana-node.yml stocks — so no MESH_LAB_RUNTIME here; the host pulls it. */ @@ -22,7 +22,7 @@ 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, substrateBundle, onTheMachine } from "./harness.ts"; +import { labIsUsable, destroyAll, foundationBundle, onTheMachine } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -34,7 +34,7 @@ const skip = !capability.usable : !binary || !existsSync(binary) ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" : !bundle || !existsSync(bundle) - ? "MESH_LAB_BUNDLE is not set to a substrate bundle (mesh-host examples/)" + ? "MESH_LAB_BUNDLE is not set to a foundation bundle (mesh-host examples/)" : false; const SCENARIO = "grafana-node"; @@ -63,7 +63,7 @@ async function must(command: string, timeoutMs?: number): Promise { } async function mesh(command: string, timeoutMs?: number): Promise { - return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); + return must(`docker exec mesh-controller /mesh-controller ${command}`, timeoutMs); } /** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ @@ -72,7 +72,7 @@ function pinned(reference: string): string { } function bundleFor(images: HeldImage[]): string { - return substrateBundle(bundle, images); + return foundationBundle(bundle, images); } function tokenFrom(said: string): string { @@ -85,7 +85,7 @@ async function settled(withinMs = 480_000): Promise { const until = Date.now() + withinMs; let last = ""; while (Date.now() < until) { - const asked = await on(`docker exec mesh-control /mesh-control status --json`); + const asked = await on(`docker exec mesh-controller /mesh-controller status --json`); if (asked.ok) { try { const state = JSON.parse(asked.out) as { @@ -117,11 +117,11 @@ before(async () => { instanceId = raised.instanceId; held = raised.images; - await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); - await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); + await must(`cat > /tmp/foundation.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); + await must(`${HOST_PATH} apply /tmp/foundation.lock`, 600_000); const up = await must(`docker ps --format '{{.Names}}'`); - for (const c of ["mesh-store", "mesh-broker", "mesh-control"]) { - assert.match(up, new RegExp(c), `the substrate did not raise ${c}:\n${up}`); + for (const c of ["mesh-store", "mesh-broker", "mesh-controller"]) { + assert.match(up, new RegExp(c), `the foundation did not raise ${c}:\n${up}`); } await mesh(`node add ${MACHINE}`); @@ -163,13 +163,13 @@ test("the mesh assigns grafana's runtime, configured by settings, and it serves }, ], }); - await must(`printf %s ${quote(manifest)} > /tmp/grafana.json && docker cp /tmp/grafana.json mesh-control:/grafana.json`); + await must(`printf %s ${quote(manifest)} > /tmp/grafana.json && docker cp /tmp/grafana.json mesh-controller:/grafana.json`); await mesh("module add /grafana.json"); // The operator states grafana's URL and API token as settings for this node — the config the // runtime will read. Nothing about them is in the manifest. const settings = JSON.stringify({ url: "http://127.0.0.1:3000", token: "lab-grafana-token" }); - await must(`printf %s ${quote(settings)} > /tmp/grafana-settings.json && docker cp /tmp/grafana-settings.json mesh-control:/grafana-settings.json`); + await must(`printf %s ${quote(settings)} > /tmp/grafana-settings.json && docker cp /tmp/grafana-settings.json mesh-controller:/grafana-settings.json`); await mesh(`settings set grafana /grafana-settings.json --node ${MACHINE}`); const issued = await mesh(`module issue grafana --node ${MACHINE}`); diff --git a/test/integration/assigned-model-usage.test.ts b/test/integration/assigned-model-usage.test.ts index abdd1cf..c809e63 100644 --- a/test/integration/assigned-model-usage.test.ts +++ b/test/integration/assigned-model-usage.test.ts @@ -1,7 +1,7 @@ /** * The usage context store, proved end to end (novox/hq ADR 0054). * - * model-usage is a MODULE, not a control-plane feature, because mesh-control is a CLI and cannot + * model-usage is a MODULE, not a control-plane feature, because mesh-controller is a CLI and cannot * consume events: the store that keeps the latest usage reading has to be something that subscribes * to `module.*.usage.*` and upserts. This bed raises a real mesh, provisions model-usage its own * postgres store, emits usage events into the mesh, and reads the store back to prove the three @@ -14,15 +14,15 @@ * 3. IN THE CLEAR — the value and its raw payload are ordinary columns an ordinary select returns; * nothing about usage is sealed. * - * The topology mirrors two-node-db: the app-postgres provider and the mesh's own substrate store both - * want host port 5432, so the substrate (store, broker, control) owns `anchor` and NOTHING else, and + * The topology mirrors two-node-db: the app-postgres provider and the mesh's own foundation store both + * want host port 5432, so the foundation (store, broker, control) owns `anchor` and NOTHING else, and * `laptop` runs the postgres PROVIDER and the model-usage CONSUMER co-located. Only enrolment crosses * to anchor, over the underlay both machines share. * - * It needs a host binary and the substrate bundle: + * It needs a host binary and the foundation bundle: * * MESH_LAB_HOST_BINARY=.../mesh-host - * MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock + * MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock * * HELPER — stock the two runtimes into the local daemon before the run (some may already be there): * scripts/build-module-runtime.sh postgres /tmp/postgres.tar @@ -38,7 +38,7 @@ 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, substrateBundle, onTheMachine } from "./harness.ts"; +import { labIsUsable, destroyAll, foundationBundle, onTheMachine } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -50,12 +50,12 @@ const skip = !capability.usable : !binary || !existsSync(binary) ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" : !bundle || !existsSync(bundle) - ? "MESH_LAB_BUNDLE is not set to a substrate bundle (mesh-host examples/)" + ? "MESH_LAB_BUNDLE is not set to a foundation bundle (mesh-host examples/)" : false; const SCENARIO = "model-usage-bed"; /** The node that carries the postgres provider and the model-usage consumer. anchor carries only the - * substrate. */ + * foundation. */ const NODE = "laptop"; let instanceId = ""; @@ -83,7 +83,7 @@ async function must(machine: string, command: string, timeoutMs?: number): Promi /** The control plane, a container on the first node. */ async function mesh(command: string, timeoutMs?: number): Promise { - return must("anchor", `docker exec mesh-control /mesh-control ${command}`, timeoutMs); + return must("anchor", `docker exec mesh-controller /mesh-controller ${command}`, timeoutMs); } /** The reference a manifest should carry, once this scenario has been raised. */ @@ -92,9 +92,9 @@ function pinned(reference: string): string { return onTheMachine(reference, held); } -/** The substrate bundle: ours by the ID the machine holds, everything else upstream. */ +/** The foundation bundle: ours by the ID the machine holds, everything else upstream. */ function bundleFor(images: HeldImage[]): string { - return substrateBundle(bundle, images); + return foundationBundle(bundle, images); } function tokenFrom(said: string): string { @@ -120,7 +120,7 @@ async function settled(node: string, withinMs = 1_200_000): Promise { } | undefined; let said = ""; try { - const asked = await on("anchor", `docker exec mesh-control /mesh-control status --json`); + const asked = await on("anchor", `docker exec mesh-controller /mesh-controller status --json`); said = asked.out; if (asked.ok) state = JSON.parse(said); } catch (err) { @@ -160,11 +160,11 @@ before(async () => { instanceId = raised.instanceId; held = raised.images; - await must("anchor", `cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); - await must("anchor", `${HOST_PATH} apply /tmp/substrate.lock`, 600_000); + await must("anchor", `cat > /tmp/foundation.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); + await must("anchor", `${HOST_PATH} apply /tmp/foundation.lock`, 600_000); const up = await must("anchor", `docker ps --format '{{.Names}}'`); - for (const c of ["mesh-store", "mesh-broker", "mesh-control"]) { - assert.match(up, new RegExp(c), `the substrate did not raise ${c}:\n${up}`); + for (const c of ["mesh-store", "mesh-broker", "mesh-controller"]) { + assert.match(up, new RegExp(c), `the foundation did not raise ${c}:\n${up}`); } for (const [machine, node] of [["anchor", "anchor"], ["laptop", "laptop"]] as const) { @@ -269,7 +269,7 @@ test("usage events are upserted into model-usage's store — latest-per-key, bot }); async function addIssueAssign(name: string, manifest: string): Promise { - await must("anchor", `printf %s ${quote(manifest)} > /tmp/${name}.json && docker cp /tmp/${name}.json mesh-control:/${name}.json`); + await must("anchor", `printf %s ${quote(manifest)} > /tmp/${name}.json && docker cp /tmp/${name}.json mesh-controller:/${name}.json`); await mesh(`module add /${name}.json`); await mesh(`module issue ${name} --node ${NODE}`); await mesh(`assign ${NODE} ${name}`); @@ -289,7 +289,7 @@ test("usage events are upserted into model-usage's store — latest-per-key, bot await mesh(`push ${NODE}`); await settled(NODE); - // The provider owns 5432 on laptop; the substrate store owns it on anchor. + // The provider owns 5432 on laptop; the foundation store owns it on anchor. const onLaptop = await must(NODE, `docker ps --format '{{.Names}}'`); for (const name of ["postgres", "mesh-postgres", "mesh-model-usage"]) { assert.match(onLaptop, new RegExp(`(^|\\n)${name}(\\n|$)`), @@ -331,7 +331,7 @@ test("usage events are upserted into model-usage's store — latest-per-key, bot assert.match(tableReady, /^t$/m, `the usage table was never created (the consumer did not migrate):\n${tableReady}`); // Inject a usage event into the mesh. model-usage is a PURE CONSUMER, so its own broker account has - // no publish right (mesh-control grants write to mesh.events only to a module that declares `emits`). + // no publish right (mesh-controller grants write to mesh.events only to a module that declares `emits`). // So publish from the postgres provider's container — a publisher already on the node — overriding // the source header to the real producer the key names, exactly as events.test.ts injects with // `-e MESH_MODULE=...`. Write permission is per-exchange, not per-key, so postgres may carry any diff --git a/test/integration/assigned-plex.test.ts b/test/integration/assigned-plex.test.ts index 33c4b5c..14e6877 100644 --- a/test/integration/assigned-plex.test.ts +++ b/test/integration/assigned-plex.test.ts @@ -10,10 +10,10 @@ * proof the invocation routed to the assigned runtime, ran plex's real code, and replied, all under * the scoped account and never the broker's own. * - * It needs the host binary, the substrate bundle, and the runtime image the scenario loads: + * It needs the host binary, the foundation bundle, and the runtime image the scenario loads: * * MESH_LAB_HOST_BINARY=.../mesh-host - * MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock + * MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock * scripts/build-module-runtime.sh plex builds mesh-runtime-plex:development into the local daemon, * which scenarios/plex-node.yml stocks — so no MESH_LAB_RUNTIME here; the host pulls it. */ @@ -25,7 +25,7 @@ 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, substrateBundle, onTheMachine } from "./harness.ts"; +import { labIsUsable, destroyAll, foundationBundle, onTheMachine } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -37,7 +37,7 @@ const skip = !capability.usable : !binary || !existsSync(binary) ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" : !bundle || !existsSync(bundle) - ? "MESH_LAB_BUNDLE is not set to a substrate bundle (mesh-host examples/)" + ? "MESH_LAB_BUNDLE is not set to a foundation bundle (mesh-host examples/)" : false; const SCENARIO = "plex-node"; @@ -68,7 +68,7 @@ async function must(command: string, timeoutMs?: number): Promise { /** The control plane, a container on the node. */ async function mesh(command: string, timeoutMs?: number): Promise { - return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); + return must(`docker exec mesh-controller /mesh-controller ${command}`, timeoutMs); } /** The reference a manifest should carry, once this scenario has been raised. */ @@ -77,9 +77,9 @@ function pinned(reference: string): string { return onTheMachine(reference, held); } -/** The substrate bundle: ours by the ID the machine holds, everything else upstream. */ +/** The foundation bundle: ours by the ID the machine holds, everything else upstream. */ function bundleFor(images: HeldImage[]): string { - return substrateBundle(bundle, images); + return foundationBundle(bundle, images); } function tokenFrom(said: string): string { @@ -92,7 +92,7 @@ async function settled(withinMs = 480_000): Promise { const until = Date.now() + withinMs; let last = ""; while (Date.now() < until) { - const asked = await on(`docker exec mesh-control /mesh-control status --json`); + const asked = await on(`docker exec mesh-controller /mesh-controller status --json`); if (asked.ok) { try { const state = JSON.parse(asked.out) as { @@ -124,12 +124,12 @@ before(async () => { instanceId = raised.instanceId; held = raised.images; - // Raise the substrate — store, broker, control — from the bundle. - await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); - await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); + // Raise the foundation — store, broker, control — from the bundle. + await must(`cat > /tmp/foundation.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); + await must(`${HOST_PATH} apply /tmp/foundation.lock`, 600_000); const up = await must(`docker ps --format '{{.Names}}'`); - for (const c of ["mesh-store", "mesh-broker", "mesh-control"]) { - assert.match(up, new RegExp(c), `the substrate did not raise ${c}:\n${up}`); + for (const c of ["mesh-store", "mesh-broker", "mesh-controller"]) { + assert.match(up, new RegExp(c), `the foundation did not raise ${c}:\n${up}`); } // The node joins its own mesh, so it is a node the mesh can assign to, and start the host so it @@ -176,7 +176,7 @@ test("the mesh assigns plex's runtime, and it serves plex's tools over the accou }, ], }); - await must(`printf %s ${quote(manifest)} > /tmp/plex.json && docker cp /tmp/plex.json mesh-control:/plex.json`); + await must(`printf %s ${quote(manifest)} > /tmp/plex.json && docker cp /tmp/plex.json mesh-controller:/plex.json`); await mesh("module add /plex.json"); // The mesh issues plex's scoped account and seals it to this machine, then assigns and pushes it. @@ -210,7 +210,7 @@ test("the mesh assigns plex's runtime, and it serves plex's tools over the accou `plex's runtime never bound its serve queue:\n${(await on(`docker logs mesh-plex 2>&1 | tail -20`)).out}\n---\n${served}`); // A caller invokes plex.plex_reachable over the mesh, from the bootstrap account (a caller, like - // mesh-control's command API — plex's own account serves, it does not call). The reply is the + // mesh-controller's command API — plex's own account serves, it does not call). The reply is the // tool's own answer: it ran in the assigned runtime and reported the Plex server is unreachable // (there is none in the lab). A reply at all — not a timeout — is the proof the invocation routed // to the assigned runtime and ran plex's real code under its scoped account. diff --git a/test/integration/assigned-redis.test.ts b/test/integration/assigned-redis.test.ts index 647d889..2e8cea0 100644 --- a/test/integration/assigned-redis.test.ts +++ b/test/integration/assigned-redis.test.ts @@ -12,9 +12,9 @@ * has no way yet to deliver one to a provider's runtime (04-ISSUES). The manifest here sets a * lab-local key so the mechanism can be proven; the delivery is a separate, open design question. * - * It needs the host binary, the substrate bundle, and the runtime image the scenario loads: + * It needs the host binary, the foundation bundle, and the runtime image the scenario loads: * - * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock + * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock * scripts/build-module-runtime.sh redis builds mesh-runtime-redis:development into the local * daemon, which scenarios/redis-node.yml stocks — so no MESH_LAB_RUNTIME here; the host pulls it. */ @@ -26,7 +26,7 @@ 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, substrateBundle, onTheMachine } from "./harness.ts"; +import { labIsUsable, destroyAll, foundationBundle, onTheMachine } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -38,7 +38,7 @@ const skip = !capability.usable : !binary || !existsSync(binary) ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" : !bundle || !existsSync(bundle) - ? "MESH_LAB_BUNDLE is not set to a substrate bundle (mesh-host examples/)" + ? "MESH_LAB_BUNDLE is not set to a foundation bundle (mesh-host examples/)" : false; const SCENARIO = "redis-node"; @@ -67,7 +67,7 @@ async function must(command: string, timeoutMs?: number): Promise { } async function mesh(command: string, timeoutMs?: number): Promise { - return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); + return must(`docker exec mesh-controller /mesh-controller ${command}`, timeoutMs); } /** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ @@ -76,7 +76,7 @@ function pinned(reference: string): string { } function bundleFor(images: HeldImage[]): string { - return substrateBundle(bundle, images); + return foundationBundle(bundle, images); } function tokenFrom(said: string): string { @@ -89,7 +89,7 @@ async function settled(withinMs = 480_000): Promise { const until = Date.now() + withinMs; let last = ""; while (Date.now() < until) { - const asked = await on(`docker exec mesh-control /mesh-control status --json`); + const asked = await on(`docker exec mesh-controller /mesh-controller status --json`); if (asked.ok) { try { const state = JSON.parse(asked.out) as { @@ -121,11 +121,11 @@ before(async () => { instanceId = raised.instanceId; held = raised.images; - await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); - await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); + await must(`cat > /tmp/foundation.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); + await must(`${HOST_PATH} apply /tmp/foundation.lock`, 600_000); const up = await must(`docker ps --format '{{.Names}}'`); - for (const c of ["mesh-store", "mesh-broker", "mesh-control"]) { - assert.match(up, new RegExp(c), `the substrate did not raise ${c}:\n${up}`); + for (const c of ["mesh-store", "mesh-broker", "mesh-controller"]) { + assert.match(up, new RegExp(c), `the foundation did not raise ${c}:\n${up}`); } await mesh(`node add ${MACHINE}`); @@ -151,7 +151,7 @@ test("the mesh assigns redis, and its runtime serves tools and provisions grants version: "1", emits: ["module.redis.cache.provisioned", "module.redis.cache.deprovisioned"], // redis's events entrypoint subscribes to its own lifecycle events (an audit-trail log), so it - // consumes them too — declared, or the substrate never makes the queue the runtime binds and it + // consumes them too — declared, or the foundation never makes the queue the runtime binds and it // crashes on start with a 404 (novox/hq ADR 0046: a consume is declared). consumes: ["module.redis.cache.provisioned", "module.redis.cache.deprovisioned"], "own-secrets": { default: "/var/lib/redis-module/default.secret", broker: "/var/lib/mesh/redis/broker" }, @@ -190,7 +190,7 @@ test("the mesh assigns redis, and its runtime serves tools and provisions grants }, ], }); - await must(`printf %s ${quote(manifest)} > /tmp/redis.json && docker cp /tmp/redis.json mesh-control:/redis.json`); + await must(`printf %s ${quote(manifest)} > /tmp/redis.json && docker cp /tmp/redis.json mesh-controller:/redis.json`); await mesh("module add /redis.json"); const issued = await mesh(`module issue redis --node ${MACHINE}`); diff --git a/test/integration/assigned-schedule-tick.test.ts b/test/integration/assigned-schedule-tick.test.ts index 7cf53f8..e7487d1 100644 --- a/test/integration/assigned-schedule-tick.test.ts +++ b/test/integration/assigned-schedule-tick.test.ts @@ -25,8 +25,8 @@ * registry by digest; the host pulls and runs it on the cadence. * * MESH_LAB_HOST_BINARY=.../mesh-host (feat/apply-schedule — the scheduler that fires the step) - * MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock - * MESH_LAB_MODULES=.../mesh-control/examples/modules (feat/schedule-container — the parser that + * MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock + * MESH_LAB_MODULES=.../mesh-controller/examples/modules (feat/schedule-container — the parser that * carries `schedule` through). scenarios/schedule-tick.yml stocks alpine:latest (which must be in * the local daemon) and serves it by digest; there is no runtime image — schedtest is a bare tick. */ @@ -38,7 +38,7 @@ 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, substrateBundle, onTheMachine } from "./harness.ts"; +import { labIsUsable, destroyAll, foundationBundle, onTheMachine } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -50,7 +50,7 @@ const skip = !capability.usable : !binary || !existsSync(binary) ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" : !bundle || !existsSync(bundle) - ? "MESH_LAB_BUNDLE is not set to a substrate bundle (mesh-host examples/)" + ? "MESH_LAB_BUNDLE is not set to a foundation bundle (mesh-host examples/)" : false; const SCENARIO = "schedule-tick"; @@ -81,7 +81,7 @@ async function must(command: string, timeoutMs?: number): Promise { /** The control plane, a container on the node. */ async function mesh(command: string, timeoutMs?: number): Promise { - return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); + return must(`docker exec mesh-controller /mesh-controller ${command}`, timeoutMs); } /** The reference a manifest should carry, once this scenario has been raised. */ @@ -90,9 +90,9 @@ function pinned(reference: string): string { return onTheMachine(reference, held); } -/** The substrate bundle: ours by the ID the machine holds, everything else upstream. */ +/** The foundation bundle: ours by the ID the machine holds, everything else upstream. */ function bundleFor(images: HeldImage[]): string { - return substrateBundle(bundle, images); + return foundationBundle(bundle, images); } function tokenFrom(said: string): string { @@ -119,7 +119,7 @@ async function settled(withinMs = 600_000): Promise { const until = Date.now() + withinMs; let last = ""; while (Date.now() < until) { - const asked = await on(`docker exec mesh-control /mesh-control status --json`); + const asked = await on(`docker exec mesh-controller /mesh-controller status --json`); if (asked.ok) { try { const state = JSON.parse(asked.out) as { @@ -151,12 +151,12 @@ before(async () => { instanceId = raised.instanceId; held = raised.images; - // Raise the substrate — store, broker, control — from the bundle. - await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); - await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); + // Raise the foundation — store, broker, control — from the bundle. + await must(`cat > /tmp/foundation.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); + await must(`${HOST_PATH} apply /tmp/foundation.lock`, 600_000); const up = await must(`docker ps --format '{{.Names}}'`); - for (const c of ["mesh-store", "mesh-broker", "mesh-control"]) { - assert.match(up, new RegExp(c), `the substrate did not raise ${c}:\n${up}`); + for (const c of ["mesh-store", "mesh-broker", "mesh-controller"]) { + assert.match(up, new RegExp(c), `the foundation did not raise ${c}:\n${up}`); } // The node joins its own mesh, so it is a node the mesh can assign to, and start the host so it @@ -197,7 +197,7 @@ test("the mesh assigns schedtest: installing the schedule does not run it, and t ], }); - await must(`printf %s ${quote(manifest)} > /tmp/schedtest.json && docker cp /tmp/schedtest.json mesh-control:/schedtest.json`); + await must(`printf %s ${quote(manifest)} > /tmp/schedtest.json && docker cp /tmp/schedtest.json mesh-controller:/schedtest.json`); await mesh("module add /schedtest.json"); // No `module issue`: schedtest serves/consumes nothing and carries no runtime, so it needs no // broker account. `assign` resolves its plan (no own-secret to fill) and ONE push converges it. diff --git a/test/integration/assigned-sonarr.test.ts b/test/integration/assigned-sonarr.test.ts index 656f903..dc63f19 100644 --- a/test/integration/assigned-sonarr.test.ts +++ b/test/integration/assigned-sonarr.test.ts @@ -8,7 +8,7 @@ * Sonarr here), registers its tools, and serves them under a scoped account. There is no live Sonarr * to reach — that the serve queue is bound is the proof the key was detected and the tools loaded. * - * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock + * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock * scripts/build-module-runtime.sh sonarr builds mesh-runtime-sonarr:development into the local * daemon, which scenarios/sonarr-node.yml stocks — so no MESH_LAB_RUNTIME here; the host pulls it. */ @@ -20,7 +20,7 @@ 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, substrateBundle, onTheMachine } from "./harness.ts"; +import { labIsUsable, destroyAll, foundationBundle, onTheMachine } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -32,7 +32,7 @@ const skip = !capability.usable : !binary || !existsSync(binary) ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" : !bundle || !existsSync(bundle) - ? "MESH_LAB_BUNDLE is not set to a substrate bundle (mesh-host examples/)" + ? "MESH_LAB_BUNDLE is not set to a foundation bundle (mesh-host examples/)" : false; const SCENARIO = "sonarr-node"; @@ -61,7 +61,7 @@ async function must(command: string, timeoutMs?: number): Promise { } async function mesh(command: string, timeoutMs?: number): Promise { - return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); + return must(`docker exec mesh-controller /mesh-controller ${command}`, timeoutMs); } /** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ @@ -70,7 +70,7 @@ function pinned(reference: string): string { } function bundleFor(images: HeldImage[]): string { - return substrateBundle(bundle, images); + return foundationBundle(bundle, images); } function tokenFrom(said: string): string { @@ -83,7 +83,7 @@ async function settled(withinMs = 480_000): Promise { const until = Date.now() + withinMs; let last = ""; while (Date.now() < until) { - const asked = await on(`docker exec mesh-control /mesh-control status --json`); + const asked = await on(`docker exec mesh-controller /mesh-controller status --json`); if (asked.ok) { try { const state = JSON.parse(asked.out) as { @@ -115,11 +115,11 @@ before(async () => { instanceId = raised.instanceId; held = raised.images; - await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); - await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); + await must(`cat > /tmp/foundation.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); + await must(`${HOST_PATH} apply /tmp/foundation.lock`, 600_000); const up = await must(`docker ps --format '{{.Names}}'`); - for (const c of ["mesh-store", "mesh-broker", "mesh-control"]) { - assert.match(up, new RegExp(c), `the substrate did not raise ${c}:\n${up}`); + for (const c of ["mesh-store", "mesh-broker", "mesh-controller"]) { + assert.match(up, new RegExp(c), `the foundation did not raise ${c}:\n${up}`); } await mesh(`node add ${MACHINE}`); @@ -167,7 +167,7 @@ test("the mesh assigns sonarr's runtime, and it detects its key and serves its t }, ], }); - await must(`printf %s ${quote(manifest)} > /tmp/sonarr.json && docker cp /tmp/sonarr.json mesh-control:/sonarr.json`); + await must(`printf %s ${quote(manifest)} > /tmp/sonarr.json && docker cp /tmp/sonarr.json mesh-controller:/sonarr.json`); await mesh("module add /sonarr.json"); const issued = await mesh(`module issue sonarr --node ${MACHINE}`); diff --git a/test/integration/assigned-tools-confluence.test.ts b/test/integration/assigned-tools-confluence.test.ts index 681ebf3..00f3fed 100644 --- a/test/integration/assigned-tools-confluence.test.ts +++ b/test/integration/assigned-tools-confluence.test.ts @@ -17,7 +17,7 @@ * A tool would only fail if it were actually invoked without real creds — which this bed does not do, * because the point is exactly that serving does not require them. * - * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock + * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock * scripts/build-module-runtime.sh confluence builds mesh-runtime-confluence:development into the * local daemon; scenarios/tools-confluence.yml stocks it. There is no service image. */ @@ -29,7 +29,7 @@ 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, substrateBundle, onTheMachine } from "./harness.ts"; +import { labIsUsable, destroyAll, foundationBundle, onTheMachine } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -41,7 +41,7 @@ const skip = !capability.usable : !binary || !existsSync(binary) ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" : !bundle || !existsSync(bundle) - ? "MESH_LAB_BUNDLE is not set to a substrate bundle (mesh-host examples/)" + ? "MESH_LAB_BUNDLE is not set to a foundation bundle (mesh-host examples/)" : false; const SCENARIO = "tools-confluence"; @@ -72,7 +72,7 @@ async function must(command: string, timeoutMs?: number): Promise { /** The control plane, a container on the node. */ async function mesh(command: string, timeoutMs?: number): Promise { - return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); + return must(`docker exec mesh-controller /mesh-controller ${command}`, timeoutMs); } /** The reference a manifest should carry, once this scenario has been raised. */ @@ -81,9 +81,9 @@ function pinned(reference: string): string { return onTheMachine(reference, held); } -/** The substrate bundle: ours by the ID the machine holds, everything else upstream. */ +/** The foundation bundle: ours by the ID the machine holds, everything else upstream. */ function bundleFor(images: HeldImage[]): string { - return substrateBundle(bundle, images); + return foundationBundle(bundle, images); } function tokenFrom(said: string): string { @@ -96,7 +96,7 @@ async function settled(withinMs = 600_000): Promise { const until = Date.now() + withinMs; let last = ""; while (Date.now() < until) { - const asked = await on(`docker exec mesh-control /mesh-control status --json`); + const asked = await on(`docker exec mesh-controller /mesh-controller status --json`); if (asked.ok) { try { const state = JSON.parse(asked.out) as { @@ -128,12 +128,12 @@ before(async () => { instanceId = raised.instanceId; held = raised.images; - // Raise the substrate — store, broker, control — from the bundle. - await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); - await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); + // Raise the foundation — store, broker, control — from the bundle. + await must(`cat > /tmp/foundation.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); + await must(`${HOST_PATH} apply /tmp/foundation.lock`, 600_000); const up = await must(`docker ps --format '{{.Names}}'`); - for (const c of ["mesh-store", "mesh-broker", "mesh-control"]) { - assert.match(up, new RegExp(c), `the substrate did not raise ${c}:\n${up}`); + for (const c of ["mesh-store", "mesh-broker", "mesh-controller"]) { + assert.match(up, new RegExp(c), `the foundation did not raise ${c}:\n${up}`); } // The node joins its own mesh, so it is a node the mesh can assign to, and start the host so it @@ -185,7 +185,7 @@ test("the mesh assigns confluence: its tools-only runtime comes up and serves th ], }); - await must(`printf %s ${quote(manifest)} > /tmp/confluence.json && docker cp /tmp/confluence.json mesh-control:/confluence.json`); + await must(`printf %s ${quote(manifest)} > /tmp/confluence.json && docker cp /tmp/confluence.json mesh-controller:/confluence.json`); await mesh("module add /confluence.json"); // confluence serves tools, so it is issued a scoped broker account (it emits/consumes nothing else). const issued = await mesh(`module issue confluence --node ${MACHINE}`); diff --git a/test/integration/assigned-tools-gitlab.test.ts b/test/integration/assigned-tools-gitlab.test.ts index 70fcc19..622882d 100644 --- a/test/integration/assigned-tools-gitlab.test.ts +++ b/test/integration/assigned-tools-gitlab.test.ts @@ -16,7 +16,7 @@ * A tool would only fail if it were actually invoked without real creds — which this bed does not do, * because the point is exactly that serving does not require them. * - * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock + * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock * scripts/build-module-runtime.sh gitlab builds mesh-runtime-gitlab:development into the local * daemon; scenarios/tools-gitlab.yml stocks it. There is no service image — gitlab is tools-only. */ @@ -28,7 +28,7 @@ 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, substrateBundle, onTheMachine } from "./harness.ts"; +import { labIsUsable, destroyAll, foundationBundle, onTheMachine } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -40,7 +40,7 @@ const skip = !capability.usable : !binary || !existsSync(binary) ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" : !bundle || !existsSync(bundle) - ? "MESH_LAB_BUNDLE is not set to a substrate bundle (mesh-host examples/)" + ? "MESH_LAB_BUNDLE is not set to a foundation bundle (mesh-host examples/)" : false; const SCENARIO = "tools-gitlab"; @@ -71,7 +71,7 @@ async function must(command: string, timeoutMs?: number): Promise { /** The control plane, a container on the node. */ async function mesh(command: string, timeoutMs?: number): Promise { - return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); + return must(`docker exec mesh-controller /mesh-controller ${command}`, timeoutMs); } /** The reference a manifest should carry, once this scenario has been raised. */ @@ -80,9 +80,9 @@ function pinned(reference: string): string { return onTheMachine(reference, held); } -/** The substrate bundle: ours by the ID the machine holds, everything else upstream. */ +/** The foundation bundle: ours by the ID the machine holds, everything else upstream. */ function bundleFor(images: HeldImage[]): string { - return substrateBundle(bundle, images); + return foundationBundle(bundle, images); } function tokenFrom(said: string): string { @@ -95,7 +95,7 @@ async function settled(withinMs = 600_000): Promise { const until = Date.now() + withinMs; let last = ""; while (Date.now() < until) { - const asked = await on(`docker exec mesh-control /mesh-control status --json`); + const asked = await on(`docker exec mesh-controller /mesh-controller status --json`); if (asked.ok) { try { const state = JSON.parse(asked.out) as { @@ -127,12 +127,12 @@ before(async () => { instanceId = raised.instanceId; held = raised.images; - // Raise the substrate — store, broker, control — from the bundle. - await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); - await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); + // Raise the foundation — store, broker, control — from the bundle. + await must(`cat > /tmp/foundation.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); + await must(`${HOST_PATH} apply /tmp/foundation.lock`, 600_000); const up = await must(`docker ps --format '{{.Names}}'`); - for (const c of ["mesh-store", "mesh-broker", "mesh-control"]) { - assert.match(up, new RegExp(c), `the substrate did not raise ${c}:\n${up}`); + for (const c of ["mesh-store", "mesh-broker", "mesh-controller"]) { + assert.match(up, new RegExp(c), `the foundation did not raise ${c}:\n${up}`); } // The node joins its own mesh, so it is a node the mesh can assign to, and start the host so it @@ -184,7 +184,7 @@ test("the mesh assigns gitlab: its tools-only runtime comes up and serves the fu ], }); - await must(`printf %s ${quote(manifest)} > /tmp/gitlab.json && docker cp /tmp/gitlab.json mesh-control:/gitlab.json`); + await must(`printf %s ${quote(manifest)} > /tmp/gitlab.json && docker cp /tmp/gitlab.json mesh-controller:/gitlab.json`); await mesh("module add /gitlab.json"); // gitlab serves tools, so it is issued a scoped broker account (it emits/consumes nothing else). const issued = await mesh(`module issue gitlab --node ${MACHINE}`); diff --git a/test/integration/assigned-two-node-db.test.ts b/test/integration/assigned-two-node-db.test.ts index 33ea3d0..d8602f8 100644 --- a/test/integration/assigned-two-node-db.test.ts +++ b/test/integration/assigned-two-node-db.test.ts @@ -1,18 +1,18 @@ /** * The DB-consumer chain a single node cannot host, proved across two machines. * - * app-postgres and the mesh's own substrate store both want host port 5432, so they cannot share a - * machine. Every earlier catalogue bed put the provider on the same node as the substrate and got + * app-postgres and the mesh's own foundation store both want host port 5432, so they cannot share a + * machine. Every earlier catalogue bed put the provider on the same node as the foundation and got * away with it only because the provider published no 5432 a consumer ever reached, or because the - * substrate's store and the module's postgres were the same container. The moment a real + * foundation's store and the module's postgres were the same container. The moment a real * postgres PROVIDER must publish 5432 for real consumers to connect, it collides with the store the - * substrate already has there — and the chain is blocked single-node. + * foundation already has there — and the chain is blocked single-node. * - * This is the split that unblocks it. `anchor` runs the substrate (store, broker, control) and + * This is the split that unblocks it. `anchor` runs the foundation (store, broker, control) and * NOTHING else. `laptop` runs the whole chain: the postgres and redis PROVIDERS, and the baserow * and letta CONSUMERS that require them. Provider and consumers are co-located on laptop, so the * grant never crosses a node boundary and no overlay is needed — only enrolment crosses to anchor, - * over the underlay both machines share. And because the substrate store is on the OTHER node, the + * over the underlay both machines share. And because the foundation store is on the OTHER node, the * provider owns laptop's 5432 uncontested. * * The four manifests are the committed catalogue shapes (novox/hq ADR 0039/0047/0048), verbatim @@ -22,10 +22,10 @@ * ONCE; laptop converges once with every one up, and the two consumers are provisioned against the * database the provider on their own node gave them. * - * It needs a host binary and the substrate bundle: + * It needs a host binary and the foundation bundle: * * MESH_LAB_HOST_BINARY=.../mesh-host - * MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock + * MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock * * HELPER — stock the four runtimes into the local daemon before the run (some may already be there): * scripts/build-module-runtime.sh postgres /tmp/postgres.tar @@ -44,7 +44,7 @@ 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, substrateBundle, onTheMachine } from "./harness.ts"; +import { labIsUsable, destroyAll, foundationBundle, onTheMachine } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -56,11 +56,11 @@ const skip = !capability.usable : !binary || !existsSync(binary) ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" : !bundle || !existsSync(bundle) - ? "MESH_LAB_BUNDLE is not set to a substrate bundle (mesh-host examples/)" + ? "MESH_LAB_BUNDLE is not set to a foundation bundle (mesh-host examples/)" : false; const SCENARIO = "two-node-db"; -/** The node that carries the whole DB-consumer chain. anchor carries only the substrate. */ +/** The node that carries the whole DB-consumer chain. anchor carries only the foundation. */ const NODE = "laptop"; let instanceId = ""; @@ -88,7 +88,7 @@ async function must(machine: string, command: string, timeoutMs?: number): Promi /** The control plane, a container on the first node. */ async function mesh(command: string, timeoutMs?: number): Promise { - return must("anchor", `docker exec mesh-control /mesh-control ${command}`, timeoutMs); + return must("anchor", `docker exec mesh-controller /mesh-controller ${command}`, timeoutMs); } /** The reference a manifest should carry, once this scenario has been raised. */ @@ -97,9 +97,9 @@ function pinned(reference: string): string { return onTheMachine(reference, held); } -/** The substrate bundle: ours by the ID the machine holds, everything else upstream. */ +/** The foundation bundle: ours by the ID the machine holds, everything else upstream. */ function bundleFor(images: HeldImage[]): string { - return substrateBundle(bundle, images); + return foundationBundle(bundle, images); } function tokenFrom(said: string): string { @@ -128,7 +128,7 @@ async function settled(node: string, withinMs = 1_200_000): Promise { } | undefined; let said = ""; try { - const asked = await on("anchor", `docker exec mesh-control /mesh-control status --json`); + const asked = await on("anchor", `docker exec mesh-controller /mesh-controller status --json`); said = asked.out; if (asked.ok) state = JSON.parse(said); } catch (err) { @@ -169,13 +169,13 @@ before(async () => { instanceId = raised.instanceId; held = raised.images; - // The first node raises the substrate — store, broker, control — from the bundle its host carries, + // The first node raises the foundation — store, broker, control — from the bundle its host carries, // its digests rewritten to the ones this scenario's own registry serves. - await must("anchor", `cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); - await must("anchor", `${HOST_PATH} apply /tmp/substrate.lock`, 600_000); + await must("anchor", `cat > /tmp/foundation.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); + await must("anchor", `${HOST_PATH} apply /tmp/foundation.lock`, 600_000); const up = await must("anchor", `docker ps --format '{{.Names}}'`); - for (const c of ["mesh-store", "mesh-broker", "mesh-control"]) { - assert.match(up, new RegExp(c), `the substrate did not raise ${c}:\n${up}`); + for (const c of ["mesh-store", "mesh-broker", "mesh-controller"]) { + assert.match(up, new RegExp(c), `the foundation did not raise ${c}:\n${up}`); } // Both machines join the one mesh, each with a token that says what the mesh calls it, and each @@ -196,7 +196,7 @@ after(async () => { await destroyAll(`${SCENARIO}-`); }, { timeout: 600_000 }); -test("the provider and its consumers ride the second node while the substrate owns 5432 on the first", { +test("the provider and its consumers ride the second node while the foundation owns 5432 on the first", { skip, timeout: 1_500_000, }, async () => { // ================================================================================================ @@ -392,7 +392,7 @@ test("the provider and its consumers ride the second node while the substrate ow // --- add, issue a scoped broker account, assign to laptop, then ONE push ------------------------- async function addIssueAssign(name: string, manifest: string): Promise { - await must("anchor", `printf %s ${quote(manifest)} > /tmp/${name}.json && docker cp /tmp/${name}.json mesh-control:/${name}.json`); + await must("anchor", `printf %s ${quote(manifest)} > /tmp/${name}.json && docker cp /tmp/${name}.json mesh-controller:/${name}.json`); await mesh(`module add /${name}.json`); const issued = await mesh(`module issue ${name} --node ${NODE}`); assert.match(issued, /scoped to what it emits and consumes/, issued); @@ -400,7 +400,7 @@ test("the provider and its consumers ride the second node while the substrate ow } // The consumer connects to its provider by the provider's PRIVATE-NETWORK address — the binding's - // `at`, which mesh-control fills as "where the consuming machine is on the private network, empty + // `at`, which mesh-controller fills as "where the consuming machine is on the private network, empty // if it is not on one" (declaration.go). So even though provider and consumer are co-located on // laptop, the address baserow is handed is the mesh OVERLAY address, and it is empty unless the // machine is on the overlay. The overlay networking is therefore assigned first, to both nodes. @@ -411,7 +411,7 @@ test("the provider and its consumers ride the second node while the substrate ow // Providers first, then the consumers that require them. The mesh resolves the whole set at push // time regardless of order; this order simply reads like the dependency graph. Provider AND - // consumers all go to laptop; the 5432 conflict is gone because the substrate store is on anchor. + // consumers all go to laptop; the 5432 conflict is gone because the foundation store is on anchor. await addIssueAssign("postgres", postgresManifest); await addIssueAssign("redis", redisManifest); await addIssueAssign("baserow", baserowManifest); @@ -422,14 +422,14 @@ test("the provider and its consumers ride the second node while the substrate ow await settled(NODE); // ================================================================================================ - // THE two-node split — the substrate owns 5432 on anchor, the provider owns it on laptop. + // THE two-node split — the foundation owns 5432 on anchor, the provider owns it on laptop. // ================================================================================================ const onAnchor = await must("anchor", `docker ps --format '{{.Names}}'`); const onLaptop = await must(NODE, `docker ps --format '{{.Names}}'`); - assert.match(onAnchor, /(^|\n)mesh-store(\n|$)/, "the substrate store is not on the first node"); + assert.match(onAnchor, /(^|\n)mesh-store(\n|$)/, "the foundation store is not on the first node"); assert.doesNotMatch(onAnchor, /(^|\n)postgres(\n|$)/, - "the postgres provider landed on the substrate node — the 5432 collision this bed exists to avoid"); - assert.doesNotMatch(onLaptop, /(^|\n)mesh-store(\n|$)/, "the substrate store leaked onto the second node"); + "the postgres provider landed on the foundation node — the 5432 collision this bed exists to avoid"); + assert.doesNotMatch(onLaptop, /(^|\n)mesh-store(\n|$)/, "the foundation store leaked onto the second node"); assert.match(onLaptop, /(^|\n)postgres(\n|$)/, "the postgres provider is not on the second node"); // ================================================================================================ @@ -453,7 +453,7 @@ test("the provider and its consumers ride the second node while the substrate ow // REGRESSION (provider-seal-key): baserow's server.env DATABASE_PASSWORD is filled from the // ${secret:postgres-database} placeholder; its database.secret file carries the same credential via // the secrets: map. Both are baserow's one postgres password and MUST be equal. Before the - // mesh-control fix (secrets_into_files.go matched a need by provision name alone, not by consuming + // mesh-controller fix (secrets_into_files.go matched a need by provision name alone, not by consuming // module) the placeholder path took whichever co-located consumer came last — letta's — so the two // diverged and baserow authenticated with the wrong password. novox/hq 04-ISSUES/022. { @@ -496,7 +496,7 @@ test("the provider and its consumers ride the second node while the substrate ow } // ================================================================================================ - // Each module got its own scoped broker account on the substrate's broker (which is on anchor, + // Each module got its own scoped broker account on the foundation's broker (which is on anchor, // reached from laptop over the shared segment) — named for the node that runs it and the module. // ================================================================================================ const users = await must("anchor", `docker exec mesh-broker lavinmqctl list_users 2>&1`); diff --git a/test/integration/builds.test.ts b/test/integration/builds.test.ts index 539068b..5118a44 100644 --- a/test/integration/builds.test.ts +++ b/test/integration/builds.test.ts @@ -9,7 +9,7 @@ * agree over a wire. Everything either side of the wire is already asserted in its own suite. * * MESH_LAB_HOST_BINARY a built mesh-host - * MESH_LAB_BUNDLE the substrate bundle + * MESH_LAB_BUNDLE the foundation bundle * MESH_LAB_BUILDER a built mesh-builder */ @@ -20,7 +20,7 @@ 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, substrateBundle } from "./harness.ts"; +import { labIsUsable, destroyAll, foundationBundle } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; import { incus } from "../../src/incus/client.ts"; import { machineName } from "../../src/lifecycle/names.ts"; @@ -35,7 +35,7 @@ const skip = !capability.usable : !host || !existsSync(host) ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" : !bundle || !existsSync(bundle) - ? "MESH_LAB_BUNDLE is not set to a substrate bundle" + ? "MESH_LAB_BUNDLE is not set to a foundation bundle" : !builder || !existsSync(builder) ? "MESH_LAB_BUILDER is not set to a built mesh-builder" : false; @@ -79,12 +79,12 @@ async function must(command: string): Promise { } async function mesh(command: string): Promise { - return must(`docker exec mesh-control /mesh-control ${command}`); + return must(`docker exec mesh-controller /mesh-controller ${command}`); } /** The bundle: ours by the ID the machine holds, everything else upstream. */ function bundleFor(images: HeldImage[]): string { - return substrateBundle(bundle, images); + return foundationBundle(bundle, images); } before(async () => { @@ -92,8 +92,8 @@ before(async () => { const raised = await raise(loadScenario(`scenarios/${SCENARIO}.yml`), {}); instanceId = raised.instanceId; - await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); - await must(`${HOST_PATH} apply /tmp/substrate.lock`); + await must(`cat > /tmp/foundation.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); + await must(`${HOST_PATH} apply /tmp/foundation.lock`); // The mesh's artifact store, standing where the `registry` module would. Read back rather than // assumed: a builder publishing into a registry that never came up fails several minutes later, @@ -166,7 +166,7 @@ test("a build that cannot succeed says why, and records nothing", { skip, timeou // A failure is a result. A build that fails silently is indistinguishable from a builder that // is not running, and those want completely different responses. const { out, ok } = await on( - `docker exec mesh-control /mesh-control build /root/does-not-exist --wait 120s`, + `docker exec mesh-controller /mesh-controller build /root/does-not-exist --wait 120s`, ); assert.equal(ok, false, "a build of nothing reported success"); assert.match(out, /could not build/, out); diff --git a/test/integration/canary.test.ts b/test/integration/canary.test.ts index 7584f22..e94b27e 100644 --- a/test/integration/canary.test.ts +++ b/test/integration/canary.test.ts @@ -35,7 +35,7 @@ const skip = !capability.usable : !host || !existsSync(host) ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" : !bundle || !existsSync(bundle) - ? "MESH_LAB_BUNDLE is not set to a substrate bundle" + ? "MESH_LAB_BUNDLE is not set to a foundation bundle" : false; const SCENARIO = "first-node"; @@ -62,7 +62,7 @@ async function must(command: string, timeoutMs?: number): Promise { } const mesh = (command: string, timeoutMs?: number) => - must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); + must(`docker exec mesh-controller /mesh-controller ${command}`, timeoutMs); before(async () => { if (skip) return; @@ -70,9 +70,9 @@ before(async () => { onProgress: (m) => console.log(`raise: ${m}`), }); instanceId = raised.instanceId; - await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${ + await must(`cat > /tmp/foundation.lock <<'MESHBUNDLE'\n${ pinnedInto(readFileSync(bundle, "utf8"), raised.images)}\nMESHBUNDLE`); - await must(`${HOST_PATH} apply /tmp/substrate.lock`, 300_000); + await must(`${HOST_PATH} apply /tmp/foundation.lock`, 300_000); // **And the machine joins.** Applying the bundle raises a control plane; it does not tell that // control plane a machine exists. Leaving this out is what the first run of this canary found, @@ -111,7 +111,7 @@ test("a module reaches the machine", { skip, timeout: 600_000 }, async () => { { id: "note", type: "file", path: "/var/lib/canary/it-arrived", content: "yes\n", mode: "0644" }, ], }))} > /tmp/canary.json`); - await must(`docker cp /tmp/canary.json mesh-control:/canary.json`); + await must(`docker cp /tmp/canary.json mesh-controller:/canary.json`); await mesh("module add /canary.json"); await mesh(`assign ${MACHINE} canary`); await mesh(`push ${MACHINE}`, 300_000); @@ -151,7 +151,7 @@ test("a consumer gets a credential it can use", { skip, timeout: 600_000 }, asyn ], }))} > /tmp/canary-app.json`); for (const name of ["canary-store", "canary-app"]) { - await must(`docker cp /tmp/${name}.json mesh-control:/${name}.json`); + await must(`docker cp /tmp/${name}.json mesh-controller:/${name}.json`); await mesh(`module add /${name}.json`); await mesh(`assign ${MACHINE} ${name}`); } diff --git a/test/integration/certificates.test.ts b/test/integration/certificates.test.ts index 37aa663..1fe8e64 100644 --- a/test/integration/certificates.test.ts +++ b/test/integration/certificates.test.ts @@ -25,7 +25,7 @@ const proxy = process.env["MESH_LAB_ROUTE_PROXY"] ?? ""; const skip = !capability.usable ? `lab not usable: ${capability.why}` : !proxy - ? "set MESH_LAB_ROUTE_PROXY to a built proxy (mesh-control: go build ./examples/route-proxy)" + ? "set MESH_LAB_ROUTE_PROXY to a built proxy (mesh-controller: go build ./examples/route-proxy)" : false; const SCENARIO = "a-public-name"; diff --git a/test/integration/events.test.ts b/test/integration/events.test.ts index 3ed2758..c986311 100644 --- a/test/integration/events.test.ts +++ b/test/integration/events.test.ts @@ -5,8 +5,8 @@ * credential is delivered over it. This proves the other half of the bus (novox/hq ADR 0046): the * events exchange, where a module emits and any number listen, and the audit logger consumes `#` * and writes down what happened. It runs against the `mesh-broker` this scenario's own host raised - * from the substrate bundle — not a broker a test stood up — because "the mesh hosts the broker" - * (tier-1 substrate) is the thing being relied on. + * from the foundation bundle — not a broker a test stood up — because "the mesh hosts the broker" + * (tier-1 foundation) is the thing being relied on. * * The wire shape it asserts is ADR 0047: metadata rides as headers so the body is only the * payload, a consumer gets a durable per-consumer queue `..events`, and a @@ -14,10 +14,10 @@ * on the raised broker is the check that the runtime provisioned the contract, not just that a * message happened to arrive. * - * It needs the host binary and the substrate bundle, like the mesh walk, plus a runtime image: + * It needs the host binary and the foundation bundle, like the mesh walk, plus a runtime image: * * MESH_LAB_HOST_BINARY=.../mesh-host - * MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock + * MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock * MESH_LAB_RUNTIME=.../mesh-runtime-audit.tar (docker save of the runtime+audit-logger image; * built by scripts/build-runtime-image.sh) * @@ -33,7 +33,7 @@ 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, substrateBundle } from "./harness.ts"; +import { labIsUsable, destroyAll, foundationBundle } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; import { incus } from "../../src/incus/client.ts"; import { machineName } from "../../src/lifecycle/names.ts"; @@ -48,7 +48,7 @@ const skip = !capability.usable : !binary || !existsSync(binary) ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" : !bundle || !existsSync(bundle) - ? "MESH_LAB_BUNDLE is not set to a substrate bundle (mesh-host examples/)" + ? "MESH_LAB_BUNDLE is not set to a foundation bundle (mesh-host examples/)" : !runtime || !existsSync(runtime) ? "MESH_LAB_RUNTIME is not set to a runtime image tar (scripts/build-runtime-image.sh)" : false; @@ -58,7 +58,7 @@ const MACHINE = "anchor"; /** Where the audit-logger container writes its trail, on the machine — mounted from a host dir. */ const TRAIL_DIR = "/var/lib/mesh-audit"; const TRAIL = `${TRAIL_DIR}/audit.log`; -/** The broker the substrate raised, reachable on the node's loopback (novox/hq ADR 0001). */ +/** The broker the foundation raised, reachable on the node's loopback (novox/hq ADR 0001). */ const BROKER = "amqp://guest:guest@127.0.0.1:5672/"; let instanceId = ""; @@ -73,7 +73,7 @@ function quote(s: string): string { * A command on the machine, its exit read from a marker on its own line. * * `exec 2>&1` on its own first line and the marker on its own last line, so a command that carries - * a heredoc — the substrate bundle is written with one — terminates where it says it does rather + * a heredoc — the foundation bundle is written with one — terminates where it says it does rather * than swallowing the marker (the fault mesh.test.ts documents). */ async function on(command: string, timeoutMs?: number): Promise<{ out: string; ok: boolean }> { @@ -92,14 +92,14 @@ async function must(command: string, timeoutMs?: number): Promise { } /** - * The substrate bundle, its image references pointed at this scenario's own registry. + * The foundation bundle, its image references pointed at this scenario's own registry. * * A digest belongs to whatever registry serves it, so the committed bundle names a registry that * is not this one; matching by repository and rewriting to the digest this registry assigned is * what makes it applicable (the same rewrite mesh.test.ts does). */ function bundleFor(images: HeldImage[]): string { - return substrateBundle(bundle, images); + return foundationBundle(bundle, images); } /** Read the trail back as parsed JSON lines. */ @@ -120,14 +120,14 @@ before(async () => { }); instanceId = raised.instanceId; - // The node raises its substrate — store, broker and the rest — from the bundle, applied from a + // The node raises its foundation — store, broker and the rest — from the bundle, applied from a // file because the control plane's image is named by the ID this machine holds it under, // which is not knowable until it has been handed over. - await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); - await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); + await must(`cat > /tmp/foundation.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); + await must(`${HOST_PATH} apply /tmp/foundation.lock`, 600_000); const running = await must(`docker ps --format '{{.Names}}'`); - assert.match(running, /mesh-broker/, `the substrate did not raise a broker:\n${running}`); + assert.match(running, /mesh-broker/, `the foundation did not raise a broker:\n${running}`); // Bring the runtime+audit-logger image onto the machine. Loaded, not pulled: the machine has no // route out (novox/hq the lab is a closed address space), so the image arrives as a tar the way diff --git a/test/integration/fresh-mesh.test.ts b/test/integration/fresh-mesh.test.ts index 6e940f9..a14a221 100644 --- a/test/integration/fresh-mesh.test.ts +++ b/test/integration/fresh-mesh.test.ts @@ -31,9 +31,9 @@ * MESH_LAB_INCUS='sudo -n incus' * MESH_LAB_HOST_BINARY=.../mesh-host/mesh-host * MESH_LAB_BOOTSTRAP_BINARY=.../mesh-host/mesh-bootstrap - * MESH_LAB_BUNDLE=.../mesh-host/examples/substrate-first-node.lock + * MESH_LAB_BUNDLE=.../mesh-host/examples/foundation-first-node.lock * MESH_LAB_CATALOG=.../mesh-catalog/modules - * MESH_LAB_SOURCE=/mesh-control.git MESH_LAB_SOURCE_REF= + * MESH_LAB_SOURCE=/mesh-controller.git MESH_LAB_SOURCE_REF= * MESH_LAB_KEEP=1 to leave it standing afterwards */ import { test, before, after } from "node:test"; @@ -44,7 +44,7 @@ import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; import { destroy, exec, push } from "../../src/lifecycle/operate.ts"; import { bootstrapBinaryPath, hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll, substrateBundle } from "./harness.ts"; +import { labIsUsable, destroyAll, foundationBundle } from "./harness.ts"; import { genesis, type GenesisResult } from "./genesis.ts"; const SCENARIO = "fresh-mesh"; @@ -68,7 +68,7 @@ const REGISTRY = `${ANCHOR}:5000`; */ const BASE = { module: "mesh-tools", repo: "mesh-tools", path: "" }; /** - * What `amqp-ping` requires, and what the substrate does not supply. + * What `amqp-ping` requires, and what the foundation does not supply. * * The installer raises a broker, but as a bundle resource — plumbing, not a module the mesh has a * record of, so it provides nothing to anything. A module asking for `amqp` is asking for a @@ -86,7 +86,7 @@ const PROVIDER = { module: "lavinmq", repo: "mesh-catalog", path: "modules/lavin const STORE = { module: "postgres", repo: "mesh-catalog", path: "modules/postgres", container: "mesh-postgres" }; const CATALOGUE = { module: "mesh-catalog", repo: "mesh-catalog", path: "modules/mesh-catalog", container: "mesh-catalog" }; /** The control plane, rebuilt from its own repository — the step that ends the installer's tenure. */ -const CONTROL_PLANE = { module: "mesh-control", repo: "mesh-control", path: "", container: "mesh-control" }; +const CONTROL_PLANE = { module: "mesh-controller", repo: "mesh-controller", path: "", container: "mesh-controller" }; /** Named once, because the step title is also how later steps say what they waited on. */ const NEEDS = "the mesh runs a broker for that module to talk to"; const GENESIS = "a bare machine becomes a mesh of one, raised by the installer"; @@ -154,7 +154,7 @@ const skip = !installer ? "MESH_LAB_BOOTSTRAP_BINARY is not set to a built mesh-bootstrap" : !source ? "MESH_LAB_SOURCE is not set to the repository the control plane is built from" : !sourceRef ? "MESH_LAB_SOURCE_REF is not set to the commit to build" : - !bundle || !existsSync(bundle) ? "MESH_LAB_BUNDLE is not set to a substrate template" : + !bundle || !existsSync(bundle) ? "MESH_LAB_BUNDLE is not set to a foundation template" : !catalogDir || !existsSync(catalogDir) ? "MESH_LAB_CATALOG is not set to mesh-catalog/modules" : false; @@ -180,7 +180,7 @@ async function must(machine: string, command: string, timeoutMs?: number): Promi return out; } async function mesh(command: string, timeoutMs?: number): Promise { - return must(CONTROL, `docker exec mesh-control /mesh-control ${command}`, timeoutMs); + return must(CONTROL, `docker exec mesh-controller /mesh-controller ${command}`, timeoutMs); } /** A module the mesh has to make for itself: where its source is, and what it runs when it works. */ @@ -239,7 +239,7 @@ async function waitForContainer(node: string, container: string, seconds = 200): * * **The control plane runs in a container, so a file on the machine is not a file it can open.** * Pushing the manifest to the machine and naming that path got `no such file or directory` from - * inside mesh-control, which is correct and was briefly mistaken for a missing manifest. It is + * inside mesh-controller, which is correct and was briefly mistaken for a missing manifest. It is * copied the last step of the way with `docker cp`. * * **Into the root, not into /tmp.** The control plane's image is a minimal one and has no `/tmp` @@ -250,7 +250,7 @@ async function registerModule(module: string, manifest: string): Promise const onMachine = `/tmp/${module}.json`; const inContainer = `/${module}.json`; await push(instanceId, CONTROL, manifest, onMachine); - await must(CONTROL, `docker cp ${onMachine} mesh-control:${inContainer}`); + await must(CONTROL, `docker cp ${onMachine} mesh-controller:${inContainer}`); return mesh(`module add ${inContainer}`); } function tokenFrom(said: string): string { @@ -325,7 +325,7 @@ before(async () => { // whose anchor is somewhere else every node, including this one, would enrol against an // address nothing answers on. The installer refuses to guess it and says so, which is // right: it does not know what this machine is called from outside. - bundleTemplate: substrateBundle(bundle, []).replaceAll("192.0.2.10:5671", `${ANCHOR}:5671`), + bundleTemplate: foundationBundle(bundle, []).replaceAll("192.0.2.10:5671", `${ANCHOR}:5671`), registry: REGISTRY, source, sourceRef, @@ -371,7 +371,7 @@ before(async () => { return built; }); - // A store of its own. **Not the substrate's.** The installer raises a store for the control + // A store of its own. **Not the foundation's.** The installer raises a store for the control // plane to keep its own records in, the way it raises a broker — plumbing, not a module the mesh // has any record of, so it provides nothing to anything. A module that wants a database wants a // provider in the graph, and the catalogue below is the first thing to want one. diff --git a/test/integration/genesis-single.test.ts b/test/integration/genesis-single.test.ts index 42dc73a..d27ca2f 100644 --- a/test/integration/genesis-single.test.ts +++ b/test/integration/genesis-single.test.ts @@ -12,7 +12,7 @@ * MESH_LAB_INCUS='sudo -n incus' * MESH_LAB_HOST_BINARY=.../mesh-host/mesh-host * MESH_LAB_BOOTSTRAP_BINARY=.../mesh-host/mesh-bootstrap - * MESH_LAB_BUNDLE=.../mesh-host/examples/substrate-first-node.lock + * MESH_LAB_BUNDLE=.../mesh-host/examples/foundation-first-node.lock * MESH_LAB_CATALOG=.../mesh-catalog/modules * MESH_LAB_KEEP=1 to leave it standing afterwards */ @@ -23,7 +23,7 @@ import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; import { destroy } from "../../src/lifecycle/operate.ts"; import { bootstrapBinaryPath, hostBinaryPath } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll, substrateBundle } from "./harness.ts"; +import { labIsUsable, destroyAll, foundationBundle } from "./harness.ts"; import { genesis, type GenesisResult } from "./genesis.ts"; const SCENARIO = "genesis-single"; @@ -49,7 +49,7 @@ const skip = "bootstrap IMAGE=mesh-builder:development`)" : !source ? "MESH_LAB_SOURCE is not set to the repository the control plane is built from" : !sourceRef ? "MESH_LAB_SOURCE_REF is not set to the commit to build" : - !bundle || !existsSync(bundle) ? "MESH_LAB_BUNDLE is not set to a substrate template" : + !bundle || !existsSync(bundle) ? "MESH_LAB_BUNDLE is not set to a foundation template" : !catalogDir || !existsSync(catalogDir) ? "MESH_LAB_CATALOG is not set to mesh-catalog/modules" : false; @@ -75,10 +75,10 @@ before(async () => { installer: installer as string, catalogDir, // The template, not a bundle. The store and broker become the references mesh-catalog pins, - // and the machine pulls them over its uplink like any first node. mesh-control is left + // and the machine pulls them over its uplink like any first node. mesh-controller is left // naming a registry that does not exist — the installer overwrites it, and leaving it proves // that it does. - bundleTemplate: substrateBundle(bundle, []), + bundleTemplate: foundationBundle(bundle, []), source, sourceRef, // Phase two builds from the same forge; the repositories sit beside the control plane's. diff --git a/test/integration/genesis.ts b/test/integration/genesis.ts index 64222cb..2793b52 100644 --- a/test/integration/genesis.ts +++ b/test/integration/genesis.ts @@ -37,7 +37,7 @@ export interface GenesisOptions { /** A checkout of mesh-catalog's `modules/` on this workstation. */ catalogDir: string; /** - * The substrate TEMPLATE's content — not a bundle. The installer produces the bundle from it, + * The foundation TEMPLATE's content — not a bundle. The installer produces the bundle from it, * replacing the control plane's image with the id of the image it carries. */ bundleTemplate: string; @@ -91,7 +91,7 @@ export function stepIn(said: string): string { export async function genesis(o: GenesisOptions): Promise { const node = o.node; const registry = o.registry ?? "127.0.0.1:5000"; - const modules = o.catalogueModules ?? ["distribution", "mesh-control", "builder"]; + const modules = o.catalogueModules ?? ["distribution", "mesh-controller", "builder"]; const catalogueOnMachine = o.catalogueOnMachine ?? "/opt/mesh-catalog"; const log = o.log ?? (() => {}); @@ -147,9 +147,9 @@ export async function genesis(o: GenesisOptions): Promise { } report.push(` catalogue full checkout at ${catalogueOnMachine} (${modules.join(", ")} + phase two)`); - const local = join(tmpdir(), `mesh-lab-substrate-${process.pid}-${node}.lock`); + const local = join(tmpdir(), `mesh-lab-foundation-${process.pid}-${node}.lock`); writeFileSync(local, o.bundleTemplate); - await push(o.instanceId, node, local, "/tmp/substrate-template.lock"); + await push(o.instanceId, node, local, "/tmp/foundation-template.lock"); // Supervise the host as a service (the real install path) when asked — the only way it survives a // reboot. Installs the shipped packaging in the machine, then lets --host-service start+enable it. @@ -165,7 +165,7 @@ export async function genesis(o: GenesisOptions): Promise { BOOTSTRAP_PATH, `--source ${o.source}`, `--source-ref ${o.sourceRef}`, - `--bundle /tmp/substrate-template.lock`, + `--bundle /tmp/foundation-template.lock`, `--catalog ${catalogueOnMachine}`, `--node ${node}`, `--registry ${registry}`, @@ -212,9 +212,9 @@ export async function genesis(o: GenesisOptions): Promise { // ------------------------------------------------------------------------------------------ // 1. The control plane answers, asked of the PERMANENT container by name. - const answered = await on(`docker exec mesh-control /mesh-control status`, 60_000); + const answered = await on(`docker exec mesh-controller /mesh-controller status`, 60_000); report.push(` control plane ${answered.ok ? answered.out.split("\n")[0] : "NO ANSWER"}`); - if (!answered.ok) return stop("after the last step", `mesh-control does not answer:\n${answered.out}`); + if (!answered.ok) return stop("after the last step", `mesh-controller does not answer:\n${answered.out}`); // 2. The registry replies on /v2/. A container that is up is not a registry that serves. const v2 = await on(`curl -s -o /dev/null -w '%{http_code}' --max-time 10 http://${registry}/v2/`); @@ -224,17 +224,17 @@ export async function genesis(o: GenesisOptions): Promise { // 3. THE PIVOT COMPLETED — the running control plane is pinned by a digest THIS MESH'S REGISTRY // assigned, not by an image id (ADR 0067 states this check in as many words). - const pinnedTo = (await on(`docker inspect --format '{{.Config.Image}}' mesh-control`)).out.trim(); + const pinnedTo = (await on(`docker inspect --format '{{.Config.Image}}' mesh-controller`)).out.trim(); report.push(` pinned to ${pinnedTo || "(nothing)"}`); if (/^sha256:[0-9a-f]{64}$/.test(pinnedTo)) { return stop("after the last step", - `mesh-control is running from ${pinnedTo}, which is an IMAGE ID — the digest of the image's ` + + `mesh-controller is running from ${pinnedTo}, which is an IMAGE ID — the digest of the image's ` + `own configuration, which no registry ever served. The pivot did not happen, so this mesh ` + `cannot upgrade itself (novox/hq ADR 0067, "the pivot completed").`); } - if (!new RegExp(`^${registry.replaceAll(".", "\\.")}/mesh-control@sha256:[0-9a-f]{64}$`).test(pinnedTo)) { + if (!new RegExp(`^${registry.replaceAll(".", "\\.")}/mesh-controller@sha256:[0-9a-f]{64}$`).test(pinnedTo)) { return stop("after the last step", - `mesh-control is running from ${pinnedTo || "nothing this bed could read"}, which is not a ` + + `mesh-controller is running from ${pinnedTo || "nothing this bed could read"}, which is not a ` + `digest assigned by ${registry}.`); } @@ -249,34 +249,34 @@ export async function genesis(o: GenesisOptions): Promise { // installer that quietly built something else would satisfy a weaker check and raise a mesh // nobody asked for. const wanted = o.sourceRef.slice(0, 8); - if (!new RegExp(`built mesh-control from ${wanted}`).test(said)) { + if (!new RegExp(`built mesh-controller from ${wanted}`).test(said)) { return stop("after the last step", - `the installer never said it built mesh-control from ${wanted}. What runs may have been ` + + `the installer never said it built mesh-controller from ${wanted}. What runs may have been ` + `carried rather than made here, which is a mesh that cannot rebuild its own control plane. ` + `The installer said:\n${said.split("\n").filter((l) => /built|build/.test(l)).join("\n") || "(nothing about building)"}`); } - report.push(` built here mesh-control from ${wanted}, by the carried builder`); + report.push(` built here mesh-controller from ${wanted}, by the carried builder`); // 3b. And the registry really serves it. A reference is a claim; a tag list is the registry agreeing. - const tags = await on(`curl -s --max-time 10 http://${registry}/v2/mesh-control/tags/list`); + const tags = await on(`curl -s --max-time 10 http://${registry}/v2/mesh-controller/tags/list`); report.push(` registry holds ${tags.out.trim() || "nothing"}`); if (!tags.out.includes("genesis")) { return stop("after the last step", - `${registry} does not serve mesh-control, so the digest the container is pinned to names an ` + + `${registry} does not serve mesh-controller, so the digest the container is pinned to names an ` + `image nothing can pull: ${tags.out.trim()}`); } // 4. The temporary control plane is GONE. The name is the audit. - const temp = await on(`docker inspect --format '{{.State.Status}}' temp-mesh-control`); - report.push(` temp-mesh-control ${temp.ok ? `STILL HERE (${temp.out.trim()})` : "gone"}`); + const temp = await on(`docker inspect --format '{{.State.Status}}' temp-mesh-controller`); + report.push(` temp-mesh-controller ${temp.ok ? `STILL HERE (${temp.out.trim()})` : "gone"}`); if (temp.ok) { return stop("after the last step", - `temp-mesh-control is still ${temp.out.trim()}. Two control planes are consuming this mesh's ` + + `temp-mesh-controller is still ${temp.out.trim()}. Two control planes are consuming this mesh's ` + `broker queues; neither is wrong and the pivot is not finished.`); } // 5. And the mesh has heard from its one node. - const nodes = await on(`docker exec mesh-control /mesh-control node list`); + const nodes = await on(`docker exec mesh-controller /mesh-controller node list`); report.push(` node list ${nodes.out.trim().split("\n").join(" | ")}`); const line = nodes.out.split("\n").map((l) => l.trim()).find((l) => l.startsWith(`${node} `)); if (!line || !/^\S+\s+here\b/.test(line)) { diff --git a/test/integration/harness.ts b/test/integration/harness.ts index 8d82bc0..4cc9f99 100644 --- a/test/integration/harness.ts +++ b/test/integration/harness.ts @@ -17,19 +17,19 @@ import { duplicateAddresses, describeConflicts, type Held } from "../../src/life import type { Scenario } from "../../src/declaration/types.ts"; import { mustBeHandedOver, pinnedInto, referenceFor, repositoryOf, type HeldImage } from "../../src/pinning.ts"; -// --- the substrate bundle, and what its three images are on a real machine --------------------- +// --- the foundation bundle, and what its three images are on a real machine --------------------- /** * The example bundle in mesh-host names a registry that no longer exists. * - * `examples/substrate-first-node.lock` was written **for a target**, and the target was the lab: it + * `examples/foundation-first-node.lock` was written **for a target**, and the target was the lab: it * pins `192.0.2.250:5000/…` because that is where the registry the lab used to raise served from. * That registry is gone, so those three references name nothing. * * Two of them are ordinary third-party images and belong to the internet. Rather than invent * digests here, they are the ones the mesh's own modules already pin — mesh-catalog's `postgres` - * and `lavinmq` — so the substrate's store and broker are literally the images the mesh runs. The - * third, mesh-control, exists in no registry at all and becomes the ID the machine holds it under. + * and `lavinmq` — so the foundation's store and broker are literally the images the mesh runs. The + * third, mesh-controller, exists in no registry at all and becomes the ID the machine holds it under. * * **The bundle itself should be fixed in mesh-host**, and this substitution deleted with it. It is * here because the file lives in another repository and because a fixture that lies about where an @@ -41,13 +41,13 @@ const UPSTREAM_BROKER = "cloudamqp/lavinmq@sha256:3eb54c12916d700a978c2ea86e6362cd4974b0e3189508718006d4e6d341246b"; /** - * The substrate bundle as a machine should receive it. + * The foundation bundle as a machine should receive it. * * Third-party references become upstream ones, which the machine pulls over its uplink; ours * become the ID the machine was handed. Nothing points inside the scenario any more, which is the * whole of this change: what the bed proves about a bootstrap is now what would happen anywhere. */ -export function substrateBundle(path: string, held: HeldImage[]): string { +export function foundationBundle(path: string, held: HeldImage[]): string { let text = readFileSync(path, "utf8"); text = text.replaceAll(/[A-Za-z0-9_.:-]+\/postgres@sha256:[0-9a-f]{64}/g, UPSTREAM_STORE); text = text.replaceAll( diff --git a/test/integration/lavinmq-bed.test.ts b/test/integration/lavinmq-bed.test.ts index e2326d5..97a68a7 100644 --- a/test/integration/lavinmq-bed.test.ts +++ b/test/integration/lavinmq-bed.test.ts @@ -1,12 +1,12 @@ /** - * A lavinmq PROVIDER and a consumer of it ride one node while the substrate's own broker owns 5672 on + * A lavinmq PROVIDER and a consumer of it ride one node while the foundation's own broker owns 5672 on * another — the end-to-end proof that a module which needs a message queue gets its OWN broker. * * lavinmq is the mesh's control-plane broker AND a user-facing capability: a consumer that requires * `amqp` is given a scoped vhost + user on a lavinmq PROVIDER, not an account on the control broker - * (novox/hq ADR 0048). That makes it the two-node case, the twin of two-node-db: the substrate's + * (novox/hq ADR 0048). That makes it the two-node case, the twin of two-node-db: the foundation's * broker publishes 5672 on anchor, and a lavinmq provider must publish 5672 for its consumers to - * reach it — so the two cannot share a machine. `anchor` runs the substrate and nothing else; `laptop` + * reach it — so the two cannot share a machine. `anchor` runs the foundation and nothing else; `laptop` * runs the provider AND the amqp-ping consumer, co-located, and owns laptop's 5672 uncontested. * * The proof is layered: @@ -14,7 +14,7 @@ * config BEFORE the broker started (ADR 0052) — proven because the broker came up at all and is * gone from `docker ps -a` (a step, not a service); * - the lavinmq service and BOTH runtimes (bootstrap done, provisioner running) are up and stable; - * - each module got its own scoped broker account on the substrate broker (anchor), named for the + * - each module got its own scoped broker account on the foundation broker (anchor), named for the * node that runs it; * - the provisioner (in mesh-lavinmq on laptop) created the consumer's vhost AND user, both named * for the login the mesh derived — checked with `lavinmqctl` inside the broker; @@ -25,12 +25,12 @@ * its vhost — the provider named the vhost after the login — and nothing is hardcoded; the provider's * `serves` carries the port so the consumer references `${bound:amqp:port}`. * - * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock + * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock * * HELPER — stock the two runtimes into the local daemon before the run (some may already be there): * scripts/build-module-runtime.sh lavinmq /tmp/lavinmq.tar * scripts/build-module-runtime.sh amqp-ping /tmp/amqp-ping.tar - * cloudamqp/lavinmq:latest must be in the local daemon too (it is the substrate's own broker image); + * cloudamqp/lavinmq:latest must be in the local daemon too (it is the foundation's own broker image); * scenarios/lavinmq-bed.yml stocks all of them, and each node pulls what it runs by digest. */ @@ -41,7 +41,7 @@ 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, substrateBundle, onTheMachine } from "./harness.ts"; +import { labIsUsable, destroyAll, foundationBundle, onTheMachine } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -53,11 +53,11 @@ const skip = !capability.usable : !binary || !existsSync(binary) ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" : !bundle || !existsSync(bundle) - ? "MESH_LAB_BUNDLE is not set to a substrate bundle (mesh-host examples/)" + ? "MESH_LAB_BUNDLE is not set to a foundation bundle (mesh-host examples/)" : false; const SCENARIO = "lavinmq-bed"; -/** The node that carries the provider and its consumer. anchor carries only the substrate. */ +/** The node that carries the provider and its consumer. anchor carries only the foundation. */ const NODE = "laptop"; /** The login the mesh derives for the consumer: mesh__ (slug "ping"; ADR 0049). */ const CONSUMER_LOGIN = "mesh_laptop_ping"; @@ -86,7 +86,7 @@ async function must(machine: string, command: string, timeoutMs?: number): Promi /** The control plane, a container on the first node. */ async function mesh(command: string, timeoutMs?: number): Promise { - return must("anchor", `docker exec mesh-control /mesh-control ${command}`, timeoutMs); + return must("anchor", `docker exec mesh-controller /mesh-controller ${command}`, timeoutMs); } /** The reference a manifest should carry, once this scenario has been raised. */ @@ -95,9 +95,9 @@ function pinned(reference: string): string { return onTheMachine(reference, held); } -/** The substrate bundle: ours by the ID the machine holds, everything else upstream. */ +/** The foundation bundle: ours by the ID the machine holds, everything else upstream. */ function bundleFor(images: HeldImage[]): string { - return substrateBundle(bundle, images); + return foundationBundle(bundle, images); } function tokenFrom(said: string): string { @@ -118,7 +118,7 @@ async function settled(node: string, withinMs = 1_200_000): Promise { } | undefined; let said = ""; try { - const asked = await on("anchor", `docker exec mesh-control /mesh-control status --json`); + const asked = await on("anchor", `docker exec mesh-controller /mesh-controller status --json`); said = asked.out; if (asked.ok) state = JSON.parse(said); } catch (err) { @@ -157,11 +157,11 @@ before(async () => { instanceId = raised.instanceId; held = raised.images; - await must("anchor", `cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); - await must("anchor", `${HOST_PATH} apply /tmp/substrate.lock`, 600_000); + await must("anchor", `cat > /tmp/foundation.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); + await must("anchor", `${HOST_PATH} apply /tmp/foundation.lock`, 600_000); const up = await must("anchor", `docker ps --format '{{.Names}}'`); - for (const c of ["mesh-store", "mesh-broker", "mesh-control"]) { - assert.match(up, new RegExp(c), `the substrate did not raise ${c}:\n${up}`); + for (const c of ["mesh-store", "mesh-broker", "mesh-controller"]) { + assert.match(up, new RegExp(c), `the foundation did not raise ${c}:\n${up}`); } for (const [machine, node] of [["anchor", "anchor"], ["laptop", "laptop"]] as const) { @@ -178,7 +178,7 @@ after(async () => { await destroyAll(`${SCENARIO}-`); }, { timeout: 600_000 }); -test("the lavinmq provider and its consumer ride laptop while the substrate broker owns 5672 on anchor", { +test("the lavinmq provider and its consumer ride laptop while the foundation broker owns 5672 on anchor", { skip, timeout: 1_500_000, }, async () => { // ================================================================================================ @@ -287,7 +287,7 @@ test("the lavinmq provider and its consumer ride laptop while the substrate brok // --- add, issue a scoped broker account, assign to laptop -------------------------------------- async function addIssueAssign(name: string, manifest: string): Promise { - await must("anchor", `printf %s ${quote(manifest)} > /tmp/${name}.json && docker cp /tmp/${name}.json mesh-control:/${name}.json`); + await must("anchor", `printf %s ${quote(manifest)} > /tmp/${name}.json && docker cp /tmp/${name}.json mesh-controller:/${name}.json`); await mesh(`module add /${name}.json`); const issued = await mesh(`module issue ${name} --node ${NODE}`); assert.match(issued, /broker account/, issued); @@ -311,14 +311,14 @@ test("the lavinmq provider and its consumer ride laptop while the substrate brok await settled(NODE); // ================================================================================================ - // THE two-node split — the substrate broker owns 5672 on anchor, the provider owns it on laptop. + // THE two-node split — the foundation broker owns 5672 on anchor, the provider owns it on laptop. // ================================================================================================ const onAnchor = await must("anchor", `docker ps --format '{{.Names}}'`); const onLaptop = await must(NODE, `docker ps --format '{{.Names}}'`); - assert.match(onAnchor, /(^|\n)mesh-broker(\n|$)/, "the substrate broker is not on the first node"); + assert.match(onAnchor, /(^|\n)mesh-broker(\n|$)/, "the foundation broker is not on the first node"); assert.doesNotMatch(onAnchor, /(^|\n)lavinmq(\n|$)/, - "the lavinmq provider landed on the substrate node — the 5672 collision this bed exists to avoid"); - assert.doesNotMatch(onLaptop, /(^|\n)mesh-store(\n|$)/, "the substrate store leaked onto the second node"); + "the lavinmq provider landed on the foundation node — the 5672 collision this bed exists to avoid"); + assert.doesNotMatch(onLaptop, /(^|\n)mesh-store(\n|$)/, "the foundation store leaked onto the second node"); assert.match(onLaptop, /(^|\n)lavinmq(\n|$)/, "the lavinmq provider is not on the second node"); // ================================================================================================ @@ -359,7 +359,7 @@ test("the lavinmq provider and its consumer ride laptop while the substrate brok } // ================================================================================================ - // Each module got its own scoped broker account on the substrate broker (anchor), named for the + // Each module got its own scoped broker account on the foundation broker (anchor), named for the // node that runs it and the module. // ================================================================================================ const users = await must("anchor", `docker exec mesh-broker lavinmqctl list_users 2>&1`); diff --git a/test/integration/local-model-bed.test.ts b/test/integration/local-model-bed.test.ts index 981734c..7a01fe0 100644 --- a/test/integration/local-model-bed.test.ts +++ b/test/integration/local-model-bed.test.ts @@ -15,7 +15,7 @@ * asserts the templated URL and that a request to it reaches the running server (ollama answers * /v1/models even with no model pulled — the wiring is what is proven, not a model's output). * - * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock + * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock * No module runtime image is built — both modules are pure declaration. */ @@ -26,7 +26,7 @@ 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, substrateBundle, onTheMachine } from "./harness.ts"; +import { labIsUsable, destroyAll, foundationBundle, onTheMachine } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -38,7 +38,7 @@ const skip = !capability.usable : !binary || !existsSync(binary) ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" : !bundle || !existsSync(bundle) - ? "MESH_LAB_BUNDLE is not set to a substrate bundle (mesh-host examples/)" + ? "MESH_LAB_BUNDLE is not set to a foundation bundle (mesh-host examples/)" : false; const SCENARIO = "local-model-bed"; @@ -67,11 +67,11 @@ async function must(command: string, timeoutMs?: number): Promise { } async function mesh(command: string, timeoutMs?: number): Promise { - return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); + return must(`docker exec mesh-controller /mesh-controller ${command}`, timeoutMs); } async function meshTry(command: string): Promise<{ out: string; ok: boolean }> { - return on(`docker exec mesh-control /mesh-control ${command}`); + return on(`docker exec mesh-controller /mesh-controller ${command}`); } /** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ @@ -80,7 +80,7 @@ function pinned(reference: string): string { } function bundleFor(images: HeldImage[]): string { - return substrateBundle(bundle, images); + return foundationBundle(bundle, images); } function tokenFrom(said: string): string { @@ -117,7 +117,7 @@ async function settled(withinMs = 600_000): Promise { } async function addAssign(name: string, manifest: string): Promise { - await must(`printf %s ${quote(manifest)} > /tmp/${name}.json && docker cp /tmp/${name}.json mesh-control:/${name}.json`); + await must(`printf %s ${quote(manifest)} > /tmp/${name}.json && docker cp /tmp/${name}.json mesh-controller:/${name}.json`); await mesh(`module add /${name}.json`); await mesh(`module issue ${name} --node ${MACHINE}`); await mesh(`assign ${MACHINE} ${name}`); @@ -132,11 +132,11 @@ before(async () => { instanceId = raised.instanceId; held = raised.images; - await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); - await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); + await must(`cat > /tmp/foundation.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); + await must(`${HOST_PATH} apply /tmp/foundation.lock`, 600_000); const up = await must(`docker ps --format '{{.Names}}'`); - for (const c of ["mesh-store", "mesh-broker", "mesh-control"]) { - assert.match(up, new RegExp(c), `the substrate did not raise ${c}:\n${up}`); + for (const c of ["mesh-store", "mesh-broker", "mesh-controller"]) { + assert.match(up, new RegExp(c), `the foundation did not raise ${c}:\n${up}`); } await mesh(`node add ${MACHINE}`); diff --git a/test/integration/mesh-grant-end-to-end.test.ts b/test/integration/mesh-grant-end-to-end.test.ts index 0d96850..167aff9 100644 --- a/test/integration/mesh-grant-end-to-end.test.ts +++ b/test/integration/mesh-grant-end-to-end.test.ts @@ -11,7 +11,7 @@ * Mint on one side and create on the other agreeing, with no shared key and nothing placed by the * test, is the entire provider/consumer contract working as one thing. * - * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock + * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock * scripts/build-module-runtime.sh redis builds mesh-runtime-redis:development, which * scenarios/redis-node.yml stocks. */ @@ -23,7 +23,7 @@ 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, substrateBundle, onTheMachine } from "./harness.ts"; +import { labIsUsable, destroyAll, foundationBundle, onTheMachine } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -35,7 +35,7 @@ const skip = !capability.usable : !binary || !existsSync(binary) ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" : !bundle || !existsSync(bundle) - ? "MESH_LAB_BUNDLE is not set to a substrate bundle (mesh-host examples/)" + ? "MESH_LAB_BUNDLE is not set to a foundation bundle (mesh-host examples/)" : false; const SCENARIO = "redis-node"; @@ -64,7 +64,7 @@ async function must(command: string, timeoutMs?: number): Promise { } async function mesh(command: string, timeoutMs?: number): Promise { - return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); + return must(`docker exec mesh-controller /mesh-controller ${command}`, timeoutMs); } /** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ @@ -73,7 +73,7 @@ function pinned(reference: string): string { } function bundleFor(images: HeldImage[]): string { - return substrateBundle(bundle, images); + return foundationBundle(bundle, images); } function tokenFrom(said: string): string { @@ -86,7 +86,7 @@ async function settled(withinMs = 480_000): Promise { const until = Date.now() + withinMs; let last = ""; while (Date.now() < until) { - const asked = await on(`docker exec mesh-control /mesh-control status --json`); + const asked = await on(`docker exec mesh-controller /mesh-controller status --json`); if (asked.ok) { try { const state = JSON.parse(asked.out) as { @@ -118,11 +118,11 @@ before(async () => { instanceId = raised.instanceId; held = raised.images; - await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); - await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); + await must(`cat > /tmp/foundation.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); + await must(`${HOST_PATH} apply /tmp/foundation.lock`, 600_000); const up = await must(`docker ps --format '{{.Names}}'`); - for (const c of ["mesh-store", "mesh-broker", "mesh-control"]) { - assert.match(up, new RegExp(c), `the substrate did not raise ${c}:\n${up}`); + for (const c of ["mesh-store", "mesh-broker", "mesh-controller"]) { + assert.match(up, new RegExp(c), `the foundation did not raise ${c}:\n${up}`); } await mesh(`node add ${MACHINE}`); @@ -201,12 +201,12 @@ test("the mesh grants a consumer redis's cache, and the credential it delivers a resources: [{ id: "state", type: "directory", path: "/var/lib/cacheuser", mode: "0700" }], }); - await must(`printf %s ${quote(redisManifest)} > /tmp/redis.json && docker cp /tmp/redis.json mesh-control:/redis.json`); + await must(`printf %s ${quote(redisManifest)} > /tmp/redis.json && docker cp /tmp/redis.json mesh-controller:/redis.json`); await mesh("module add /redis.json"); await mesh(`module issue redis --node ${MACHINE}`); await mesh(`assign ${MACHINE} redis`); - await must(`printf %s ${quote(consumerManifest)} > /tmp/cacheuser.json && docker cp /tmp/cacheuser.json mesh-control:/cacheuser.json`); + await must(`printf %s ${quote(consumerManifest)} > /tmp/cacheuser.json && docker cp /tmp/cacheuser.json mesh-controller:/cacheuser.json`); await mesh("module add /cacheuser.json"); await mesh(`assign ${MACHINE} cacheuser`); diff --git a/test/integration/mesh.test.ts b/test/integration/mesh.test.ts index 5c249db..46487cd 100644 --- a/test/integration/mesh.test.ts +++ b/test/integration/mesh.test.ts @@ -6,10 +6,10 @@ * project keeps saying cannot be checked any other way (novox/hq ADR 0001: every fault of * 2026-08-22 was found in production because nothing could be stood up locally). * - * It needs a host binary and the substrate bundle: + * It needs a host binary and the foundation bundle: * * MESH_LAB_HOST_BINARY=.../mesh-host - * MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock + * MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock * * The bundle's image references are rewritten to the ones this scenario's own registry serves. * A digest belongs to whatever registry serves it, so a committed bundle names a registry that is @@ -24,7 +24,7 @@ import { raise } from "../../src/lifecycle/raise.ts"; import { pinnedInto, stillUnpinned, type HeldImage } from "../../src/pinning.ts"; import { destroy, exec } from "../../src/lifecycle/operate.ts"; import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll, substrateBundle, onTheMachine } from "./harness.ts"; +import { labIsUsable, destroyAll, foundationBundle, onTheMachine } from "./harness.ts"; import { incus } from "../../src/incus/client.ts"; import { machineName } from "../../src/lifecycle/names.ts"; import { ready, returnTo, keep, rememberStock, warmStock } from "../../src/warm.ts"; @@ -36,7 +36,7 @@ const capability = await labIsUsable(); const binary = hostBinaryPath(); const bundle = process.env["MESH_LAB_BUNDLE"] ?? ""; const builder = process.env["MESH_LAB_BUILDER"] ?? ""; -/** mesh-control's `examples/modules`, so the manifests proven here are the ones that ship. */ +/** mesh-controller's `examples/modules`, so the manifests proven here are the ones that ship. */ const moduleExamples = process.env["MESH_LAB_MODULES"] ?? ""; const skip = !capability.usable @@ -44,7 +44,7 @@ const skip = !capability.usable : !binary || !existsSync(binary) ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" : !bundle || !existsSync(bundle) - ? "MESH_LAB_BUNDLE is not set to a substrate bundle (mesh-host examples/)" + ? "MESH_LAB_BUNDLE is not set to a foundation bundle (mesh-host examples/)" : false; const SCENARIO = "two-nodes"; @@ -105,7 +105,7 @@ async function must(machine: string, command: string, timeoutMs?: number): Promi /** The control plane, which runs in a container on the first node. */ async function mesh(command: string, timeoutMs?: number): Promise { - return must("anchor", `docker exec mesh-control /mesh-control ${command}`, timeoutMs); + return must("anchor", `docker exec mesh-controller /mesh-controller ${command}`, timeoutMs); } /** @@ -144,7 +144,7 @@ async function settled(node: string, withinMs = 480_000): Promise { let said = ""; try { const asked = await on("anchor", - `docker exec mesh-control /mesh-control status --json`); + `docker exec mesh-controller /mesh-controller status --json`); said = asked.out; // Parsed inside the try on purpose: a truncated answer from a struggling machine is the // same fact as no answer, and the likeliest moment for one is exactly the machine this @@ -186,11 +186,11 @@ async function settled(node: string, withinMs = 480_000): Promise { * The bundle, as a machine should receive it. * * The committed example was written for a target that had a registry the lab raised. Its two - * third-party images become upstream references the machine pulls itself; mesh-control, which + * third-party images become upstream references the machine pulls itself; mesh-controller, which * exists in no registry, becomes the ID this machine was handed. */ function bundleFor(images: HeldImage[]): string { - return substrateBundle(bundle, images); + return foundationBundle(bundle, images); } /** Take a token out of what `token issue` printed. It is the one base64url blob on its own line. */ @@ -253,8 +253,8 @@ before(async () => { // because the control plane's image is named by the ID this machine holds it under, which is not // knowable until it has been handed over. held = raised.images; - await must("anchor", `cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); - await must("anchor", `${HOST_PATH} apply /tmp/substrate.lock`); + await must("anchor", `cat > /tmp/foundation.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); + await must("anchor", `${HOST_PATH} apply /tmp/foundation.lock`); // A build machine, so anything here can ask the mesh to build something. Placed rather than // assumed: nothing else in this scenario would start one. @@ -289,7 +289,7 @@ after(async () => { test("a bare machine becomes a mesh", { skip, timeout: 600_000 }, async () => { const running = await must("anchor", `docker ps --format '{{.Names}}'`); - for (const container of ["mesh-store", "mesh-broker", "mesh-control"]) { + for (const container of ["mesh-store", "mesh-broker", "mesh-controller"]) { assert.match(running, new RegExp(container), `${container} is not running`); } // Answering, not merely up. A container that is running is not a control plane that replies — @@ -334,8 +334,8 @@ test("a credential reaches both ends and the mesh holds neither", { skip, timeou `"content":"PGHOST=$\{bound:postgres-database:at\}\\nPGPORT=$\{bound:postgres-database:port\}\\n` + `PGUSER=$\{bound:postgres-database:as\}\\nPGPASSWORD=$\{secret:postgres-database\}\\n"}]}' ` + `> /tmp/app.json`); - await must("anchor", `docker cp /tmp/pg.json mesh-control:/pg.json`); - await must("anchor", `docker cp /tmp/app.json mesh-control:/app.json`); + await must("anchor", `docker cp /tmp/pg.json mesh-controller:/pg.json`); + await must("anchor", `docker cp /tmp/app.json mesh-controller:/app.json`); await mesh("module add /pg.json"); await mesh("module add /app.json"); @@ -423,7 +423,7 @@ test("when a machine cannot do what it was told, the mesh says which and why", { // is the situation `status` exists to distinguish from a machine that refused everything. await must("anchor", `printf %s '{"module":"impossible","version":"1","resources":[` + `{"id":"nothing","type":"package","package":"a-package-that-does-not-exist"}]}' > /tmp/imp.json`); - await must("anchor", `docker cp /tmp/imp.json mesh-control:/imp.json`); + await must("anchor", `docker cp /tmp/imp.json mesh-controller:/imp.json`); await mesh("module add /imp.json"); await mesh("assign laptop impossible"); await mesh("push laptop"); @@ -471,7 +471,7 @@ test("a declaration waits for a machine that is switched off", { skip, timeout: await must("anchor", `printf %s '{"module":"while-away","version":"1","resources":[` + `{"id":"note","type":"file","path":"/etc/mesh-while-away","content":"waited"}]}' > /tmp/away.json`); - await must("anchor", `docker cp /tmp/away.json mesh-control:/away.json`); + await must("anchor", `docker cp /tmp/away.json mesh-controller:/away.json`); await mesh("module add /away.json"); await mesh("assign laptop while-away"); await mesh("push laptop"); @@ -512,13 +512,13 @@ test("a declaration waits for a machine that is switched off", { skip, timeout: test("unassigning takes away exactly what it should", { skip, timeout: 900_000 }, async () => { // Removal is the half nobody tests. The mesh takes away what IT declared and no longer declares, // and never what the machine raised for itself from its bundle — which is the fault that - // destroyed a substrate once (novox/hq 04-ISSUES/010). + // destroyed a foundation once (novox/hq 04-ISSUES/010). // // Two modules, so the test can tell "removed the right one" from "removed everything". for (const [name, path] of [["kept", "/etc/mesh-kept"], ["going", "/etc/mesh-going"]] as const) { await must("anchor", `printf %s '{"module":"${name}","version":"1","resources":[` + `{"id":"note","type":"file","path":"${path}","content":"${name}"}]}' > /tmp/${name}.json`); - await must("anchor", `docker cp /tmp/${name}.json mesh-control:/${name}.json`); + await must("anchor", `docker cp /tmp/${name}.json mesh-controller:/${name}.json`); await mesh(`module add /${name}.json`); await mesh(`assign anchor ${name}`); } @@ -536,11 +536,11 @@ test("unassigning takes away exactly what it should", { skip, timeout: 900_000 } assert.ok((await on("anchor", `test -f /etc/mesh-kept`)).ok, "unassigning one module took another one's file with it"); - // And the substrate this machine raised from its own bundle is untouched. It was not declared by + // And the foundation this machine raised from its own bundle is untouched. It was not declared by // the mesh, so the mesh must never remove it — the machine would take its own control plane // away, which is exactly what happened before origins existed. const running = await must("anchor", `docker ps --format '{{.Names}}'`); - for (const container of ["mesh-store", "mesh-broker", "mesh-control"]) { + for (const container of ["mesh-store", "mesh-broker", "mesh-controller"]) { assert.match(running, new RegExp(container), `${container} was removed by a declaration that never declared it`); } @@ -565,7 +565,7 @@ test("a machine that fell behind catches up without being named", { skip, timeou await must("anchor", `printf %s '{"module":"fixable","version":"1","resources":[` + `{"id":"pkg","type":"package","package":"a-package-that-does-not-exist-yet"},` + `{"id":"note","type":"file","path":"/etc/mesh-fixable","content":"here"}]}' > /tmp/fix.json`); - await must("anchor", `docker cp /tmp/fix.json mesh-control:/fix.json`); + await must("anchor", `docker cp /tmp/fix.json mesh-controller:/fix.json`); await mesh("module add /fix.json"); await mesh("assign laptop fixable"); await mesh("push laptop"); @@ -585,7 +585,7 @@ test("a machine that fell behind catches up without being named", { skip, timeou // Fix the cause, the way somebody would: the module stops asking for the impossible thing. await must("anchor", `printf %s '{"module":"fixable","version":"1","resources":[` + `{"id":"note","type":"file","path":"/etc/mesh-fixable","content":"here"}]}' > /tmp/fix.json`); - await must("anchor", `docker cp /tmp/fix.json mesh-control:/fix.json`); + await must("anchor", `docker cp /tmp/fix.json mesh-controller:/fix.json`); await mesh("module add /fix.json"); // And nobody names the machine. @@ -626,7 +626,7 @@ test("the mesh runs its own artifact store", { skip, timeout: 900_000 }, async ( // and a builder will not start without an artifact store to publish to, so a mesh that has just // bootstrapped cannot build the module that gives it one. Adding the manifest directly is the // path a real first mesh has to take, so it is the path this walks. - await must("anchor", `docker cp /root/registry/module.json mesh-control:/registry.json`); + await must("anchor", `docker cp /root/registry/module.json mesh-controller:/registry.json`); await mesh("module add /registry.json"); await mesh("assign anchor registry"); await mesh("push anchor"); @@ -658,7 +658,7 @@ test("a machine serves its internal name with a certificate the mesh issued", { `"certificate":{"into":"/etc/mesh/serving.crt","authority":"/etc/mesh/authority.crt"},` + `"resources":[{"id":"dir","type":"directory","path":"/etc/mesh","mode":"0755"}]}' ` + `> /tmp/served.json`); - await must("anchor", `docker cp /tmp/served.json mesh-control:/served.json`); + await must("anchor", `docker cp /tmp/served.json mesh-controller:/served.json`); await mesh("module add /served.json"); await mesh("assign anchor served"); await mesh("push anchor"); @@ -768,7 +768,7 @@ test("a machine filters exactly what its modules declared, and nothing else", { `{"id":"filter","type":"service","unit":"mesh-filter.service","state":"running",` + `"boot":"enabled","restart-on":["filtering"]}]}' > /tmp/firewall.json`); for (const f of ["talker", "firewall"]) { - await must("anchor", `docker cp /tmp/${f}.json mesh-control:/${f}.json`); + await must("anchor", `docker cp /tmp/${f}.json mesh-controller:/${f}.json`); await mesh(`module add /${f}.json`); } await mesh("assign laptop talker"); @@ -972,7 +972,7 @@ test("rotating a credential moves both ends, and the old one stops working", { `"resources":[{"id":"dir","type":"directory","path":"/etc/realapp","mode":"0755"}]}' ` + `> /tmp/realapp.json`); for (const f of ["realstore", "realapp"]) { - await must("anchor", `docker cp /tmp/${f}.json mesh-control:/${f}.json`); + await must("anchor", `docker cp /tmp/${f}.json mesh-controller:/${f}.json`); await mesh(`module add /${f}.json`); } await mesh("assign anchor realstore"); @@ -1081,7 +1081,7 @@ test("a route is a grant: a workload is reached by the name it asked for", { `{"id":"app","type":"container","name":"storefront",` + `"image":"${ARTIFACT_STORE}","ports":["8088:5000"]}]}' > /tmp/storefront.json`); for (const f of ["frontdoor", "storefront"]) { - await must("anchor", `docker cp /tmp/${f}.json mesh-control:/${f}.json`); + await must("anchor", `docker cp /tmp/${f}.json mesh-controller:/${f}.json`); await mesh(`module add /${f}.json`); } await mesh("assign anchor frontdoor"); @@ -1135,7 +1135,7 @@ test("a route is a grant: a workload is reached by the name it asked for", { assert.ok(withdrawn, `the route outlived the module that asked for it:\n${after}\n\n` + `the machine did apply — this is what the mesh would send now:\n` + - `${(await on("anchor", `docker exec mesh-control /mesh-control plan anchor --files`)).out}`); + `${(await on("anchor", `docker exec mesh-controller /mesh-controller plan anchor --files`)).out}`); let gone = false; for (let i = 0; i < 15 && !gone; i++) { @@ -1159,7 +1159,7 @@ test("model access is answered by a record, and the key the mesh took is one it `"secrets":{"model-access":"/etc/assistant/key"},` + `"resources":[{"id":"dir","type":"directory","path":"/etc/assistant","mode":"0755"}]}' ` + `> /tmp/assistant.json`); - await must("anchor", `docker cp /tmp/assistant.json mesh-control:/assistant.json`); + await must("anchor", `docker cp /tmp/assistant.json mesh-controller:/assistant.json`); await mesh("module add /assistant.json"); // The licences first. With none recorded at all the honest answer is that nothing provides @@ -1171,7 +1171,7 @@ test("model access is answered by a record, and the key the mesh took is one it // then says the machine's set cannot be applied. The refusal names both candidates and the // command. ADR 0024 warns this will be felt — which is correct, and correct is not the same as // usable. - const refused = await on("anchor", `docker exec mesh-control /mesh-control assign laptop assistant`); + const refused = await on("anchor", `docker exec mesh-controller /mesh-controller assign laptop assistant`); assert.ok(!refused.ok, `a consumer was given model access without anybody saying which:\n${refused.out}`); for (const want of ["personal", "the-organisation", "licence use"]) { @@ -1182,7 +1182,7 @@ test("model access is answered by a record, and the key the mesh took is one it await mesh("licence use personal laptop assistant"); // Chosen, and still no key: the mesh has one thing to deliver and has not been given it. - const noKey = await on("anchor", `docker exec mesh-control /mesh-control plan laptop`); + const noKey = await on("anchor", `docker exec mesh-controller /mesh-controller plan laptop`); assert.ok(!noKey.ok, `a module was planned with a licence that has no key:\n${noKey.out}`); assert.match(noKey.out, /licence key personal/, noKey.out); @@ -1190,7 +1190,7 @@ test("model access is answered by a record, and the key the mesh took is one it // command line is a key in shell history and in every process listing taken while it ran. const secret = "sk-test-" + "0123456789abcdef".repeat(2); const accepted = await must("anchor", - `printf %s ${quote(secret)} | docker exec -i mesh-control /mesh-control licence key personal`); + `printf %s ${quote(secret)} | docker exec -i mesh-controller /mesh-controller licence key personal`); assert.match(accepted, /sealed to 1 holder/, accepted); assert.doesNotMatch(accepted, new RegExp(secret), "the key was echoed back, so the one copy that matters is on a terminal"); @@ -1298,11 +1298,11 @@ test("the board names the machine that is not doing what it was told", { await must("anchor", `printf %s '{"module":"board","version":"1",` + `"listens":[{"port":8090,"from":"mesh","why":"the board"}],` + `"resources":[]}' > /tmp/board.json`); - await must("anchor", `docker cp /tmp/board.json mesh-control:/board.json`); + await must("anchor", `docker cp /tmp/board.json mesh-controller:/board.json`); await mesh("module add /board.json"); // Served from the control plane's own container, reading the mesh on every request. - await must("anchor", `docker exec -d mesh-control /mesh-control board --listen 0.0.0.0:8090`); + await must("anchor", `docker exec -d mesh-controller /mesh-controller board --listen 0.0.0.0:8090`); await new Promise((r) => setTimeout(r, 3000)); const read = async (path: string) => @@ -1325,7 +1325,7 @@ test("the board names the machine that is not doing what it was told", { await must("anchor", `printf %s '{"module":"impossible","version":"1",` + `"resources":[{"id":"nowhere","type":"service","unit":"nothing-like-this.service",` + `"state":"running"}]}' > /tmp/impossible.json`); - await must("anchor", `docker cp /tmp/impossible.json mesh-control:/impossible.json`); + await must("anchor", `docker cp /tmp/impossible.json mesh-controller:/impossible.json`); await mesh("module add /impossible.json"); await mesh("assign laptop impossible"); await mesh("push laptop"); @@ -1389,7 +1389,7 @@ test("the hub can be filtered without severing the mesh", { `ExecStart=/usr/bin/nft -f ${rules}\\n[Install]\\nWantedBy=multi-user.target\\n"},` + `{"id":"filter","type":"service","unit":"hub-filter.service","state":"running",` + `"boot":"enabled","restart-on":["filtering"]}]}' > /tmp/hubfilter.json`); - await must("anchor", `docker cp /tmp/hubfilter.json mesh-control:/hubfilter.json`); + await must("anchor", `docker cp /tmp/hubfilter.json mesh-controller:/hubfilter.json`); await mesh("module add /hubfilter.json"); await mesh("assign anchor hubfilter"); await mesh("push anchor"); @@ -1414,7 +1414,7 @@ test("the hub can be filtered without severing the mesh", { await must("anchor", `printf %s '{"module":"stillworks","version":"1",` + `"resources":[{"id":"marker","type":"file","path":"/etc/mesh-still-works",` + `"content":"yes","mode":"0644"}]}' > /tmp/stillworks.json`); - await must("anchor", `docker cp /tmp/stillworks.json mesh-control:/stillworks.json`); + await must("anchor", `docker cp /tmp/stillworks.json mesh-controller:/stillworks.json`); await mesh("module add /stillworks.json"); await mesh("assign laptop stillworks"); await mesh("push laptop"); @@ -1450,7 +1450,7 @@ test("a container reaches another machine by the name the mesh gave it", { `"capabilities":["container-runtime"],` + `"resources":[{"id":"idle","type":"container","name":"resolves",` + `"image":"${ARTIFACT_STORE}"}]}' > /tmp/resolves.json`); - await must("anchor", `docker cp /tmp/resolves.json mesh-control:/resolves.json`); + await must("anchor", `docker cp /tmp/resolves.json mesh-controller:/resolves.json`); await mesh("module add /resolves.json"); await mesh("assign laptop resolves"); await mesh("push laptop"); @@ -1539,7 +1539,7 @@ test("every name under a machine resolves to that machine", { }); test("a service is reached by a name under the machine it runs on", { - skip: skip || (!moduleExamples ? "set MESH_LAB_MODULES to mesh-control's examples/modules" : false), + skip: skip || (!moduleExamples ? "set MESH_LAB_MODULES to mesh-controller's examples/modules" : false), timeout: 900_000, }, async () => { // postgres.novox.internal, plex.ace.internal — the first label is the service and the rest is @@ -1555,7 +1555,7 @@ test("a service is reached by a name under the machine it runs on", { for (const name of ["dnsmasq", "resolved-split-dns"]) { const manifest = readFileSync(`${moduleExamples}/${name}.json`, "utf8"); await must("anchor", `cat > /tmp/${name}.json <<'MANIFEST'\n${manifest}\nMANIFEST`); - await must("anchor", `docker cp /tmp/${name}.json mesh-control:/${name}.json`); + await must("anchor", `docker cp /tmp/${name}.json mesh-controller:/${name}.json`); await mesh(`module add /${name}.json`); } @@ -1700,18 +1700,18 @@ test("a third-party workload is adopted, with the credential it already had", { }, ], }))} > /umami.json`); - await must("anchor", `docker cp /umami.json mesh-control:/umami.json`); + await must("anchor", `docker cp /umami.json mesh-controller:/umami.json`); await mesh("module add /umami.json"); // **Accepted, not generated.** The value is what the database already answers to; the mesh // seals it and cannot read it again. Given whole, as the environment lines the containers read. await must("anchor", `printf %s ${quote(`POSTGRES_PASSWORD=${password}`)} | ` + - `docker exec -i mesh-control /mesh-control secret accept anchor umami database --from -`); + `docker exec -i mesh-controller /mesh-controller secret accept anchor umami database --from -`); await must("anchor", `printf %s ${quote( `DATABASE_URL=postgresql://umami:${password}@umami-db:5432/umami`)} | ` + - `docker exec -i mesh-control /mesh-control secret accept anchor umami app --from -`); + `docker exec -i mesh-controller /mesh-controller secret accept anchor umami app --from -`); await mesh("assign anchor umami"); await mesh("push anchor", 300_000); @@ -1815,7 +1815,7 @@ test("the real modules resolve together, and compose a declaration a host accept } planned.push(name); await must("anchor", `printf %s ${quote(pinned)} > /${name}.json`); - await must("anchor", `docker cp /${name}.json mesh-control:/${name}.json`); + await must("anchor", `docker cp /${name}.json mesh-controller:/${name}.json`); await mesh(`module add /${name}.json`); } @@ -1932,7 +1932,7 @@ test("the forge runs, on a database the mesh gave it", { skip, timeout: 900_000 assert.deepEqual(stillUnpinned(pinned), [], `${name} still names an image nothing serves, so it could not start`); await must("anchor", `printf %s ${quote(pinned)} > /run-${name}.json`); - await must("anchor", `docker cp /run-${name}.json mesh-control:/run-${name}.json`); + await must("anchor", `docker cp /run-${name}.json mesh-controller:/run-${name}.json`); await mesh(`module add /run-${name}.json`); await mesh(`assign anchor ${name}`); } @@ -2018,7 +2018,7 @@ test("a consumer's cache grant means exactly its own keys", { skip, timeout: 600 assert.deepEqual(stillUnpinned(pinned), [], "redis still names an image nothing serves, so it could not start"); await must("anchor", `printf %s ${quote(pinned)} > /run-redis.json`); - await must("anchor", `docker cp /run-redis.json mesh-control:/run-redis.json`); + await must("anchor", `docker cp /run-redis.json mesh-controller:/run-redis.json`); await mesh("module add /run-redis.json"); // A consumer with no container: what is under test is the credential's reach, and files on the @@ -2030,7 +2030,7 @@ test("a consumer's cache grant means exactly its own keys", { skip, timeout: 600 `"secrets":{"redis-cache":"/var/lib/cachetest/cache.secret"},` + `"resources":[{"id":"state","type":"directory","path":"/var/lib/cachetest","mode":"0700"}]}' ` + `> /cachetest.json`); - await must("anchor", `docker cp /cachetest.json mesh-control:/cachetest.json`); + await must("anchor", `docker cp /cachetest.json mesh-controller:/cachetest.json`); await mesh("module add /cachetest.json"); await mesh("assign anchor redis"); diff --git a/test/integration/minio-grant-end-to-end.test.ts b/test/integration/minio-grant-end-to-end.test.ts index 1fae1de..34fd53e 100644 --- a/test/integration/minio-grant-end-to-end.test.ts +++ b/test/integration/minio-grant-end-to-end.test.ts @@ -9,7 +9,7 @@ * the consumer reaching its bucket with the access key and secret the mesh delivered it. Nothing is * placed by the test. * - * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock + * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock * scripts/build-module-runtime.sh minio builds mesh-runtime-minio:development (with mc), which * scenarios/minio-node.yml stocks. minio/minio:latest must be in the local daemon. */ @@ -21,7 +21,7 @@ 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, substrateBundle, onTheMachine } from "./harness.ts"; +import { labIsUsable, destroyAll, foundationBundle, onTheMachine } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -36,7 +36,7 @@ const skip = !capability.usable : !binary || !existsSync(binary) ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" : !bundle || !existsSync(bundle) - ? "MESH_LAB_BUNDLE is not set to a substrate bundle (mesh-host examples/)" + ? "MESH_LAB_BUNDLE is not set to a foundation bundle (mesh-host examples/)" : false; const SCENARIO = "minio-node"; @@ -65,7 +65,7 @@ async function must(command: string, timeoutMs?: number): Promise { } async function mesh(command: string, timeoutMs?: number): Promise { - return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); + return must(`docker exec mesh-controller /mesh-controller ${command}`, timeoutMs); } /** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ @@ -74,7 +74,7 @@ function pinned(reference: string): string { } function bundleFor(images: HeldImage[]): string { - return substrateBundle(bundle, images); + return foundationBundle(bundle, images); } function tokenFrom(said: string): string { @@ -93,7 +93,7 @@ async function settled(withinMs = 480_000): Promise { const until = Date.now() + withinMs; let last = ""; while (Date.now() < until) { - const asked = await on(`docker exec mesh-control /mesh-control status --json`); + const asked = await on(`docker exec mesh-controller /mesh-controller status --json`); if (asked.ok) { try { const state = JSON.parse(asked.out) as { @@ -125,11 +125,11 @@ before(async () => { instanceId = raised.instanceId; held = raised.images; - await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); - await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); + await must(`cat > /tmp/foundation.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); + await must(`${HOST_PATH} apply /tmp/foundation.lock`, 600_000); const up = await must(`docker ps --format '{{.Names}}'`); - for (const c of ["mesh-store", "mesh-broker", "mesh-control"]) { - assert.match(up, new RegExp(c), `the substrate did not raise ${c}:\n${up}`); + for (const c of ["mesh-store", "mesh-broker", "mesh-controller"]) { + assert.match(up, new RegExp(c), `the foundation did not raise ${c}:\n${up}`); } await mesh(`node add ${MACHINE}`); @@ -203,12 +203,12 @@ test("the mesh grants a consumer an S3 bucket, and the credential it delivers re resources: [{ id: "state", type: "directory", path: "/var/lib/bucketuser", mode: "0700" }], }); - await must(`printf %s ${quote(minioManifest)} > /tmp/minio.json && docker cp /tmp/minio.json mesh-control:/minio.json`); + await must(`printf %s ${quote(minioManifest)} > /tmp/minio.json && docker cp /tmp/minio.json mesh-controller:/minio.json`); await mesh("module add /minio.json"); await mesh(`module issue minio --node ${MACHINE}`); await mesh(`assign ${MACHINE} minio`); - await must(`printf %s ${quote(consumerManifest)} > /tmp/bucketuser.json && docker cp /tmp/bucketuser.json mesh-control:/bucketuser.json`); + await must(`printf %s ${quote(consumerManifest)} > /tmp/bucketuser.json && docker cp /tmp/bucketuser.json mesh-controller:/bucketuser.json`); await mesh("module add /bucketuser.json"); await mesh(`assign ${MACHINE} bucketuser`); diff --git a/test/integration/objectstore.test.ts b/test/integration/objectstore.test.ts index 337025a..1b08379 100644 --- a/test/integration/objectstore.test.ts +++ b/test/integration/objectstore.test.ts @@ -33,7 +33,7 @@ const skip = !capability.usable ? `lab not usable: ${capability.why}` : !provisioner ? "set MESH_LAB_OBJECTSTORE_PROVISIONER to a built provisioner " + - "(mesh-control: go build ./examples/objectstore-provisioner)" + "(mesh-controller: go build ./examples/objectstore-provisioner)" : false; const SCENARIO = "an-object-store"; diff --git a/test/integration/one-node-mesh.test.ts b/test/integration/one-node-mesh.test.ts index 4f17c66..f381028 100644 --- a/test/integration/one-node-mesh.test.ts +++ b/test/integration/one-node-mesh.test.ts @@ -31,9 +31,9 @@ * MESH_LAB_INCUS='sudo -n incus' * MESH_LAB_HOST_BINARY=.../mesh-host/mesh-host * MESH_LAB_BOOTSTRAP_BINARY=.../mesh-host/mesh-bootstrap - * MESH_LAB_BUNDLE=.../mesh-host/examples/substrate-first-node.lock + * MESH_LAB_BUNDLE=.../mesh-host/examples/foundation-first-node.lock * MESH_LAB_CATALOG=.../mesh-catalog/modules - * MESH_LAB_SOURCE=/mesh-control.git MESH_LAB_SOURCE_REF= + * MESH_LAB_SOURCE=/mesh-controller.git MESH_LAB_SOURCE_REF= * MESH_LAB_KEEP=1 to leave it standing afterwards */ import { test, before, after } from "node:test"; @@ -45,7 +45,7 @@ import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; import { destroy, exec, push } from "../../src/lifecycle/operate.ts"; import { bootstrapBinaryPath, hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll, substrateBundle } from "./harness.ts"; +import { labIsUsable, destroyAll, foundationBundle } from "./harness.ts"; import { genesis, type GenesisResult } from "./genesis.ts"; import { incus } from "../../src/incus/client.ts"; import { instanceNameOf } from "../../src/lifecycle/operate.ts"; @@ -69,7 +69,7 @@ const REGISTRY = `${ANCHOR}:5000`; */ const BASE = { module: "mesh-tools", repo: "mesh-tools", path: "" }; /** - * What `amqp-ping` requires, and what the substrate does not supply. + * What `amqp-ping` requires, and what the foundation does not supply. * * The installer raises a broker, but as a bundle resource — plumbing, not a module the mesh has a * record of, so it provides nothing to anything. A module asking for `amqp` is asking for a @@ -98,7 +98,7 @@ const FILTER_MODULE = "nftables"; const STORE = { module: "postgres", repo: "mesh-catalog", path: "modules/postgres", container: "mesh-postgres" }; const CATALOGUE = { module: "mesh-catalog", repo: "mesh-catalog", path: "modules/mesh-catalog", container: "mesh-catalog" }; /** The control plane, rebuilt from its own repository — the step that ends the installer's tenure. */ -const CONTROL_PLANE = { module: "mesh-control", repo: "mesh-control", path: "", container: "mesh-control" }; +const CONTROL_PLANE = { module: "mesh-controller", repo: "mesh-controller", path: "", container: "mesh-controller" }; /** * What this mesh must hold when it is finished, and what must be RUNNING on the machine. @@ -108,14 +108,14 @@ const CONTROL_PLANE = { module: "mesh-control", repo: "mesh-control", path: "", * the registry and the builder — are here too: they are carried in, and a mesh missing any of them * is not one. */ -const MUST_HOLD = ["mesh-control", "distribution", "builder", "mesh-tools", "postgres", +const MUST_HOLD = ["mesh-controller", "distribution", "builder", "mesh-tools", "postgres", "mesh-catalog", "lavinmq", "amqp-ping"]; -const MUST_RUN = ["mesh-control", "mesh-registry", "mesh-broker", "mesh-store", +const MUST_RUN = ["mesh-controller", "mesh-registry", "mesh-broker", "mesh-store", "mesh-postgres", "mesh-catalog", "mesh-lavinmq", "amqp-ping"]; /** Named once, because the step title is also how later steps say what they waited on. */ const NEEDS = "the mesh runs a broker for that module to talk to"; const GENESIS = "a bare machine becomes a mesh of one, raised by the installer"; -const SUBSTRATE = "the substrate is up — a store and a broker of the mesh's own"; +const FOUNDATION = "the foundation is up — a store and a broker of the mesh's own"; const BUILT_CP = "the control plane is one this mesh built, not one it was handed"; const PIVOTED = "the pivot finished — what raised the mesh is gone"; const HAS_REGISTRY = "the registry serves this mesh its own images"; @@ -191,7 +191,7 @@ const skip = !installer ? "MESH_LAB_BOOTSTRAP_BINARY is not set to a built mesh-bootstrap" : !source ? "MESH_LAB_SOURCE is not set to the repository the control plane is built from" : !sourceRef ? "MESH_LAB_SOURCE_REF is not set to the commit to build" : - !bundle || !existsSync(bundle) ? "MESH_LAB_BUNDLE is not set to a substrate template" : + !bundle || !existsSync(bundle) ? "MESH_LAB_BUNDLE is not set to a foundation template" : !catalogDir || !existsSync(catalogDir) ? "MESH_LAB_CATALOG is not set to mesh-catalog/modules" : false; @@ -220,19 +220,19 @@ async function mesh(command: string, timeoutMs?: number): Promise { // The control plane is a live, mesh-MANAGED container: the mesh recreates it whenever its // declaration changes — most visibly when the machine first gets its `.internal` name on the // private network, which becomes the container's `--add-host` (containers are immutable, so a new - // spec is a new container). A `docker exec mesh-control` that lands in that brief recreate window + // spec is a new container). A `docker exec mesh-controller` that lands in that brief recreate window // fails with "container ... is not running". That is not the mesh being wrong — it is a command // racing a legitimate restart — so it is retried until the control plane answers again. A real // command failure (anything else) still throws at once. const deadline = Date.now() + (timeoutMs ?? 120_000); for (;;) { - const { out, ok } = await on(CONTROL, `docker exec mesh-control /mesh-control ${command}`, timeoutMs); + const { out, ok } = await on(CONTROL, `docker exec mesh-controller /mesh-controller ${command}`, timeoutMs); if (ok) return out; if (/is not running|No such container/i.test(out) && Date.now() < deadline) { await new Promise((r) => setTimeout(r, 2_000)); continue; } - throw new Error(`${CONTROL}: docker exec mesh-control /mesh-control ${command}\n${out}`); + throw new Error(`${CONTROL}: docker exec mesh-controller /mesh-controller ${command}\n${out}`); } } @@ -304,7 +304,7 @@ async function waitForContainer(node: string, container: string, seconds = 200): * * **The control plane runs in a container, so a file on the machine is not a file it can open.** * Pushing the manifest to the machine and naming that path got `no such file or directory` from - * inside mesh-control, which is correct and was briefly mistaken for a missing manifest. It is + * inside mesh-controller, which is correct and was briefly mistaken for a missing manifest. It is * copied the last step of the way with `docker cp`. * * **Into the root, not into /tmp.** The control plane's image is a minimal one and has no `/tmp` @@ -315,7 +315,7 @@ async function registerModule(module: string, manifest: string): Promise const onMachine = `/tmp/${module}.json`; const inContainer = `/${module}.json`; await push(instanceId, CONTROL, manifest, onMachine); - await must(CONTROL, `docker cp ${onMachine} mesh-control:${inContainer}`); + await must(CONTROL, `docker cp ${onMachine} mesh-controller:${inContainer}`); return mesh(`module add ${inContainer}`); } function tokenFrom(said: string): string { @@ -393,7 +393,7 @@ function report(name: string): string { */ const PLAN: { code: string; title: string }[] = [ { code: "R1", title: GENESIS }, - { code: "R2", title: SUBSTRATE }, + { code: "R2", title: FOUNDATION }, { code: "R3", title: BUILT_CP }, { code: "R4", title: PIVOTED }, { code: "R5", title: HAS_REGISTRY }, @@ -479,7 +479,7 @@ before(async () => { // whose anchor is somewhere else every node, including this one, would enrol against an // address nothing answers on. The installer refuses to guess it and says so, which is // right: it does not know what this machine is called from outside. - bundleTemplate: substrateBundle(bundle, []).replaceAll("192.0.2.10:5671", `${ANCHOR}:5671`), + bundleTemplate: foundationBundle(bundle, []).replaceAll("192.0.2.10:5671", `${ANCHOR}:5671`), registry: REGISTRY, source, sourceRef, @@ -514,7 +514,7 @@ before(async () => { // saying it published an image and the registry serving one are different facts, and it is the // second that matters. - await step("R2", SUBSTRATE, GENESIS, async () => { + await step("R2", FOUNDATION, GENESIS, async () => { await waitForContainer(CONTROL, "mesh-store", 120); await waitForContainer(CONTROL, "mesh-broker", 120); return (await on(CONTROL, `docker ps --format '{{.Names}}\t{{.Status}}'`)).out; @@ -525,7 +525,7 @@ before(async () => { // the image has to be one this mesh's own registry serves. await step("R3", BUILT_CP, GENESIS, async () => { const image = (await on(CONTROL, - `docker inspect -f '{{.Config.Image}}' mesh-control 2>&1`)).out.trim(); + `docker inspect -f '{{.Config.Image}}' mesh-controller 2>&1`)).out.trim(); assert.match(image, /@sha256:[0-9a-f]{64}/, `the control plane names its image by tag, not by digest: ${image}`); assert.ok(image.includes(":5000/"), @@ -535,17 +535,17 @@ before(async () => { await step("R4", PIVOTED, BUILT_CP, async () => { const ps = (await on(CONTROL, `docker ps -a --format '{{.Names}}'`)).out; - assert.doesNotMatch(ps, /^temp-mesh-control$/m, + assert.doesNotMatch(ps, /^temp-mesh-controller$/m, `the temporary control plane is still here, so the pivot did not finish:\n${ps}`); return ps; }); - await step("R5", HAS_REGISTRY, SUBSTRATE, async () => { + await step("R5", HAS_REGISTRY, FOUNDATION, async () => { await waitForContainer(CONTROL, "mesh-registry", 120); const held = (await on(CONTROL, `curl -sS --max-time 15 http://127.0.0.1:5000/v2/_catalog`)).out; - assert.match(held, /mesh-control/, - `the registry serves no mesh-control, so nothing was published into it:\n${held}`); + assert.match(held, /mesh-controller/, + `the registry serves no mesh-controller, so nothing was published into it:\n${held}`); return held.trim(); }); @@ -597,7 +597,7 @@ before(async () => { return built; }); - // A store of its own. **Not the substrate's.** The installer raises a store for the control + // A store of its own. **Not the foundation's.** The installer raises a store for the control // plane to keep its own records in, the way it raises a broker — plumbing, not a module the mesh // has any record of, so it provides nothing to anything. A module that wants a database wants a // provider in the graph, and the catalogue below is the first thing to want one. @@ -790,7 +790,7 @@ before(async () => { // broker: a module's account is scoped to what it declares it emits and consumes, and calling // a tool needs a temporary reply queue that scope does not cover. So a module can SERVE tools // and cannot CALL them, and nothing issues an account to anyone who wants to ask (novox/hq - // issue 049). Until that is decided the caller is the substrate's bootstrap admin over the + // issue 049). Until that is decided the caller is the foundation's bootstrap admin over the // broker's loopback, reached by joining its network namespace. const image = (await on(CONTROL, `docker inspect -f '{{.Config.Image}}' mesh-catalog`)).out.trim(); @@ -1061,7 +1061,7 @@ after(async () => { for (const name of [ GENESIS, - SUBSTRATE, + FOUNDATION, BUILT_CP, PIVOTED, HAS_REGISTRY, diff --git a/test/integration/openai-bed.test.ts b/test/integration/openai-bed.test.ts index f15d57d..f80f327 100644 --- a/test/integration/openai-bed.test.ts +++ b/test/integration/openai-bed.test.ts @@ -12,7 +12,7 @@ * ordinary sealed-delivery path — with NO manager, NO refresh, NO access/refresh split, NO usage. The * whole of OpenAI's integration in the control plane is one registry line (vendor -> static-key). * - * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock + * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock * Build the consumer runtime image into the local daemon first: * scripts/build-module-runtime.sh openai-consumer /tmp/openai-consumer.tar */ @@ -24,7 +24,7 @@ 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, substrateBundle, onTheMachine } from "./harness.ts"; +import { labIsUsable, destroyAll, foundationBundle, onTheMachine } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -36,7 +36,7 @@ const skip = !capability.usable : !binary || !existsSync(binary) ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" : !bundle || !existsSync(bundle) - ? "MESH_LAB_BUNDLE is not set to a substrate bundle (mesh-host examples/)" + ? "MESH_LAB_BUNDLE is not set to a foundation bundle (mesh-host examples/)" : false; const SCENARIO = "openai-bed"; @@ -69,11 +69,11 @@ async function must(command: string, timeoutMs?: number): Promise { } async function mesh(command: string, timeoutMs?: number): Promise { - return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); + return must(`docker exec mesh-controller /mesh-controller ${command}`, timeoutMs); } async function meshTry(command: string): Promise<{ out: string; ok: boolean }> { - return on(`docker exec mesh-control /mesh-control ${command}`); + return on(`docker exec mesh-controller /mesh-controller ${command}`); } /** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ @@ -82,7 +82,7 @@ function pinned(reference: string): string { } function bundleFor(images: HeldImage[]): string { - return substrateBundle(bundle, images); + return foundationBundle(bundle, images); } function tokenFrom(said: string): string { @@ -135,11 +135,11 @@ before(async () => { instanceId = raised.instanceId; held = raised.images; - await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); - await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); + await must(`cat > /tmp/foundation.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); + await must(`${HOST_PATH} apply /tmp/foundation.lock`, 600_000); const up = await must(`docker ps --format '{{.Names}}'`); - for (const c of ["mesh-store", "mesh-broker", "mesh-control"]) { - assert.match(up, new RegExp(c), `the substrate did not raise ${c}:\n${up}`); + for (const c of ["mesh-store", "mesh-broker", "mesh-controller"]) { + assert.match(up, new RegExp(c), `the foundation did not raise ${c}:\n${up}`); } await mesh(`node add ${MACHINE}`); @@ -159,7 +159,7 @@ test("a static-key model-access licence delivers the operator's API key to the c const consumerImage = pinned("mesh-runtime-openai-consumer"); // --- the licence, a record with vendor openai (static-key) ------------------------------------- - // No manager: a static-key licence has none (mesh-control refuses `licence manager` on it). The + // No manager: a static-key licence has none (mesh-controller refuses `licence manager` on it). The // consumer is put on the licence BEFORE the key is set — the static-key adapter's Accept seals to the // CURRENT holders, so a holder must exist first, or the key would seal to nobody. await mesh(`licence add openai personal --serves '{"model":"gpt-model"}'`); @@ -167,8 +167,8 @@ test("a static-key model-access licence delivers the operator's API key to the c // The operator sets the static key. `licence key` reads it from a file (never a CLI arg) and seals it // to the holder; the plaintext is discarded by the control plane. Staged 0644 so distroless - // mesh-control can read the file (docker cp preserves the mode). - await must(`printf %s ${quote(API_KEY)} > /tmp/openai-key && chmod 0644 /tmp/openai-key && docker cp /tmp/openai-key mesh-control:/openai-key`); + // mesh-controller can read the file (docker cp preserves the mode). + await must(`printf %s ${quote(API_KEY)} > /tmp/openai-key && chmod 0644 /tmp/openai-key && docker cp /tmp/openai-key mesh-controller:/openai-key`); await mesh(`licence key personal --file /openai-key`); // --- deploy the consumer ----------------------------------------------------------------------- @@ -199,7 +199,7 @@ test("a static-key model-access licence delivers the operator's API key to the c }, ], }); - await must(`printf %s ${quote(consumerManifest)} > /tmp/openai-consumer.json && docker cp /tmp/openai-consumer.json mesh-control:/openai-consumer.json`); + await must(`printf %s ${quote(consumerManifest)} > /tmp/openai-consumer.json && docker cp /tmp/openai-consumer.json mesh-controller:/openai-consumer.json`); await mesh(`module add /openai-consumer.json`); await mesh(`module issue openai-consumer --node ${MACHINE}`); await mesh(`assign ${MACHINE} openai-consumer`); diff --git a/test/integration/postgres-grant-end-to-end.test.ts b/test/integration/postgres-grant-end-to-end.test.ts index 3ee0521..1859f98 100644 --- a/test/integration/postgres-grant-end-to-end.test.ts +++ b/test/integration/postgres-grant-end-to-end.test.ts @@ -9,7 +9,7 @@ * the mesh minted. The proof is the consumer connecting to its database with the credential the mesh * delivered it. Nothing is placed by the test. * - * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock + * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock * scripts/build-module-runtime.sh postgres builds mesh-runtime-postgres:development (with psql), * which scenarios/postgres-node.yml stocks. */ @@ -21,7 +21,7 @@ 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, substrateBundle, onTheMachine } from "./harness.ts"; +import { labIsUsable, destroyAll, foundationBundle, onTheMachine } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -33,7 +33,7 @@ const skip = !capability.usable : !binary || !existsSync(binary) ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" : !bundle || !existsSync(bundle) - ? "MESH_LAB_BUNDLE is not set to a substrate bundle (mesh-host examples/)" + ? "MESH_LAB_BUNDLE is not set to a foundation bundle (mesh-host examples/)" : false; const SCENARIO = "postgres-node"; @@ -62,7 +62,7 @@ async function must(command: string, timeoutMs?: number): Promise { } async function mesh(command: string, timeoutMs?: number): Promise { - return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); + return must(`docker exec mesh-controller /mesh-controller ${command}`, timeoutMs); } /** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ @@ -71,7 +71,7 @@ function pinned(reference: string): string { } function bundleFor(images: HeldImage[]): string { - return substrateBundle(bundle, images); + return foundationBundle(bundle, images); } function tokenFrom(said: string): string { @@ -84,7 +84,7 @@ async function settled(withinMs = 480_000): Promise { const until = Date.now() + withinMs; let last = ""; while (Date.now() < until) { - const asked = await on(`docker exec mesh-control /mesh-control status --json`); + const asked = await on(`docker exec mesh-controller /mesh-controller status --json`); if (asked.ok) { try { const state = JSON.parse(asked.out) as { @@ -116,11 +116,11 @@ before(async () => { instanceId = raised.instanceId; held = raised.images; - await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); - await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); + await must(`cat > /tmp/foundation.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); + await must(`${HOST_PATH} apply /tmp/foundation.lock`, 600_000); const up = await must(`docker ps --format '{{.Names}}'`); - for (const c of ["mesh-store", "mesh-broker", "mesh-control"]) { - assert.match(up, new RegExp(c), `the substrate did not raise ${c}:\n${up}`); + for (const c of ["mesh-store", "mesh-broker", "mesh-controller"]) { + assert.match(up, new RegExp(c), `the foundation did not raise ${c}:\n${up}`); } await mesh(`node add ${MACHINE}`); @@ -157,7 +157,7 @@ test("the mesh grants a consumer a postgres database, and the credential it deli { id: "data", type: "directory", path: "/services/postgres/db-data", mode: "0700" }, { id: "net", type: "network", name: "postgres" }, { - // No published port here: the substrate's own store already holds host :5432 on this + // No published port here: the foundation's own store already holds host :5432 on this // single-node bed, and the consumer reaches postgres over the private network by name. The // committed manifest publishes it for cross-node consumers, which is a different node. id: "server", type: "container", name: "postgres", image: pinned("postgres"), network: "postgres", @@ -194,12 +194,12 @@ test("the mesh grants a consumer a postgres database, and the credential it deli resources: [{ id: "state", type: "directory", path: "/var/lib/dbuser", mode: "0700" }], }); - await must(`printf %s ${quote(postgresManifest)} > /tmp/postgres.json && docker cp /tmp/postgres.json mesh-control:/postgres.json`); + await must(`printf %s ${quote(postgresManifest)} > /tmp/postgres.json && docker cp /tmp/postgres.json mesh-controller:/postgres.json`); await mesh("module add /postgres.json"); await mesh(`module issue postgres --node ${MACHINE}`); await mesh(`assign ${MACHINE} postgres`); - await must(`printf %s ${quote(consumerManifest)} > /tmp/dbuser.json && docker cp /tmp/dbuser.json mesh-control:/dbuser.json`); + await must(`printf %s ${quote(consumerManifest)} > /tmp/dbuser.json && docker cp /tmp/dbuser.json mesh-controller:/dbuser.json`); await mesh("module add /dbuser.json"); await mesh(`assign ${MACHINE} dbuser`); diff --git a/test/integration/provider-on-backend-network.test.ts b/test/integration/provider-on-backend-network.test.ts index 4b3f1b8..18b1894 100644 --- a/test/integration/provider-on-backend-network.test.ts +++ b/test/integration/provider-on-backend-network.test.ts @@ -20,7 +20,7 @@ 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, substrateBundle, onTheMachine } from "./harness.ts"; +import { labIsUsable, destroyAll, foundationBundle, onTheMachine } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -32,7 +32,7 @@ const skip = !capability.usable : !binary || !existsSync(binary) ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" : !bundle || !existsSync(bundle) - ? "MESH_LAB_BUNDLE is not set to a substrate bundle (mesh-host examples/)" + ? "MESH_LAB_BUNDLE is not set to a foundation bundle (mesh-host examples/)" : false; const SCENARIO = "redis-node"; @@ -61,7 +61,7 @@ async function must(command: string, timeoutMs?: number): Promise { } async function mesh(command: string, timeoutMs?: number): Promise { - return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); + return must(`docker exec mesh-controller /mesh-controller ${command}`, timeoutMs); } /** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ @@ -70,7 +70,7 @@ function pinned(reference: string): string { } function bundleFor(images: HeldImage[]): string { - return substrateBundle(bundle, images); + return foundationBundle(bundle, images); } function tokenFrom(said: string): string { @@ -83,7 +83,7 @@ async function settled(withinMs = 480_000): Promise { const until = Date.now() + withinMs; let last = ""; while (Date.now() < until) { - const asked = await on(`docker exec mesh-control /mesh-control status --json`); + const asked = await on(`docker exec mesh-controller /mesh-controller status --json`); if (asked.ok) { try { const state = JSON.parse(asked.out) as { @@ -115,11 +115,11 @@ before(async () => { instanceId = raised.instanceId; held = raised.images; - await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); - await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); + await must(`cat > /tmp/foundation.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); + await must(`${HOST_PATH} apply /tmp/foundation.lock`, 600_000); const up = await must(`docker ps --format '{{.Names}}'`); - for (const c of ["mesh-store", "mesh-broker", "mesh-control"]) { - assert.match(up, new RegExp(c), `the substrate did not raise ${c}:\n${up}`); + for (const c of ["mesh-store", "mesh-broker", "mesh-controller"]) { + assert.match(up, new RegExp(c), `the foundation did not raise ${c}:\n${up}`); } await mesh(`node add ${MACHINE}`); @@ -184,7 +184,7 @@ test("redis's runtime, on the backend's private network, binds the broker and pr }, ], }); - await must(`printf %s ${quote(manifest)} > /tmp/redis.json && docker cp /tmp/redis.json mesh-control:/redis.json`); + await must(`printf %s ${quote(manifest)} > /tmp/redis.json && docker cp /tmp/redis.json mesh-controller:/redis.json`); await mesh("module add /redis.json"); await mesh(`module issue redis --node ${MACHINE}`); await mesh(`assign ${MACHINE} redis`); diff --git a/test/integration/provider-uses-mesh-credential.test.ts b/test/integration/provider-uses-mesh-credential.test.ts index 1fca4ba..1e72897 100644 --- a/test/integration/provider-uses-mesh-credential.test.ts +++ b/test/integration/provider-uses-mesh-credential.test.ts @@ -9,10 +9,10 @@ * password gets PONG — where a provisioner that invented its own password would answer WRONGPASS. * * A hand-written contributions file and secret stand in for the control plane here (a full grant - * from a second module is a heavier bed); their SHAPE is exactly what mesh-control writes — a + * from a second module is a heavier bed); their SHAPE is exactly what mesh-controller writes — a * `receives` doc with `as`/`secret`, and the secret file the host leaves after unsealing. * - * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock + * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock * scripts/build-module-runtime.sh redis builds mesh-runtime-redis:development, which * scenarios/redis-node.yml stocks. */ @@ -24,7 +24,7 @@ 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, substrateBundle, onTheMachine } from "./harness.ts"; +import { labIsUsable, destroyAll, foundationBundle, onTheMachine } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -36,7 +36,7 @@ const skip = !capability.usable : !binary || !existsSync(binary) ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" : !bundle || !existsSync(bundle) - ? "MESH_LAB_BUNDLE is not set to a substrate bundle (mesh-host examples/)" + ? "MESH_LAB_BUNDLE is not set to a foundation bundle (mesh-host examples/)" : false; const SCENARIO = "redis-node"; @@ -65,7 +65,7 @@ async function must(command: string, timeoutMs?: number): Promise { } async function mesh(command: string, timeoutMs?: number): Promise { - return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); + return must(`docker exec mesh-controller /mesh-controller ${command}`, timeoutMs); } /** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ @@ -74,7 +74,7 @@ function pinned(reference: string): string { } function bundleFor(images: HeldImage[]): string { - return substrateBundle(bundle, images); + return foundationBundle(bundle, images); } function tokenFrom(said: string): string { @@ -87,7 +87,7 @@ async function settled(withinMs = 480_000): Promise { const until = Date.now() + withinMs; let last = ""; while (Date.now() < until) { - const asked = await on(`docker exec mesh-control /mesh-control status --json`); + const asked = await on(`docker exec mesh-controller /mesh-controller status --json`); if (asked.ok) { try { const state = JSON.parse(asked.out) as { @@ -119,11 +119,11 @@ before(async () => { instanceId = raised.instanceId; held = raised.images; - await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); - await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); + await must(`cat > /tmp/foundation.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); + await must(`${HOST_PATH} apply /tmp/foundation.lock`, 600_000); const up = await must(`docker ps --format '{{.Names}}'`); - for (const c of ["mesh-store", "mesh-broker", "mesh-control"]) { - assert.match(up, new RegExp(c), `the substrate did not raise ${c}:\n${up}`); + for (const c of ["mesh-store", "mesh-broker", "mesh-controller"]) { + assert.match(up, new RegExp(c), `the foundation did not raise ${c}:\n${up}`); } await mesh(`node add ${MACHINE}`); @@ -184,7 +184,7 @@ test("redis creates a consumer's login with the password the mesh minted, sealin }, ], }); - await must(`printf %s ${quote(manifest)} > /tmp/redis.json && docker cp /tmp/redis.json mesh-control:/redis.json`); + await must(`printf %s ${quote(manifest)} > /tmp/redis.json && docker cp /tmp/redis.json mesh-controller:/redis.json`); await mesh("module add /redis.json"); await mesh(`module issue redis --node ${MACHINE}`); await mesh(`assign ${MACHINE} redis`); diff --git a/test/integration/provisioner.test.ts b/test/integration/provisioner.test.ts index 853d14b..f4a43ef 100644 --- a/test/integration/provisioner.test.ts +++ b/test/integration/provisioner.test.ts @@ -25,7 +25,7 @@ const provisioner = process.env["MESH_LAB_PROVISIONER"] ?? ""; const skip = !capability.usable ? `lab not usable: ${capability.why}` : !provisioner - ? "set MESH_LAB_PROVISIONER to a built provisioner (mesh-control: go build ./examples/postgres-provisioner)" + ? "set MESH_LAB_PROVISIONER to a built provisioner (mesh-controller: go build ./examples/postgres-provisioner)" : false; const SCENARIO = "a-provider"; diff --git a/test/integration/route-forwarding.test.ts b/test/integration/route-forwarding.test.ts index bb8fe23..9ded2b1 100644 --- a/test/integration/route-forwarding.test.ts +++ b/test/integration/route-forwarding.test.ts @@ -25,7 +25,7 @@ * publicly-trusted certificate and answering an HTTP-01 challenge at the name — is proven separately * by certificates.test.ts, which drives the same proxy binary against a real ACME server (Pebble). * - * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock + * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock * scripts/build-route-proxy-image.sh builds mesh-route-proxy:development into the local daemon; * scenarios/route-forwarding.yml stocks it and alpine:latest, and serves both by digest. */ @@ -37,7 +37,7 @@ 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, substrateBundle, onTheMachine } from "./harness.ts"; +import { labIsUsable, destroyAll, foundationBundle, onTheMachine } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -49,7 +49,7 @@ const skip = !capability.usable : !binary || !existsSync(binary) ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" : !bundle || !existsSync(bundle) - ? "MESH_LAB_BUNDLE is not set to a substrate bundle (mesh-host examples/)" + ? "MESH_LAB_BUNDLE is not set to a foundation bundle (mesh-host examples/)" : false; const SCENARIO = "route-forwarding"; @@ -81,7 +81,7 @@ async function must(command: string, timeoutMs?: number): Promise { /** The control plane, a container on the node. */ async function mesh(command: string, timeoutMs?: number): Promise { - return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); + return must(`docker exec mesh-controller /mesh-controller ${command}`, timeoutMs); } /** The reference a manifest should carry, once this scenario has been raised. */ @@ -90,9 +90,9 @@ function pinned(reference: string): string { return onTheMachine(reference, held); } -/** The substrate bundle: ours by the ID the machine holds, everything else upstream. */ +/** The foundation bundle: ours by the ID the machine holds, everything else upstream. */ function bundleFor(images: HeldImage[]): string { - return substrateBundle(bundle, images); + return foundationBundle(bundle, images); } function tokenFrom(said: string): string { @@ -105,7 +105,7 @@ async function settled(withinMs = 600_000): Promise { const until = Date.now() + withinMs; let last = ""; while (Date.now() < until) { - const asked = await on(`docker exec mesh-control /mesh-control status --json`); + const asked = await on(`docker exec mesh-controller /mesh-controller status --json`); if (asked.ok) { try { const state = JSON.parse(asked.out) as { @@ -143,12 +143,12 @@ before(async () => { instanceId = raised.instanceId; held = raised.images; - // Raise the substrate — store, broker, control — from the bundle. - await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); - await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); + // Raise the foundation — store, broker, control — from the bundle. + await must(`cat > /tmp/foundation.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); + await must(`${HOST_PATH} apply /tmp/foundation.lock`, 600_000); const up = await must(`docker ps --format '{{.Names}}'`); - for (const c of ["mesh-store", "mesh-broker", "mesh-control"]) { - assert.match(up, new RegExp(c), `the substrate did not raise ${c}:\n${up}`); + for (const c of ["mesh-store", "mesh-broker", "mesh-controller"]) { + assert.match(up, new RegExp(c), `the foundation did not raise ${c}:\n${up}`); } // The node joins its own mesh, so it is a node the mesh can assign to, and the host runs so it @@ -216,13 +216,13 @@ test("the mesh routes a public name through the proxy to the consumer, and withd ], }); - await must(`printf %s ${quote(proxyManifest)} > /tmp/route-proxy.json && docker cp /tmp/route-proxy.json mesh-control:/route-proxy.json`); + await must(`printf %s ${quote(proxyManifest)} > /tmp/route-proxy.json && docker cp /tmp/route-proxy.json mesh-controller:/route-proxy.json`); await mesh("module add /route-proxy.json"); // No `module issue`: route-proxy has no broker account and no own-secret to mint. `assign` resolves // its plan and the provider is matchable by a consumer's route from that alone. await mesh(`assign ${MACHINE} route-proxy`); - await must(`printf %s ${quote(webManifest)} > /tmp/hello-web.json && docker cp /tmp/hello-web.json mesh-control:/hello-web.json`); + await must(`printf %s ${quote(webManifest)} > /tmp/hello-web.json && docker cp /tmp/hello-web.json mesh-controller:/hello-web.json`); await mesh("module add /hello-web.json"); await mesh(`assign ${MACHINE} hello-web`); diff --git a/test/integration/runtime-restart-on-config.test.ts b/test/integration/runtime-restart-on-config.test.ts index 6eaf500..5dc1bcb 100644 --- a/test/integration/runtime-restart-on-config.test.ts +++ b/test/integration/runtime-restart-on-config.test.ts @@ -12,7 +12,7 @@ * the container was replaced (a new container id) and the config on disk carries the new value. * It builds the host from source (no --no-build), because the behaviour under test is the host's. * - * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock + * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock * scripts/build-module-runtime.sh grafana builds mesh-runtime-grafana:development, which * scenarios/grafana-node.yml stocks. */ @@ -24,7 +24,7 @@ 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, substrateBundle, onTheMachine } from "./harness.ts"; +import { labIsUsable, destroyAll, foundationBundle, onTheMachine } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -36,7 +36,7 @@ const skip = !capability.usable : !binary || !existsSync(binary) ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" : !bundle || !existsSync(bundle) - ? "MESH_LAB_BUNDLE is not set to a substrate bundle (mesh-host examples/)" + ? "MESH_LAB_BUNDLE is not set to a foundation bundle (mesh-host examples/)" : false; const SCENARIO = "grafana-node"; @@ -65,7 +65,7 @@ async function must(command: string, timeoutMs?: number): Promise { } async function mesh(command: string, timeoutMs?: number): Promise { - return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); + return must(`docker exec mesh-controller /mesh-controller ${command}`, timeoutMs); } /** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ @@ -74,7 +74,7 @@ function pinned(reference: string): string { } function bundleFor(images: HeldImage[]): string { - return substrateBundle(bundle, images); + return foundationBundle(bundle, images); } function tokenFrom(said: string): string { @@ -87,7 +87,7 @@ async function settled(withinMs = 480_000): Promise { const until = Date.now() + withinMs; let last = ""; while (Date.now() < until) { - const asked = await on(`docker exec mesh-control /mesh-control status --json`); + const asked = await on(`docker exec mesh-controller /mesh-controller status --json`); if (asked.ok) { try { const state = JSON.parse(asked.out) as { @@ -112,7 +112,7 @@ async function settled(withinMs = 480_000): Promise { async function setToken(token: string): Promise { const settings = JSON.stringify({ url: "http://127.0.0.1:3000", token }); - await must(`printf %s ${quote(settings)} > /tmp/s.json && docker cp /tmp/s.json mesh-control:/s.json`); + await must(`printf %s ${quote(settings)} > /tmp/s.json && docker cp /tmp/s.json mesh-controller:/s.json`); await mesh(`settings set grafana /s.json --node ${MACHINE}`); } @@ -129,11 +129,11 @@ before(async () => { instanceId = raised.instanceId; held = raised.images; - await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); - await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); + await must(`cat > /tmp/foundation.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); + await must(`${HOST_PATH} apply /tmp/foundation.lock`, 600_000); const up = await must(`docker ps --format '{{.Names}}'`); - for (const c of ["mesh-store", "mesh-broker", "mesh-control"]) { - assert.match(up, new RegExp(c), `the substrate did not raise ${c}:\n${up}`); + for (const c of ["mesh-store", "mesh-broker", "mesh-controller"]) { + assert.match(up, new RegExp(c), `the foundation did not raise ${c}:\n${up}`); } await mesh(`node add ${MACHINE}`); @@ -170,7 +170,7 @@ test("a running runtime is recreated when its settings change, and reads the new }, ], }); - await must(`printf %s ${quote(manifest)} > /tmp/grafana.json && docker cp /tmp/grafana.json mesh-control:/grafana.json`); + await must(`printf %s ${quote(manifest)} > /tmp/grafana.json && docker cp /tmp/grafana.json mesh-controller:/grafana.json`); await mesh("module add /grafana.json"); await setToken("token-alpha"); diff --git a/test/integration/whole-mesh-ace.test.ts b/test/integration/whole-mesh-ace.test.ts index 39606aa..44cca2d 100644 --- a/test/integration/whole-mesh-ace.test.ts +++ b/test/integration/whole-mesh-ace.test.ts @@ -1,9 +1,9 @@ /** * The whole `ace` server's converted service set, installed together on ONE node behind the - * substrate — the media / home-automation half of the whole-mesh rehearsal (novox/hq). Sibling of + * foundation — the media / home-automation half of the whole-mesh rehearsal (novox/hq). Sibling of * whole-mesh-novox.test.ts; same harness and topology, a larger, media-heavy module set. * - * Substrate (store, broker, control) rides `anchor` and nothing else; ALL of ace's services ride the + * Foundation (store, broker, control) rides `anchor` and nothing else; ALL of ace's services ride the * `ace` node. An overlay is placed so the two DB consumers (baserow, letta) reach the postgres/redis * providers co-located with them. The media stack shares the operator-owned library directories * under /services/media (ADR 0051 `accesses`): the mesh writes an `access` resource that CONFIRMS @@ -20,7 +20,7 @@ * references of OURS are rewritten to the IDs the machine holds, and the co-located * host-port collisions are remapped at load time (see REMAP). * - * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock + * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock */ import { test, before, after } from "node:test"; @@ -31,7 +31,7 @@ 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, substrateBundle, onTheMachine } from "./harness.ts"; +import { labIsUsable, destroyAll, foundationBundle, onTheMachine } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -44,7 +44,7 @@ const skip = !capability.usable : !binary || !existsSync(binary) ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" : !bundle || !existsSync(bundle) - ? "MESH_LAB_BUNDLE is not set to a substrate bundle (mesh-host examples/)" + ? "MESH_LAB_BUNDLE is not set to a foundation bundle (mesh-host examples/)" : false; const SCENARIO = "whole-mesh-ace"; @@ -171,7 +171,7 @@ async function must(machine: string, command: string, timeoutMs?: number): Promi } async function mesh(command: string, timeoutMs?: number): Promise { - return must("anchor", `docker exec mesh-control /mesh-control ${command}`, timeoutMs); + return must("anchor", `docker exec mesh-controller /mesh-controller ${command}`, timeoutMs); } /** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ @@ -180,7 +180,7 @@ function pinned(reference: string): string { } function bundleFor(images: HeldImage[]): string { - return substrateBundle(bundle, images); + return foundationBundle(bundle, images); } function loadManifest(name: string): { manifest: string; broker: boolean } { @@ -214,7 +214,7 @@ interface NodeState { } async function nodeState(node: string): Promise { - const asked = await on("anchor", `docker exec mesh-control /mesh-control status --json`); + const asked = await on("anchor", `docker exec mesh-controller /mesh-controller status --json`); if (!asked.ok) return { reached: false, applied: false, current: false, waiting: false, raw: asked.out }; let state: { wrong: { node: string; outcome: string; refused?: string; failed?: { id: string; error: string }[] }[]; @@ -248,11 +248,11 @@ before(async () => { instanceId = raised.instanceId; held = raised.images; - await must("anchor", `cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); - await must("anchor", `${HOST_PATH} apply /tmp/substrate.lock`, 900_000); + await must("anchor", `cat > /tmp/foundation.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); + await must("anchor", `${HOST_PATH} apply /tmp/foundation.lock`, 900_000); const up = await must("anchor", `docker ps --format '{{.Names}}'`); - for (const c of ["mesh-store", "mesh-broker", "mesh-control"]) { - assert.match(up, new RegExp(c), `the substrate did not raise ${c}:\n${up}`); + for (const c of ["mesh-store", "mesh-broker", "mesh-controller"]) { + assert.match(up, new RegExp(c), `the foundation did not raise ${c}:\n${up}`); } for (const machine of ["anchor", NODE]) { @@ -294,7 +294,7 @@ test("the whole ace service set resolves, installs and converges on one node in if (DROPPED.some((d) => d.name === name)) continue; try { const { manifest, broker } = loadManifest(name); - await must("anchor", `printf %s ${quote(manifest)} > /tmp/${name}.json && docker cp /tmp/${name}.json mesh-control:/${name}.json`); + await must("anchor", `printf %s ${quote(manifest)} > /tmp/${name}.json && docker cp /tmp/${name}.json mesh-controller:/${name}.json`); await mesh(`module add /${name}.json`); if (broker) { await mesh(`module issue ${name} --node ${NODE}`); diff --git a/test/integration/whole-mesh-full.test.ts b/test/integration/whole-mesh-full.test.ts index 7198827..82f4009 100644 --- a/test/integration/whole-mesh-full.test.ts +++ b/test/integration/whole-mesh-full.test.ts @@ -5,12 +5,12 @@ * * hosting (public) home (private, behind a NAT access point) * novox 192.0.2.20 — the ANCHOR: ace 10.99.1.10 the home server, media/IoT set - * substrate (store/broker/ shanks 10.99.1.20 workstation (light: portainer only) + * foundation (store/broker/ shanks 10.99.1.20 workstation (light: portainer only) * control) + the whole novox g14 10.99.1.30 workstation (light: portainer only) * set + overlay hub + ingress * - * There is NO separate anchor: novox IS the anchor. The substrate runs on novox, and novox also - * enrols as a node and receives its own service set — the substrate host and a service node at once. + * There is NO separate anchor: novox IS the anchor. The foundation runs on novox, and novox also + * enrols as a node and receives its own service set — the foundation host and a service node at once. * * TWO ACTS, AND THE BED NOW DISTINGUISHES THEM (novox/hq ADR 0067). * @@ -22,9 +22,9 @@ * is a WORKING MESH OF ONE, and this bed asserts exactly that before going any further. * * JOINING — ace, shanks and g14 then join a mesh that already exists: host binary, token, - * `enrol`, run the agent. No bootstrap, no substrate, no registry. novox is NOT enrolled again. + * `enrol`, run the agent. No bootstrap, no foundation, no registry. novox is NOT enrolled again. * - * The bed used to do neither. It applied the substrate bundle itself and looped enrolment over all + * The bed used to do neither. It applied the foundation bundle itself and looped enrolment over all * four machines as one continuous operation — which got the order right by accident and modelled * the wrong shape, and is why ADR 0067's own acceptance check ("the bed bootstraps through the * installer rather than around it") went unmet. Genesis GATES joining: if it stops, the bed says @@ -39,10 +39,10 @@ * BEFORE any heavy module lands, so the cross-segment-overlay verdict survives whatever the module * convergence then does. Phase B converges the full node sets and reports per node. * - * SUBSTRATE-ON-NOVOX PORT COLLISIONS (a real consequence of collapsing the anchor onto novox that the - * separate-anchor beds never hit): the substrate store binds 127.0.0.1:5432 and novox's postgres - * provider publishes 5432; the substrate broker binds 5671 + 127.0.0.1:5672 and novox's lavinmq - * provider publishes 5672. The two provider host publishes are REMAPPED off the substrate's ports + * FOUNDATION-ON-NOVOX PORT COLLISIONS (a real consequence of collapsing the anchor onto novox that the + * separate-anchor beds never hit): the foundation store binds 127.0.0.1:5432 and novox's postgres + * provider publishes 5432; the foundation broker binds 5671 + 127.0.0.1:5672 and novox's lavinmq + * provider publishes 5672. The two provider host publishes are REMAPPED off the foundation's ports * (REMAP below); consumers reach the providers over the mesh network on the container port, so the * host side is free to move. Reported as a topology finding. * @@ -50,7 +50,7 @@ * (whole-mesh-full-live) and NOT torn down — it is left standing and browsable. Without it the bed * behaves like every other: raise in before(), destroy in after(). * - * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock + * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock * MESH_LAB_BOOTSTRAP_BINARY=.../mesh-bootstrap MESH_LAB_CATALOG=.../mesh-catalog/modules */ @@ -65,7 +65,7 @@ import { destroy, exec, instanceNameOf, push } from "../../src/lifecycle/operate import { bootstrapBinaryPath, hostBinaryPath, placeBootstrap, BOOTSTRAP_PATH, HOST_PATH, } from "../../src/lifecycle/place.ts"; -import { labIsUsable, destroyAll, substrateBundle, onTheMachine } from "./harness.ts"; +import { labIsUsable, destroyAll, foundationBundle, onTheMachine } from "./harness.ts"; import { referenceFor, type HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -84,7 +84,7 @@ const skip = !capability.usable : !binary || !existsSync(binary) ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" : !bundle || !existsSync(bundle) - ? "MESH_LAB_BUNDLE is not set to a substrate bundle (mesh-host examples/)" + ? "MESH_LAB_BUNDLE is not set to a foundation bundle (mesh-host examples/)" : !installer || !existsSync(installer) ? "MESH_LAB_BOOTSTRAP_BINARY is not set to a built mesh-bootstrap (mesh-host `make " + "bootstrap IMAGE=mesh-builder:development`). The anchor is raised BY the installer now, " + @@ -96,7 +96,7 @@ const skip = !capability.usable : false; const SCENARIO = "whole-mesh-full"; -/** novox hosts the substrate and the control plane; it is where `mesh` commands run. */ +/** novox hosts the foundation and the control plane; it is where `mesh` commands run. */ const CONTROL = "novox"; /** Every node in the mesh. novox is on hosting; the rest are behind the home gateway. */ const NODES = ["novox", "ace", "shanks", "g14"]; @@ -118,7 +118,7 @@ const CATALOGUE_ON_MACHINE = "/opt/mesh-catalog"; * `internal/bootstrap` RegistryModule and ControlPlaneModule. If it ever opens a third, this list * is where the bed finds out, by the installer saying which manifest it could not read. */ -const CATALOGUE_MODULES = ["registry", "mesh-control", "builder"]; +const CATALOGUE_MODULES = ["registry", "mesh-controller", "builder"]; /** * Where this mesh keeps its own images, as the anchor reaches it. @@ -221,7 +221,7 @@ const CORE_NOVOX = new Set([ const GAPS_NOVOX = new Set([ "umami", "mailu", "firewall", "fail2ban", "only-office", "de-spiegel", "amqp-email-forwarder", // step-ca is reported, not gated: the internal-CA ISSUANCE path is still being fixed in - // mesh-control, and this bed is not the place to discover that a fix has not landed yet. What is + // mesh-controller, and this bed is not the place to discover that a fix has not landed yet. What is // gated is the half that is decided and cheap — see the ADR 0066 section at the end. "step-ca", ]); @@ -274,13 +274,13 @@ const PLAN: { node: string; mods: Mod[]; core: Set; gaps: Set }[ /** * Host-port remaps (per module; host ports are per-VM so novox's and ace's never clash across nodes). - * The two SUBSTRATE collisions are the new ones: postgres 5432 and lavinmq 5672 are moved off the - * substrate store/broker's host ports, which only exist on novox because that is where the substrate + * The two FOUNDATION collisions are the new ones: postgres 5432 and lavinmq 5672 are moved off the + * foundation store/broker's host ports, which only exist on novox because that is where the foundation * runs. The rest break the novox web/app host-port collisions (route-proxy fronts 80/443). */ const REMAP: Record> = { // Moved, NOT bound to loopback. These two carried `127.0.0.1:` and it broke a consumer on a node - // with no substrate at all: a module is told to reach its provider at `.internal`, that + // with no foundation at all: a module is told to reach its provider at `.internal`, that // name resolves to the node's overlay address, and a provider listening only on loopback refuses // it. letta on ace died of exactly this — "is the server running on that host and accepting // TCP/IP connections?" — while postgres sat healthy beside it. @@ -344,7 +344,7 @@ async function must(machine: string, command: string, timeoutMs?: number): Promi /** The control plane, a container on novox (the anchor). */ async function mesh(command: string, timeoutMs?: number): Promise { - return must(CONTROL, `docker exec mesh-control /mesh-control ${command}`, timeoutMs); + return must(CONTROL, `docker exec mesh-controller /mesh-controller ${command}`, timeoutMs); } /** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ @@ -353,7 +353,7 @@ function pinned(reference: string): string { } function bundleFor(images: HeldImage[]): string { - return substrateBundle(bundle, images); + return foundationBundle(bundle, images); } function loadManifest(name: string): { manifest: string; broker: boolean } { @@ -405,7 +405,7 @@ interface NodeState { } async function nodeState(node: string): Promise { - const asked = await on(CONTROL, `docker exec mesh-control /mesh-control status --json`); + const asked = await on(CONTROL, `docker exec mesh-controller /mesh-controller status --json`); if (!asked.ok) return { reached: false, applied: false, current: false, waiting: false, raw: asked.out }; let state: { wrong: { node: string; outcome: string; refused?: string; failed?: { id: string; error: string }[] }[]; @@ -467,9 +467,9 @@ async function deliverCaRoot(): Promise { // Safe here and nowhere else: these three exist for the seconds between being written and // being sealed to the machine, on a lab node, for a CA thrown away with the scenario. "chmod 0644 /tmp/ca/root.crt /tmp/ca/root.key /tmp/ca/key-password", - "docker cp /tmp/ca/root.crt mesh-control:/ca-root-cert", - "docker cp /tmp/ca/root.key mesh-control:/ca-root-key", - "docker cp /tmp/ca/key-password mesh-control:/ca-root-key-password", + "docker cp /tmp/ca/root.crt mesh-controller:/ca-root-cert", + "docker cp /tmp/ca/root.key mesh-controller:/ca-root-key", + "docker cp /tmp/ca/key-password mesh-controller:/ca-root-key-password", ].join("\n"), 180_000); if (!made.ok) { console.log(`CA ROOT NOT MADE on ${CONTROL}:\n${made.out.split("\n").slice(-8).join("\n")}`); @@ -527,7 +527,7 @@ function stepIn(said: string): string { * Put on the anchor what the installer needs to read, and run it. * * **This is the whole of what changed, and it is not a refactor.** The bed used to apply the - * substrate bundle itself, by hand, and then enrol four machines in one loop. It got the order + * foundation bundle itself, by hand, and then enrol four machines in one loop. It got the order * right by accident and it modelled the wrong shape: an install procedure that exists only as a * test fixture is exercised by whoever writes tests and never by whoever installs, which is why * every bootstrap fault this year was found late (novox/hq ADR 0067). The anchor is now raised by @@ -566,31 +566,31 @@ async function genesis(images: HeldImage[]): Promise { } report.push(` catalogue ${CATALOGUE_MODULES.join(", ")} at ${CATALOGUE_ON_MACHINE}`); - // The substrate TEMPLATE — not the bundle. The installer produces the bundle from it: it replaces + // The foundation TEMPLATE — not the bundle. The installer produces the bundle from it: it replaces // the control plane's image with the id of the image it carries, renames that container - // `temp-mesh-control`, and writes the result where a person can read it. + // `temp-mesh-controller`, and writes the result where a person can read it. // // Two substitutions still happen here, and both belong to the bed rather than to the installer. // The example names three images at a registry the lab no longer raises: the store and the broker // become the upstream references mesh-catalog pins (harness), and the machine pulls them over its - // uplink like any first node. The third, mesh-control, is deliberately LEFT naming that dead + // uplink like any first node. The third, mesh-controller, is deliberately LEFT naming that dead // registry — the installer overwrites it, and leaving it proves that it does. // // And the broker's advertised address. The template hardcodes 192.0.2.10:5671, the old // separate-anchor address; a token carries MESH_BROKER_ADDRESS verbatim as the endpoint an - // enrolling node dials, so with the substrate on novox it must be novox's own public address or + // enrolling node dials, so with the foundation on novox it must be novox's own public address or // every node would enrol against a dead one. The installer refuses to guess this and says so // loudly, which is right — it does not know what this machine is called from outside. const template = bundleFor(images).replaceAll("192.0.2.10:5671", "192.0.2.20:5671"); - const local = join(tmpdir(), `mesh-lab-substrate-${process.pid}.lock`); + const local = join(tmpdir(), `mesh-lab-foundation-${process.pid}.lock`); writeFileSync(local, template); - await push(instanceId, CONTROL, local, "/tmp/substrate-template.lock"); + await push(instanceId, CONTROL, local, "/tmp/foundation-template.lock"); const command = [ BOOTSTRAP_PATH, `--source ${source}`, `--source-ref ${sourceRef}`, - `--bundle /tmp/substrate-template.lock`, + `--bundle /tmp/foundation-template.lock`, `--catalog ${CATALOGUE_ON_MACHINE}`, `--node ${CONTROL}`, `--registry ${MESH_REGISTRY}`, @@ -628,10 +628,10 @@ async function genesis(images: HeldImage[]): Promise { // ------------------------------------------------------------------------------------------ // 1. The control plane answers. Asked of the PERMANENT container by name — `status` opens all - // three stores, so a reply proves the connections it was given are the substrate's own. - const answered = await on(CONTROL, `docker exec mesh-control /mesh-control status`, 60_000); + // three stores, so a reply proves the connections it was given are the foundation's own. + const answered = await on(CONTROL, `docker exec mesh-controller /mesh-controller status`, 60_000); report.push(` control plane ${answered.ok ? answered.out.split("\n")[0] : "NO ANSWER"}`); - if (!answered.ok) return stop("after step 10", `mesh-control does not answer:\n${answered.out}`); + if (!answered.ok) return stop("after step 10", `mesh-controller does not answer:\n${answered.out}`); // 2. The registry replies on /v2/ — the registry API's own "yes, I am one and I am ready". A // container that is up is not a registry that serves. @@ -643,22 +643,22 @@ async function genesis(images: HeldImage[]): Promise { // 3. THE PIVOT COMPLETED. ADR 0067 states this check in as many words: after installing, the // running control plane's image is pinned by a digest THE MESH'S OWN REGISTRY ASSIGNED — not - // by an image id. If it is still an image id, the substrate's container is what is running, + // by an image id. If it is still an image id, the foundation's container is what is running, // nothing was published, and this mesh can never roll out its own upgrades. - const pinnedTo = (await on(CONTROL, `docker inspect --format '{{.Config.Image}}' mesh-control`)) + const pinnedTo = (await on(CONTROL, `docker inspect --format '{{.Config.Image}}' mesh-controller`)) .out.trim(); report.push(` pinned to ${pinnedTo || "(nothing)"}`); if (/^sha256:[0-9a-f]{64}$/.test(pinnedTo)) { return stop("after step 10", - `mesh-control is running from ${pinnedTo}, which is an IMAGE ID — the digest of the image's ` + + `mesh-controller is running from ${pinnedTo}, which is an IMAGE ID — the digest of the image's ` + `own configuration, which no registry ever served. The pivot did not happen: what is ` + `running is the image the installer carried, not one this mesh published, so this mesh ` + `cannot upgrade itself (novox/hq ADR 0067, "the pivot completed").`); } - if (!new RegExp(`^${MESH_REGISTRY.replaceAll(".", "\\.")}/mesh-control@sha256:[0-9a-f]{64}$`) + if (!new RegExp(`^${MESH_REGISTRY.replaceAll(".", "\\.")}/mesh-controller@sha256:[0-9a-f]{64}$`) .test(pinnedTo)) { return stop("after step 10", - `mesh-control is running from ${pinnedTo || "nothing this bed could read"}, which is not a ` + + `mesh-controller is running from ${pinnedTo || "nothing this bed could read"}, which is not a ` + `digest assigned by ${MESH_REGISTRY}.`); } @@ -669,36 +669,36 @@ async function genesis(images: HeldImage[]): Promise { // outside to one that can — same container, same digest, same registry. The difference is // whether a build happened, and the only place that is visible is the installer saying so. const wanted = sourceRef.slice(0, 8); - if (!new RegExp(`built mesh-control from ${wanted}`).test(said)) { + if (!new RegExp(`built mesh-controller from ${wanted}`).test(said)) { return stop("after the last step", - `the installer never said it built mesh-control from ${wanted}. What runs may have been ` + + `the installer never said it built mesh-controller from ${wanted}. What runs may have been ` + `carried rather than made here, which is a mesh that cannot rebuild its own control plane.`); } - report.push(` built here mesh-control from ${wanted}, by the carried builder`); + report.push(` built here mesh-controller from ${wanted}, by the carried builder`); // 3b. And the registry really serves it, asked of the registry rather than of the container. A // reference is a claim; a tag list is the registry agreeing. const tags = await on(CONTROL, - `curl -s --max-time 10 http://${MESH_REGISTRY}/v2/mesh-control/tags/list`); + `curl -s --max-time 10 http://${MESH_REGISTRY}/v2/mesh-controller/tags/list`); report.push(` registry holds ${tags.out.trim() || "nothing"}`); if (!tags.out.includes("genesis")) { return stop("after step 10", - `${MESH_REGISTRY} does not serve mesh-control, so the digest the container is pinned to ` + + `${MESH_REGISTRY} does not serve mesh-controller, so the digest the container is pinned to ` + `names an image nothing can pull: ${tags.out.trim()}`); } // 4. The temporary control plane is GONE. Two control planes is the half-finished state, and the - // name is the audit: a machine running mesh-control and not temp-mesh-control has pivoted. - const temp = await on(CONTROL, `docker inspect --format '{{.State.Status}}' temp-mesh-control`); - report.push(` temp-mesh-control ${temp.ok ? `STILL HERE (${temp.out.trim()})` : "gone"}`); + // name is the audit: a machine running mesh-controller and not temp-mesh-controller has pivoted. + const temp = await on(CONTROL, `docker inspect --format '{{.State.Status}}' temp-mesh-controller`); + report.push(` temp-mesh-controller ${temp.ok ? `STILL HERE (${temp.out.trim()})` : "gone"}`); if (temp.ok) { return stop("after step 10", - `temp-mesh-control is still ${temp.out.trim()}. Two control planes are consuming this ` + + `temp-mesh-controller is still ${temp.out.trim()}. Two control planes are consuming this ` + `mesh's broker queues; neither is wrong and the pivot is not finished.`); } // 5. And the mesh has heard from its one node. Everything the join phase does next depends on it. - const nodes = await on(CONTROL, `docker exec mesh-control /mesh-control node list`); + const nodes = await on(CONTROL, `docker exec mesh-controller /mesh-controller node list`); report.push(` node list ${nodes.out.trim().split("\n").join(" | ")}`); const line = nodes.out.split("\n").map((l) => l.trim()).find((l) => l.startsWith(`${CONTROL} `)); if (!line || !/^\S+\s+here\b/.test(line)) { @@ -715,7 +715,7 @@ async function genesis(images: HeldImage[]): Promise { // ================================================================================================== /** - * ace, shanks and g14 join. Host binary plus a token — no bootstrap, no substrate, no registry. + * ace, shanks and g14 join. Host binary plus a token — no bootstrap, no foundation, no registry. * * **novox is not in this loop.** It was enrolled by the installer, as part of becoming a mesh, and * enrolling it again would present the mesh with a second identity for a node it already knows — @@ -866,7 +866,7 @@ test("the full mesh forms across the access point and both server sets converge" const known = added.get(name); if (known !== undefined) return known; const { manifest, broker } = loadManifest(name); - await must(CONTROL, `printf %s ${quote(manifest)} > /tmp/${name}.json && docker cp /tmp/${name}.json mesh-control:/${name}.json`); + await must(CONTROL, `printf %s ${quote(manifest)} > /tmp/${name}.json && docker cp /tmp/${name}.json mesh-controller:/${name}.json`); await mesh(`module add /${name}.json`); added.set(name, broker); return broker; @@ -892,7 +892,7 @@ test("the full mesh forms across the access point and both server sets converge" // Operator-provided app credentials (own-secrets), delivered as fake values through `secret accept`. const credentialDelivered = new Map(); for (const name of new Set(CREDENTIALS.map((c) => c.name))) { - await must(CONTROL, `printf %s ${quote(`fake-${name}-value`)} > /tmp/fake-${name} && docker cp /tmp/fake-${name} mesh-control:/fake-${name}`); + await must(CONTROL, `printf %s ${quote(`fake-${name}-value`)} > /tmp/fake-${name} && docker cp /tmp/fake-${name} mesh-controller:/fake-${name}`); } for (const c of CREDENTIALS) { if (!assigned[c.node]!.has(c.module)) { @@ -912,7 +912,7 @@ test("the full mesh forms across the access point and both server sets converge" if (!assigned[s.node]!.has(s.module)) continue; try { const inControl = `/secret-${s.module}-${s.name}`; - await must(CONTROL, `printf %s ${quote(s.value)} > /tmp${inControl} && docker cp /tmp${inControl} mesh-control:${inControl}`); + await must(CONTROL, `printf %s ${quote(s.value)} > /tmp${inControl} && docker cp /tmp${inControl} mesh-controller:${inControl}`); await mesh(`secret accept ${s.node} ${s.module} ${s.name} --from ${inControl}`); } catch (err) { console.log(`OPERATOR SECRET FAILED ${s.node}/${s.module}/${s.name}: ${(err as Error).message.split("\n").slice(0, 2).join(" | ")}`); @@ -1021,7 +1021,7 @@ test("the full mesh forms across the access point and both server sets converge" // and before this bed set a public domain it composed to nothing on every node, silently. // // What is NOT checked here is issuance: whether route-proxy actually obtains a certificate from - // step-ca over ACME. That path is being fixed in mesh-control as this is written, and a bed that + // step-ca over ACME. That path is being fixed in mesh-controller as this is written, and a bed that // gated on it would be reporting somebody else's in-flight work as this bed's failure. // ================================================================================================ const adr: string[] = ["================ ADR 0066: LABELLED ROUTES ================"]; diff --git a/test/integration/whole-mesh-novox.test.ts b/test/integration/whole-mesh-novox.test.ts index edbf11c..85d1dd4 100644 --- a/test/integration/whole-mesh-novox.test.ts +++ b/test/integration/whole-mesh-novox.test.ts @@ -1,11 +1,11 @@ /** * The whole `novox` server's converted service set, installed together on ONE node behind the - * substrate — the whole-catalogue install this rebuild has never actually run. First stage of a + * foundation — the whole-catalogue install this rebuild has never actually run. First stage of a * whole-mesh rehearsal (novox/hq). * - * Topology (proven by assigned-two-node-db.test.ts): the substrate (store, broker, control) rides + * Topology (proven by assigned-two-node-db.test.ts): the foundation (store, broker, control) rides * `anchor` and nothing else; ALL of novox's services ride the `novox` node. novox's own postgres - * provider owns 5432 there, so it cannot co-locate with the substrate store. An overlay is placed so + * provider owns 5432 there, so it cannot co-locate with the foundation store. An overlay is placed so * each consumer's binding `at` resolves to novox's private address and reaches the providers * co-located with it. * @@ -28,7 +28,7 @@ * host ports here (container ports unchanged); the provider ports the consumers actually connect to * (postgres 5432, minio 9000, mongodb 27017, mssql 1433) are left as-is. See REMAP below. * - * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock + * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock * scripts/build-module-runtime.sh builds one runtime per module that has code; the route-proxy image * is built by scripts/build-route-proxy-image.sh; scenarios/whole-mesh-novox.yml stocks them all * alongside every server image. @@ -42,7 +42,7 @@ 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, substrateBundle, onTheMachine } from "./harness.ts"; +import { labIsUsable, destroyAll, foundationBundle, onTheMachine } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -55,13 +55,13 @@ const skip = !capability.usable : !binary || !existsSync(binary) ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" : !bundle || !existsSync(bundle) - ? "MESH_LAB_BUNDLE is not set to a substrate bundle (mesh-host examples/)" + ? "MESH_LAB_BUNDLE is not set to a foundation bundle (mesh-host examples/)" : false; const SCENARIO = "whole-mesh-novox"; const NODE = "novox"; -/** Where the committed module.json files live: the mesh-catalog beside mesh-control. */ +/** Where the committed module.json files live: the mesh-catalog beside mesh-controller. */ const catalogDir = process.env["MESH_LAB_CATALOG"] ?? (modulesEnv ? resolve(dirname(dirname(dirname(modulesEnv))), "mesh-catalog", "modules") : "") ?? resolve(process.cwd(), "..", "mesh-catalog", "modules"); @@ -103,7 +103,7 @@ const MODULES: { name: string; containers: string[]; node?: boolean }[] = [ * fail2ban declares `capabilities: ["intrusion-prevention"]`, but mesh-host advertises no such * capability: profile/detectors.go defines container-runtime, package-manager, service-manager, * firewall, overlay, seat, privileged and graphical-session — nothing for intrusion-prevention. So - * NO node can ever host fail2ban. Worse, `mesh-control assign` records the assignment even while + * NO node can ever host fail2ban. Worse, `mesh-controller assign` records the assignment even while * reporting it "cannot be applied", and the whole-node `push` then refuses to resolve the ENTIRE node * ("nothing was sent") over that one un-hostable assignment — one bad module blocks every other. It * is therefore left unassigned here so the rest of the set can be proven. (novox/hq — escalated.) @@ -118,8 +118,8 @@ const DROPPED: { name: string; why: string }[] = [ /** * The provable CORE: modules that converge WHOLE on this node (every container up and stable) once - * the substrate resolves and applies the set. This bed gates green on the CORE — a regression in any - * of these turns it red. It is the substrate + all five providers + the four consumers that reach + * the foundation resolves and applies the set. This bed gates green on the CORE — a regression in any + * of these turns it red. It is the foundation + all five providers + the four consumers that reach * their providers and stay up + the four standalone apps. */ const CORE = new Set([ @@ -132,7 +132,7 @@ const CORE = new Set([ * KNOWN GAPS: modules that RESOLVE and are placed, but whose containers do not stay up because the * committed catalog manifest is incomplete (or, for firewall, a node-service expectation is unmet). * They are reported every run with the exact failure, and escalated (novox/hq) — but they do not gate - * green, because the gap is in the catalog/host, not in this bed or the mesh substrate. + * green, because the gap is in the catalog/host, not in this bed or the mesh foundation. * * umami — the mesh-umami provisioner needs the umami server URL and admin password in its * provisioner.env; the manifest wires neither, so it dies "UMAMI url or admin password @@ -185,7 +185,7 @@ async function must(machine: string, command: string, timeoutMs?: number): Promi /** The control plane, a container on the first node. */ async function mesh(command: string, timeoutMs?: number): Promise { - return must("anchor", `docker exec mesh-control /mesh-control ${command}`, timeoutMs); + return must("anchor", `docker exec mesh-controller /mesh-controller ${command}`, timeoutMs); } /** The pinned reference this scenario's registry serves for a repository. */ @@ -194,9 +194,9 @@ function pinned(reference: string): string { return onTheMachine(reference, held); } -/** The substrate bundle: ours by the ID the machine holds, everything else upstream. */ +/** The foundation bundle: ours by the ID the machine holds, everything else upstream. */ function bundleFor(images: HeldImage[]): string { - return substrateBundle(bundle, images); + return foundationBundle(bundle, images); } /** @@ -236,7 +236,7 @@ interface NodeState { /** Ask the mesh, in its own terms, what a node has done with what it was sent. Never throws. */ async function nodeState(node: string): Promise { - const asked = await on("anchor", `docker exec mesh-control /mesh-control status --json`); + const asked = await on("anchor", `docker exec mesh-controller /mesh-controller status --json`); if (!asked.ok) return { reached: false, applied: false, current: false, waiting: false, raw: asked.out }; let state: { wrong: { node: string; outcome: string; refused?: string; failed?: { id: string; error: string }[] }[]; @@ -270,12 +270,12 @@ before(async () => { instanceId = raised.instanceId; held = raised.images; - // anchor raises the substrate from its bundle, digests rewritten to the scenario registry's. - await must("anchor", `cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); - await must("anchor", `${HOST_PATH} apply /tmp/substrate.lock`, 900_000); + // anchor raises the foundation from its bundle, digests rewritten to the scenario registry's. + await must("anchor", `cat > /tmp/foundation.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); + await must("anchor", `${HOST_PATH} apply /tmp/foundation.lock`, 900_000); const up = await must("anchor", `docker ps --format '{{.Names}}'`); - for (const c of ["mesh-store", "mesh-broker", "mesh-control"]) { - assert.match(up, new RegExp(c), `the substrate did not raise ${c}:\n${up}`); + for (const c of ["mesh-store", "mesh-broker", "mesh-controller"]) { + assert.match(up, new RegExp(c), `the foundation did not raise ${c}:\n${up}`); } // Both machines join the one mesh and run a host so they apply what they are pushed. @@ -316,7 +316,7 @@ test("the whole novox service set resolves, installs and converges on one node i for (const { name } of MODULES) { try { const { manifest, broker } = loadManifest(name); - await must("anchor", `printf %s ${quote(manifest)} > /tmp/${name}.json && docker cp /tmp/${name}.json mesh-control:/${name}.json`); + await must("anchor", `printf %s ${quote(manifest)} > /tmp/${name}.json && docker cp /tmp/${name}.json mesh-controller:/${name}.json`); await mesh(`module add /${name}.json`); if (broker) { await mesh(`module issue ${name} --node ${NODE}`); diff --git a/test/lastrun.test.ts b/test/lastrun.test.ts index 3e6d0b9..2b19ae4 100644 --- a/test/lastrun.test.ts +++ b/test/lastrun.test.ts @@ -23,13 +23,13 @@ test("a machine that has never run the suite is told so", () => { // The one that matters: it passed, and against code nobody runs any more. test("a run against code that has since changed is not current", () => { const said = judge( - passing("2026-08-31T11:00:00Z", { "mesh-lab": "aaa", "mesh-control": "bbb" }), + passing("2026-08-31T11:00:00Z", { "mesh-lab": "aaa", "mesh-controller": "bbb" }), now, - { "mesh-lab": "aaa", "mesh-control": "ccc" }, + { "mesh-lab": "aaa", "mesh-controller": "ccc" }, ); assert.equal(said.current, false, "a run against changed code was reported as current"); const text = said.lines.join("\n"); - assert.match(text, /mesh-control\s+at bbb, now at ccc/, text); + assert.match(text, /mesh-controller\s+at bbb, now at ccc/, text); assert.match(text, /code that has since changed/, text); }); diff --git a/test/pinning.test.ts b/test/pinning.test.ts index 65a943a..e03769a 100644 --- a/test/pinning.test.ts +++ b/test/pinning.test.ts @@ -16,8 +16,8 @@ import { */ const HELD: HeldImage[] = [ { - requested: "mesh-control:development", - repository: "mesh-control", + requested: "mesh-controller:development", + repository: "mesh-controller", reference: "sha256:" + "a".repeat(64), }, { @@ -51,7 +51,7 @@ test("the repository is the reference without its tag", () => { */ test("only what is built here and published nowhere counts as ours", () => { for (const ours of [ - "mesh-control:development", "mesh-runtime-plex:development", "mesh-route-proxy:development", + "mesh-controller:development", "mesh-runtime-plex:development", "mesh-route-proxy:development", "mesh-provision-postgres@sha256:" + "0".repeat(64), ]) { assert.ok(isMeshBuilt(ours), `${ours} is one of ours and was not recognised`); @@ -61,7 +61,7 @@ test("only what is built here and published nowhere counts as ours", () => { "registry.example:5000/novox/www:latest", // A registry host in front of one of our names does NOT make it ours: it says somebody // published it, so the machine can fetch it from there like anything else. - "registry.example:5000/mesh-control:development", + "registry.example:5000/mesh-controller:development", ]) { assert.ok(!isMeshBuilt(theirs), `${theirs} is not ours and was claimed`); } @@ -96,16 +96,16 @@ test("a third-party image is left exactly as the manifest wrote it", () => { }); test("a reference of ours that already carries a registry is still redirected", () => { - // What the committed substrate bundle looks like: written for a target that had a registry. - const before = `"image": "192.0.2.250:5000/mesh-control@sha256:${"e".repeat(64)}"`; + // What the committed foundation bundle looks like: written for a target that had a registry. + const before = `"image": "192.0.2.250:5000/mesh-controller@sha256:${"e".repeat(64)}"`; assert.equal(pinnedInto(before, HELD), `"image": "sha256:${"a".repeat(64)}"`); }); // A longer repository ending in a shorter one must not be half-replaced. test("a repository that ends in another one is not partly rewritten", () => { - const before = `"image": "our-mesh-control@sha256:${"7".repeat(64)}"`; + const before = `"image": "our-mesh-controller@sha256:${"7".repeat(64)}"`; assert.equal(pinnedInto(before, HELD), before, - "'our-mesh-control' was rewritten because it ends in 'mesh-control'"); + "'our-mesh-controller' was rewritten because it ends in 'mesh-controller'"); }); test("every image in a whole manifest is settled at once", () => { @@ -127,7 +127,7 @@ test("every image in a whole manifest is settled at once", () => { }); test("what a repository is held under can be asked for, and absence is not an empty string", () => { - assert.equal(referenceFor(HELD, "mesh-control"), `sha256:${"a".repeat(64)}`); + assert.equal(referenceFor(HELD, "mesh-controller"), `sha256:${"a".repeat(64)}`); assert.equal(referenceFor(HELD, "mesh-runtime-plex"), undefined); }); @@ -144,7 +144,7 @@ test("what is still a placeholder can be named", () => { */ test("an image the machine cannot fetch by itself is handed over", () => { for (const handed of [ - "mesh-control:development", + "mesh-controller:development", "mesh-runtime-plex:development", `registry.example/novox/www@sha256:${"a".repeat(64)}`, "registry.example:5000/novox/photos-server:latest", diff --git a/test/place.test.ts b/test/place.test.ts index ebb7da1..e8dddbb 100644 --- a/test/place.test.ts +++ b/test/place.test.ts @@ -38,10 +38,10 @@ 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 placements = planPlacements(scenario("place:\n all: [host]\n anchor: [foundation]")); 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("anchor"), ["foundation"], "anchor should have ONLY foundation"); assert.deepEqual(byMachine.get("peer"), ["host"]); }); @@ -74,11 +74,11 @@ scenario: s segments: { hosting: { kind: public, cidr: [192.0.2.0/24] } } machines: anchor: { at: { segment: hosting, address: [192.0.2.10] }, egress: true } -images: [mesh-control:development, mesh-runtime-redis:development] +images: [mesh-controller:development, mesh-runtime-redis:development] place: { all: [host, runtime] } `); assert.deepEqual(planHeldImages(s), [ - { machine: "anchor", images: ["mesh-control:development", "mesh-runtime-redis:development"] }, + { machine: "anchor", images: ["mesh-controller:development", "mesh-runtime-redis:development"] }, ]); }); @@ -90,16 +90,16 @@ machines: anchor: at: { segment: hosting, address: [192.0.2.10] } egress: true - images: [mesh-control:development] + images: [mesh-controller:development] laptop: at: { segment: hosting, address: [192.0.2.20] } egress: true images: [mesh-runtime-redis:development] -images: [mesh-control:development, mesh-runtime-redis:development] +images: [mesh-controller:development, mesh-runtime-redis:development] place: { all: [host, runtime] } `); assert.deepEqual(planHeldImages(s), [ - { machine: "anchor", images: ["mesh-control:development"] }, + { machine: "anchor", images: ["mesh-controller:development"] }, { machine: "laptop", images: ["mesh-runtime-redis:development"] }, ]); }); @@ -113,7 +113,7 @@ segments: { hosting: { kind: public, cidr: [192.0.2.0/24] } } machines: anchor: { at: { segment: hosting, address: [192.0.2.10] }, egress: true } bare: { at: { segment: hosting, address: [192.0.2.20] }, egress: true, images: [] } -images: [mesh-control:development] +images: [mesh-controller:development] place: { all: [host, runtime] } `); assert.deepEqual(planHeldImages(s).map((p) => p.machine), ["anchor"]); @@ -132,15 +132,15 @@ place: { 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:` + // foundation 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]")); + assertSupported(scenario("place:\n all: [host, foundation]\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, /foundation/, "the foundation 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"); } @@ -172,18 +172,18 @@ test("an image reference is placeable, and a bare 'image:' is not", () => { test("the placeables are host, runtime and an image", () => { assert.ok(isPlaceable("host")); assert.ok(isPlaceable("runtime")); - assert.ok(!isPlaceable("substrate"), "the tiers above tier 0 do not exist yet"); + assert.ok(!isPlaceable("foundation"), "the tiers above tier 0 do not exist yet"); assert.ok(!isPlaceable("control-plane")); }); test("an unplaceable artifact is named, not refused as a whole", () => { - // A scenario placing a host and a substrate is told which half the lab cannot do — refusing + // A scenario placing a host and a foundation is told which half the lab cannot do — refusing // wholesale would send somebody looking for the wrong problem. const scenario = { name: "s", segments: {}, machines: { a: {} as never }, - place: { a: ["host", "runtime", "image:alpine@sha256:x", "substrate"] }, + place: { a: ["host", "runtime", "image:alpine@sha256:x", "foundation"] }, } as unknown as Parameters[0]; assert.throws( @@ -192,7 +192,7 @@ test("an unplaceable artifact is named, not refused as a whole", () => { // Checked as `place: —`, which is how an artifact is REPORTED as // unplaceable. Searching for the bare word matched the message's own list of what CAN // be placed, which mentions runtime — so the test failed on a correct message. - assert.ok(err.message.includes("place: substrate —"), "the unplaceable one is named"); + assert.ok(err.message.includes("place: foundation —"), "the unplaceable one is named"); assert.ok(!err.message.includes("place: image:alpine"), "a placeable one is not"); assert.ok(!err.message.includes("place: runtime —"), "nor is runtime"); assert.ok(!err.message.includes("place: host —"), "nor is host"); diff --git a/test/rebuild.test.ts b/test/rebuild.test.ts index f452700..32af5f5 100644 --- a/test/rebuild.test.ts +++ b/test/rebuild.test.ts @@ -79,9 +79,9 @@ test("the whole-mesh bed hands its anchor no control-plane image", () => { ...Object.values(scenario.machines).flatMap((m) => m.images ?? []), ]; assert.deepEqual( - named.filter((i) => i.startsWith("mesh-control")), + named.filter((i) => i.startsWith("mesh-controller")), [], - "the anchor is handed mesh-control, so genesis would never find out whether the installer " + + "the anchor is handed mesh-controller, so genesis would never find out whether the installer " + "really carries it", ); }); diff --git a/test/supported.test.ts b/test/supported.test.ts index 6a57d20..5154ec3 100644 --- a/test/supported.test.ts +++ b/test/supported.test.ts @@ -60,20 +60,20 @@ place: { all: [host] }`); }); test("a tier above 0 is still refused, and named", () => { - // The refusal narrowed rather than disappearing. A scenario placing a host AND a substrate + // The refusal narrowed rather than disappearing. A scenario placing a host AND a foundation // 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, substrate] }`); +place: { all: [host, foundation] }`); try { assertSupported(scenario); assert.fail("should have refused"); } catch (err) { 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.equal(err.missing.length, 1, `expected only the foundation: ${err.missing.join(", ")}`); + assert.match(err.missing[0] ?? "", /foundation/); assert.match(err instanceof Error ? err.message : "", /silently lacks them/); } }); diff --git a/test/validate.test.ts b/test/validate.test.ts index 065b70e..41d2716 100644 --- a/test/validate.test.ts +++ b/test/validate.test.ts @@ -280,7 +280,7 @@ test("one of ours in images: is accepted", () => { assert.doesNotThrow(() => parseScenario(`scenario: x segments: { net: { kind: public, cidr: [192.0.2.0/24] } } machines: { a: { at: { segment: net, address: [192.0.2.1] }, egress: true } } -images: [mesh-control:development, mesh-route-proxy:development] +images: [mesh-controller:development, mesh-route-proxy:development] place: { all: [runtime] }`)); }); @@ -288,7 +288,7 @@ test("images: is named by tag — an image ID is not knowable until the image is refuses(`scenario: x segments: { net: { kind: public, cidr: [192.0.2.0/24] } } machines: { a: { at: { segment: net, address: [192.0.2.1] }, egress: true } } -images: ["mesh-control@sha256:${"0".repeat(64)}"] +images: ["mesh-controller@sha256:${"0".repeat(64)}"] place: { all: [runtime] }`, /is pinned by digest/); }); @@ -303,7 +303,7 @@ machines: at: { segment: net, address: [192.0.2.1] } egress: true images: [mesh-runtime-redis:development] -images: [mesh-control:development] +images: [mesh-controller:development] place: { all: [runtime] }`, /is not in this scenario's images/); }); diff --git a/test/warm.test.ts b/test/warm.test.ts index adf2207..3f9be20 100644 --- a/test/warm.test.ts +++ b/test/warm.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { judge, type Warm } from "../src/warm.ts"; const at = "2026-08-31T20:00:00Z"; -const built = { "mesh-lab": "aaa", "mesh-host": "bbb", "mesh-control": "ccc" }; +const built = { "mesh-lab": "aaa", "mesh-host": "bbb", "mesh-controller": "ccc" }; const warm = (over: Partial = {}): Warm => ({ scenario: "two-nodes", instanceId: "mlab-two-nodes-1", images: [], against: built, at, ...over }); From 70c154516185943b1d7c3e8076cdc6461956f438 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 16 Sep 2026 21:33:16 +0200 Subject: [PATCH 25/26] =?UTF-8?q?Phase=203.2:=20lavinmq=20declares=20no=20?= =?UTF-8?q?network=20=E2=80=94=20it=20adopts=20mesh-broker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The V3 networking check asserted a `lavinmq` docker network exists, from the two-server world. The module now adopts the foundation's broker rather than raising its own on a private network (issue 051, WBS 3.2), so only the consumer's own network remains. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- test/integration/one-node-mesh.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/integration/one-node-mesh.test.ts b/test/integration/one-node-mesh.test.ts index f381028..2e0b8a0 100644 --- a/test/integration/one-node-mesh.test.ts +++ b/test/integration/one-node-mesh.test.ts @@ -848,8 +848,11 @@ before(async () => { said.push(` names ${(hosts.match(/[a-z0-9-]+\.internal/g) ?? []).join(" ")}`); // The networks the declarations asked for, rather than whatever the runtime had lying around. + // `lavinmq` no longer declares one: it adopts the foundation's mesh-broker rather than raising a + // server of its own on a private network (novox/hq issue 051, WBS 3.2), so the only module + // network here is the consumer's own. const networks = (await on(CONTROL, `docker network ls --format '{{.Name}}'`)).out; - for (const wanted of ["lavinmq", "amqp-ping"]) { + for (const wanted of ["amqp-ping"]) { assert.match(networks, new RegExp(`^${wanted}$`, "m"), `the ${wanted} module declares a network and none exists:\n${networks}`); } From 440e2653b281a7c27f1087efa7d2430428f0df83 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 16 Sep 2026 21:51:20 +0200 Subject: [PATCH 26/26] Phase 3.3/3.4: prove the store and broker upgrade in place, through the window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S1 upgrades the store: a spec change recreates mesh-store (the server the control plane reads from), and asserts the data on the named volume survives and the pool reconnects — the stated window. It also asserts postgres/lavinmq are now source-tracked modules the mesh can report behind (3.4), the question that could not form before adoption. S2 does the same for the broker, the harder case: the push that upgrades it travels over it, so it proves the mesh reconnects to the bus it just replaced. Issue 051 (WBS 3.3, 3.4). Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- test/integration/one-node-mesh.test.ts | 145 ++++++++++++++++++++++++- 1 file changed, 143 insertions(+), 2 deletions(-) diff --git a/test/integration/one-node-mesh.test.ts b/test/integration/one-node-mesh.test.ts index 2e0b8a0..6fd0c81 100644 --- a/test/integration/one-node-mesh.test.ts +++ b/test/integration/one-node-mesh.test.ts @@ -38,7 +38,7 @@ */ import { test, before, after } from "node:test"; import assert from "node:assert/strict"; -import { existsSync, writeFileSync, appendFileSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync, appendFileSync } from "node:fs"; import { execFileSync } from "node:child_process"; import { resolve } from "node:path"; import { loadScenario } from "../../src/declaration/parse.ts"; @@ -134,6 +134,8 @@ const CATALOGUED = "the catalogue holds every module this mesh built"; const NETWORK = "the machine's networking is what the modules asked for"; const DECLARED = "every resource the mesh declared is true on the machine"; const FOLLOWS = "a change to a module's source reaches the machine on its own"; +const STORE_UPGRADES = "the store is upgraded in place, and the controller reads it through the window"; +const BROKER_UPGRADES = "the broker is upgraded in place, and the mesh talks over the window"; const SURVIVES = "the mesh comes back after the machine reboots"; const MODULE = { module: "amqp-ping", repo: "mesh-catalog", path: "modules/amqp-ping", container: "amqp-ping" }; @@ -413,6 +415,8 @@ const PLAN: { code: string; title: string }[] = [ { code: "V3", title: NETWORK }, { code: "V4", title: DECLARED }, { code: "E1", title: FOLLOWS }, + { code: "S1", title: STORE_UPGRADES }, + { code: "S2", title: BROKER_UPGRADES }, { code: "E2", title: SURVIVES }, ]; @@ -1012,13 +1016,150 @@ before(async () => { return `${behind}\n${rolled}\n${after}`; }); + // ---- S1. THE STORE IS UPGRADED IN PLACE, THROUGH A STATED WINDOW ----------------------------- + // + // The store the foundation raised is now the `postgres` module (novox/hq issue 051), so it can be + // upgraded like any other — and upgrading a SERVER, unlike its provisioner, recreates the + // container the control plane keeps its own records in. This is the window: the data survives on + // the named volume, and the control plane's connection pool reconnects to the server that comes + // back. A benign marker on the server's spec stands in for a version bump; the mechanism — the + // applier replacing the container while the volume persists — is the same one a real bump uses. + await step("S1", STORE_UPGRADES, FOLLOWS, async () => { + const said: string[] = []; + + // 3.4: the foundation is source-tracked now. Before it was adopted, the store was a container + // the installer raised with no source the mesh could hold; now it is a module `status` includes + // in what can be behind — the question that "could not form" before. + const list = await mesh("module list"); + assert.match(list, /postgres/, `the store is not a module the mesh lists:\n${list}`); + assert.match(list, /lavinmq/, `the broker is not a module the mesh lists:\n${list}`); + said.push(" source-tracked postgres and lavinmq are modules the mesh can report behind"); + + // What has to survive the window: every database in the store. Counted, not named, so this does + // not depend on which consumers happened to ask for one. + const count = async () => + (await on(CONTROL, + `docker exec mesh-store psql -U postgres -tAc "select count(*) from pg_database where datistemplate=false"`)) + .out.trim(); + const before = await count(); + + // The mesh's own upgrade path: move the source, build, roll out. Two files move — the + // provisioner, so the artifact changes and the build has something to publish (staleness + // compares artifacts, not commits, exactly as E1 records), and the server's spec, so the + // roll-out recreates mesh-store, which is the window. A benign marker stands in for a version + // bump; the applier replacing the container while the volume persists is the same either way. + const checkout = resolve(catalogDir, ".."); + const provisioner = resolve(catalogDir, "postgres", "provisioner", "index.ts"); + const path = resolve(catalogDir, "postgres", "module.json"); + appendFileSync(provisioner, `// store upgrade marker ${before}\n`); + const bumped = JSON.parse(readFileSync(path, "utf8")); + bumped.resources.find((r: { name?: string }) => r.name === "mesh-store").env.MESH_UPGRADE_MARKER = "s1"; + writeFileSync(path, JSON.stringify(bumped, null, 2)); + execFileSync("git", ["-C", checkout, "add", provisioner, path], { stdio: "pipe" }); + execFileSync("git", ["-C", checkout, "commit", "-q", "-m", + "Upgrade the store, so the mesh rebuilds and recreates it"], { stdio: "pipe" }); + execFileSync("git", ["-C", checkout, "push", "-q", "origin", refFor(STORE.repo)], { stdio: "pipe" }); + const head = execFileSync("git", ["-C", checkout, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + + await mesh(`module moved postgres ${head}`); + const behind = await mesh("status"); + assert.match(behind, /behind|build --behind/, + `the store's source moved and the mesh does not report it behind:\n${behind}`); + await mesh(`build --behind --wait 1200s`, 1_500_000); + await mesh(`upgrade postgres roll-out`, 900_000); + // roll-out rolls out the behind ARTIFACT (the provisioner runtime); the server's change is a + // manifest edit, not a new artifact, so a full push is what applies it — recreating mesh-store. + await mesh(`push ${CONTROL}`, 600_000); + await waitForContainer(CONTROL, "mesh-store"); + + // Poll, not a single read: the recreate can settle after waitForContainer sees a container up, + // so a single inspect races the replacement. The window is real; this waits for it to close. + const deadline = Date.now() + 120_000; + let env = ""; + while (Date.now() < deadline) { + env = (await on(CONTROL, `docker inspect mesh-store --format '{{.Config.Env}}'`)).out; + if (/MESH_UPGRADE_MARKER=s1/.test(env)) break; + await new Promise((r) => setTimeout(r, 5_000)); + } + assert.match(env, /MESH_UPGRADE_MARKER=s1/, + `mesh-store did not come back on the upgraded spec within the window:\n${env}`); + // The control plane read through the window: `status` opens all three of its stores, so a clean + // answer is proof the pool reconnected to the server that came back. + const status = await mesh("status"); + assert.doesNotMatch(status, /cannot|refused|could not/i, + `the control plane did not read through the store window:\n${status}`); + const after = await count(); + assert.equal(after, before, + `databases did not survive the store upgrade (before=${before} after=${after})`); + said.push(" window mesh-store recreated, every database kept, the pool reconnected"); + return said.join("\n"); + }); + + // ---- S2. THE BROKER IS UPGRADED IN PLACE, OVER THE BROKER ------------------------------------ + // + // The harder one: the broker is what the push that upgrades it travels over. Recreating + // mesh-broker drops the bus mid-apply, and the machine has to finish the replacement locally and + // the mesh reconnect to the broker that comes back. Same benign-marker stand-in for a version + // bump; what is under test is that the mesh survives replacing its own bus. + await step("S2", BROKER_UPGRADES, STORE_UPGRADES, async () => { + const said: string[] = []; + // Same upgrade path as S1, for the broker: move the provisioner and the server's spec, build, + // roll out. The roll-out recreates mesh-broker, and it is what the roll-out itself travels over, + // so this proves the mesh finishes replacing its own bus and reconnects to the one that returns. + const checkout = resolve(catalogDir, ".."); + const provisioner = resolve(catalogDir, "lavinmq", "provisioner", "index.ts"); + const path = resolve(catalogDir, "lavinmq", "module.json"); + appendFileSync(provisioner, `// broker upgrade marker\n`); + const bumped = JSON.parse(readFileSync(path, "utf8")); + bumped.resources.find((r: { name?: string }) => r.name === "mesh-broker").env = { + ...bumped.resources.find((r: { name?: string }) => r.name === "mesh-broker").env, + MESH_UPGRADE_MARKER: "s2", + }; + writeFileSync(path, JSON.stringify(bumped, null, 2)); + execFileSync("git", ["-C", checkout, "add", provisioner, path], { stdio: "pipe" }); + execFileSync("git", ["-C", checkout, "commit", "-q", "-m", + "Upgrade the broker, so the mesh rebuilds and recreates it"], { stdio: "pipe" }); + execFileSync("git", ["-C", checkout, "push", "-q", "origin", refFor(PROVIDER.repo)], { stdio: "pipe" }); + const head = execFileSync("git", ["-C", checkout, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + + await mesh(`module moved lavinmq ${head}`); + const behind = await mesh("status"); + assert.match(behind, /behind|build --behind/, + `the broker's source moved and the mesh does not report it behind:\n${behind}`); + await mesh(`build --behind --wait 1200s`, 1_500_000); + await mesh(`upgrade lavinmq roll-out`, 900_000); + // As in S1: the server's manifest change lands on a full push, not the artifact roll-out. + await mesh(`push ${CONTROL}`, 600_000); + await waitForContainer(CONTROL, "mesh-broker"); + + // Poll, not a single read: recreating the broker drops the bus the push travelled over, so the + // control plane reconnects and the recreate settles a cycle later than the store's did — the + // window here is a reconnection, and it is longer. + const deadline = Date.now() + 120_000; + let env = ""; + while (Date.now() < deadline) { + env = (await on(CONTROL, `docker inspect mesh-broker --format '{{.Config.Env}}'`)).out; + if (/MESH_UPGRADE_MARKER=s2/.test(env)) break; + await new Promise((r) => setTimeout(r, 5_000)); + } + assert.match(env, /MESH_UPGRADE_MARKER=s2/, + `mesh-broker did not come back on the upgraded spec within the window:\n${env}`); + // The mesh talks over the broker that came back: a fresh push composes and delivers, which needs + // the bus, so a clean one is proof the reconnection happened. + const again = await mesh(`push ${CONTROL}`, 600_000); + assert.doesNotMatch(again, /cannot|refused|could not/i, + `the mesh did not talk over the broker that came back:\n${again}`); + said.push(" window mesh-broker recreated, the mesh talks over the one that came back"); + return said.join("\n"); + }); + // ---- 12. AND IT SURVIVES THE MACHINE STOPPING ------------------------------------------------- // // **Never once tested.** A mesh that works until the machine reboots is a demonstration, not // something to move real services onto — and the installer is explicit that a host started the // way the lab starts it does not survive a reboot, which makes this the check that says whether // that matters. - await step("E2", SURVIVES, FOLLOWS, async () => { + await step("E2", SURVIVES, BROKER_UPGRADES, async () => { await restartMachine(CONTROL); const missing: string[] = []; for (const container of MUST_RUN) {