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,