From 1ba237a63465051968e138dbf0f468541078fef3 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 13 Sep 2026 02:55:24 +0200 Subject: [PATCH 1/4] Stage the sdk from its own checkout, not from a symlink that may not be one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both image scripts copied the runtime's installed tree and relied on the sdk inside it being a link into the sibling repository. That is true only where somebody linked them by hand, and false as soon as the dependencies are installed the ordinary way — which fetches the sdk as sources with nothing compiled. The image still built, and every entry point in it pointed at nothing. --- scripts/build-module-runtime.sh | 10 ++++++++++ scripts/build-runtime-image.sh | 12 +++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/scripts/build-module-runtime.sh b/scripts/build-module-runtime.sh index baa92c3..cff3e8f 100755 --- a/scripts/build-module-runtime.sh +++ b/scripts/build-module-runtime.sh @@ -38,6 +38,16 @@ TSC="$MESH_SDK/node_modules/.bin/tsc"; ( cd "$MOD" && "$TSC" "${SRCS[@]}" --modu STAGE="$(mktemp -d)"; trap 'rm -rf "$STAGE"' EXIT cp -r "$MESH_TOOLS/dist" "$STAGE/dist" cp -rL "$MESH_TOOLS/node_modules" "$STAGE/node_modules" +# And the sdk, from the sibling this script just built, whatever form the installed tree holds it +# in. It used to be relied on being a symlink into that sibling, which `-L` above materialised — +# true only on a workstation where somebody had linked them, and false the moment the runtime's +# dependencies are installed the ordinary way, which now fetches the sdk as sources with nothing +# compiled in it. The image built then looked fine and every entry point inside it pointed at +# nothing. +rm -rf "$STAGE/node_modules/@novox/mesh-sdk" +mkdir -p "$STAGE/node_modules/@novox" +cp -rL "$MESH_SDK" "$STAGE/node_modules/@novox/mesh-sdk" +rm -rf "$STAGE/node_modules/@novox/mesh-sdk/node_modules" mkdir -p "$STAGE/modules/$MODULE"; cp -r "$MOD/dist" "$STAGE/modules/$MODULE/dist" cp "$MESH_TOOLS/package.json" "$STAGE/package.json" diff --git a/scripts/build-runtime-image.sh b/scripts/build-runtime-image.sh index 41cabec..e6ca133 100755 --- a/scripts/build-runtime-image.sh +++ b/scripts/build-runtime-image.sh @@ -36,7 +36,17 @@ echo " module $AUDIT" STAGE="$(mktemp -d)" trap 'rm -rf "$STAGE"' EXIT cp -r "$MESH_TOOLS/dist" "$STAGE/dist" -cp -rL "$MESH_TOOLS/node_modules" "$STAGE/node_modules" # -L materialises the @novox/mesh-sdk symlink +cp -rL "$MESH_TOOLS/node_modules" "$STAGE/node_modules" +# And the sdk, from the sibling this script just built, whatever form the installed tree holds it +# in. It used to be relied on being a symlink into that sibling, which `-L` above materialised — +# true only on a workstation where somebody had linked them, and false the moment the runtime's +# dependencies are installed the ordinary way, which now fetches the sdk as sources with nothing +# compiled in it. The image built then looked fine and every entry point inside it pointed at +# nothing. +rm -rf "$STAGE/node_modules/@novox/mesh-sdk" +mkdir -p "$STAGE/node_modules/@novox" +cp -rL "$MESH_SDK" "$STAGE/node_modules/@novox/mesh-sdk" +rm -rf "$STAGE/node_modules/@novox/mesh-sdk/node_modules" # -L materialises the @novox/mesh-sdk symlink mkdir -p "$STAGE/modules/audit-logger" cp -r "$AUDIT/dist" "$STAGE/modules/audit-logger/dist" cp "$MESH_TOOLS/package.json" "$STAGE/package.json" -- 2.54.0 From 607ea241c7c44d630b39d943aa7909ad6199f725 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 13 Sep 2026 04:24:18 +0200 Subject: [PATCH 2/4] The lab's installer carries a builder, and genesis is told what to build Both beds now pass a repository and a commit, and refuse to run without them rather than raising a machine the installer cannot finish. --- src/rebuild.ts | 18 +++++++++++------- test/integration/genesis-single.test.ts | 11 ++++++++++- test/integration/genesis.ts | 12 ++++++++++++ test/integration/whole-mesh-full.test.ts | 16 ++++++++++++++-- test/rebuild.test.ts | 4 ++-- 5 files changed, 49 insertions(+), 12 deletions(-) diff --git a/src/rebuild.ts b/src/rebuild.ts index d12336c..13e5baf 100644 --- a/src/rebuild.ts +++ b/src/rebuild.ts @@ -91,21 +91,25 @@ export function planned(env: NodeJS.ProcessEnv = process.env): Build[] { builds.push({ what: "installer", in: where["mesh-host"], - argv: ["make", "bootstrap", `IMAGE=${controlPlaneImage(env)}`, `BOOTSTRAP_OUT=${installer}`], + argv: ["make", "bootstrap", `IMAGE=${carriedImage(env)}`, `BOOTSTRAP_OUT=${installer}`], }); } return builds; } /** - * The control-plane image the installer carries. + * The image the installer carries. * - * `mesh-control:development` is what mesh-control's `make image` tags, and what the scenarios name - * — one tag, said in one place. It is overridable because a release installer carries a release - * image, and nothing about that is the lab's business. + * **It is the builder, not the control plane** (novox/hq ADR 0073). The installer used to carry the + * 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 + * one place. Overridable because a release installer carries a release image, and nothing about + * that is the lab's business. */ -export function controlPlaneImage(env: NodeJS.ProcessEnv = process.env): string { - return env["MESH_LAB_CONTROL_IMAGE"] ?? "mesh-control:development"; +export function carriedImage(env: NodeJS.ProcessEnv = process.env): string { + return env["MESH_LAB_CARRIED_IMAGE"] ?? "mesh-builder:development"; } /** rebuild runs the plan, and throws on the first failure rather than testing a stale artifact. */ diff --git a/test/integration/genesis-single.test.ts b/test/integration/genesis-single.test.ts index 1221bb1..7d02ff1 100644 --- a/test/integration/genesis-single.test.ts +++ b/test/integration/genesis-single.test.ts @@ -34,6 +34,11 @@ const binary = hostBinaryPath(); const installer = bootstrapBinaryPath(); const bundle = process.env["MESH_LAB_BUNDLE"] ?? ""; const catalogDir = process.env["MESH_LAB_CATALOG"] ?? ""; +// What the installer is told to build. It carries a builder rather than a finished control plane +// (novox/hq ADR 0073), so genesis needs a repository and a commit — and a commit rather than a +// branch, because what is cloned is the trust anchor for everything this mesh will ever run. +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 ? "genesis-single-live" : undefined); @@ -41,7 +46,9 @@ 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 (mesh-host `make " + - "bootstrap IMAGE=mesh-control:development`)" : + "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" : !catalogDir || !existsSync(catalogDir) ? "MESH_LAB_CATALOG is not set to mesh-catalog/modules" : false; @@ -72,6 +79,8 @@ before(async () => { // naming a registry that does not exist — the installer overwrites it, and leaving it proves // that it does. bundleTemplate: substrateBundle(bundle, []), + source, + sourceRef, log: (m) => console.log(m), }); } catch (err) { diff --git a/test/integration/genesis.ts b/test/integration/genesis.ts index 3cafb53..83b4a98 100644 --- a/test/integration/genesis.ts +++ b/test/integration/genesis.ts @@ -44,6 +44,15 @@ export interface GenesisOptions { catalogueOnMachine?: string; /** Where this mesh's own registry will answer. */ registry?: string; + /** + * Where the control plane is built from, and the commit. + * + * The installer carries a builder rather than a finished control plane (novox/hq ADR 0073), so + * it has to be told what to make. A commit rather than a branch, because what genesis clones is + * the trust anchor for everything the mesh will ever run (ADR 0071). + */ + source: string; + sourceRef: string; log?: (m: string) => void; } @@ -84,6 +93,7 @@ export async function genesis(o: GenesisOptions): Promise { const name = await instanceNameOf(o.instanceId, node); const version = await placeBootstrap(name, node, o.installer, (m) => log(`genesis:${m}`)); report.push(` installer ${version} at ${BOOTSTRAP_PATH}`); + report.push(` builds from ${o.source} at ${o.sourceRef.slice(0, 8)}`); // 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 @@ -102,6 +112,8 @@ export async function genesis(o: GenesisOptions): Promise { const command = [ BOOTSTRAP_PATH, + `--source ${o.source}`, + `--source-ref ${o.sourceRef}`, `--bundle /tmp/substrate-template.lock`, `--catalog ${catalogueOnMachine}`, `--node ${node}`, diff --git a/test/integration/whole-mesh-full.test.ts b/test/integration/whole-mesh-full.test.ts index 875f521..c98e48c 100644 --- a/test/integration/whole-mesh-full.test.ts +++ b/test/integration/whole-mesh-full.test.ts @@ -73,6 +73,11 @@ const binary = hostBinaryPath(); const installer = bootstrapBinaryPath(); const bundle = process.env["MESH_LAB_BUNDLE"] ?? ""; const modulesEnv = process.env["MESH_LAB_MODULES"] ?? ""; +// What the installer is told to build. It carries a builder rather than a finished control plane +// (novox/hq ADR 0073), so genesis is given a repository and a commit — and a commit rather than a +// branch, because what is cloned is the trust anchor for everything this mesh will ever run. +const source = process.env["MESH_LAB_SOURCE"] ?? ""; +const sourceRef = process.env["MESH_LAB_SOURCE_REF"] ?? ""; const skip = !capability.usable ? `lab not usable: ${capability.why}` @@ -82,9 +87,13 @@ const skip = !capability.usable ? "MESH_LAB_BUNDLE is not set to a substrate 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-control:development`). The anchor is raised BY the installer now, " + + "bootstrap IMAGE=mesh-builder:development`). The anchor is raised BY the installer now, " + "so a run without one would be testing the procedure this bed exists to stop testing" - : false; + : !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" + : false; const SCENARIO = "whole-mesh-full"; /** novox hosts the substrate and the control plane; it is where `mesh` commands run. */ @@ -526,6 +535,7 @@ async function genesis(images: HeldImage[]): Promise { const version = await placeBootstrap(name, CONTROL, installer as string, (m) => console.log(`genesis:${m}`)); report.push(` installer ${version} at ${BOOTSTRAP_PATH}`); + report.push(` builds from ${source} at ${sourceRef.slice(0, 8)}`); // 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 step 7 finishes — @@ -560,6 +570,8 @@ async function genesis(images: HeldImage[]): Promise { const command = [ BOOTSTRAP_PATH, + `--source ${source}`, + `--source-ref ${sourceRef}`, `--bundle /tmp/substrate-template.lock`, `--catalog ${CATALOGUE_ON_MACHINE}`, `--node ${CONTROL}`, diff --git a/test/rebuild.test.ts b/test/rebuild.test.ts index d8b04e3..f452700 100644 --- a/test/rebuild.test.ts +++ b/test/rebuild.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { planned, controlPlaneImage } from "../src/rebuild.ts"; +import { planned, carriedImage } from "../src/rebuild.ts"; import { repositories } from "../src/repos.ts"; import { loadScenario } from "../src/declaration/parse.ts"; @@ -61,7 +61,7 @@ test("the installer is built, carrying the image built in the same run", () => { const installer = builds.find((b) => b.what === "installer")!; assert.equal(installer.in, "/repo/host"); - assert.ok(installer.argv.includes(`IMAGE=${controlPlaneImage({})}`), installer.argv.join(" ")); + assert.ok(installer.argv.includes(`IMAGE=${carriedImage({})}`), installer.argv.join(" ")); assert.ok(installer.argv.includes("BOOTSTRAP_OUT=/repo/host/mesh-bootstrap"), installer.argv.join(" ")); }); -- 2.54.0 From fb188070006ef4092b7d62219a714bfa93faf38e Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 13 Sep 2026 04:28:54 +0200 Subject: [PATCH 3/4] The bed checks the control plane was built, not carried MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pivot checks proved the running control plane is pinned to a digest this mesh's registry serves, which a carried image satisfies just as well. What the installer now exists to make true is that a build happened, from the commit the bed asked for — and that was printed and not checked. --- test/integration/genesis.ts | 19 +++++++++++++++++++ test/integration/whole-mesh-full.test.ts | 14 ++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/test/integration/genesis.ts b/test/integration/genesis.ts index 83b4a98..5ba629d 100644 --- a/test/integration/genesis.ts +++ b/test/integration/genesis.ts @@ -175,6 +175,25 @@ export async function genesis(o: GenesisOptions): Promise { `digest assigned by ${registry}.`); } + // 3a. THE CONTROL PLANE WAS BUILT, not carried. + // + // **The distinction the installer now exists to make** (novox/hq ADR 0073). A mesh running an + // image it was handed cannot rebuild the thing that runs it, and looks identical from the + // 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. + // + // Checked against the commit this bed asked for, not merely that some build occurred: an + // 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)) { + return stop("after the last step", + `the installer never said it built mesh-control 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`); + // 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`); report.push(` registry holds ${tags.out.trim() || "nothing"}`); diff --git a/test/integration/whole-mesh-full.test.ts b/test/integration/whole-mesh-full.test.ts index c98e48c..b2d666c 100644 --- a/test/integration/whole-mesh-full.test.ts +++ b/test/integration/whole-mesh-full.test.ts @@ -644,6 +644,20 @@ async function genesis(images: HeldImage[]): Promise { `digest assigned by ${MESH_REGISTRY}.`); } + // 3a. THE CONTROL PLANE WAS BUILT, not carried. + // + // **The distinction the installer now exists to make** (novox/hq ADR 0073). A mesh running an + // image it was handed cannot rebuild the thing that runs it, and looks identical from the + // 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)) { + return stop("after the last step", + `the installer never said it built mesh-control 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`); + // 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, -- 2.54.0 From d27f24cf3e15e9fc14eed473b581d43f4f39a877 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 13 Sep 2026 04:56:00 +0200 Subject: [PATCH 4/4] The bed resolves an artifact the way the builder would MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It pre-builds these images and stocks them, which is the lab standing in for the builder — so it must do what the builder does and replace the artifact with the reference the machine holds. Without it the unresolved field travels to the machine and the whole declaration is refused. --- test/integration/whole-mesh-full.test.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/test/integration/whole-mesh-full.test.ts b/test/integration/whole-mesh-full.test.ts index b2d666c..65d9379 100644 --- a/test/integration/whole-mesh-full.test.ts +++ b/test/integration/whole-mesh-full.test.ts @@ -66,7 +66,7 @@ import { bootstrapBinaryPath, hostBinaryPath, placeBootstrap, BOOTSTRAP_PATH, HOST_PATH, } from "../../src/lifecycle/place.ts"; import { labIsUsable, destroyAll, substrateBundle, onTheMachine } from "./harness.ts"; -import type { HeldImage } from "../../src/pinning.ts"; +import { referenceFor, type HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); const binary = hostBinaryPath(); @@ -359,11 +359,29 @@ function bundleFor(images: HeldImage[]): string { function loadManifest(name: string): { manifest: string; broker: boolean } { const path = resolve(catalogDir, name, "module.json"); const m = JSON.parse(readFileSync(path, "utf8")) as { - resources?: { type: string; image?: string; ports?: string[] }[]; + resources?: { type: string; image?: string; artifact?: string; ports?: string[] }[]; }; const remap = REMAP[name] ?? {}; for (const r of m.resources ?? []) { if (r.type !== "container") continue; + // **A container naming an artifact is a module the mesh builds, and this bed does not build.** + // It pre-builds the same images on the workstation and stocks them, which is the lab standing + // in for the builder — so it does here what the builder does: replace the artifact with the + // reference the machine actually holds. Without this the unresolved field travels to the + // machine, whose declaration language has no such field, and the whole declaration is refused. + // + // The repository is `mesh-runtime-`, which is not a guess: it is what this repository's + // own `scripts/build-module-runtime.sh ` produces and what the scenarios stock by name. + if (typeof r.artifact === "string" && typeof r.image !== "string") { + const reference = referenceFor(held, `mesh-runtime-${name}`); + assert.ok(reference, + `${name} declares the "${r.artifact}" artifact and this scenario stocked no ` + + `mesh-runtime-${name}. The mesh would have to build it, and this bed does not build — ` + + `add it to the machine's images: in the scenario, or build it with ` + + `scripts/build-module-runtime.sh ${name}`); + r.image = reference; + delete r.artifact; + } if (typeof r.image === "string") r.image = pinned(r.image); if (Array.isArray(r.ports)) r.ports = r.ports.map((p) => remap[p] ?? p); } -- 2.54.0