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" 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..5ba629d 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}`, @@ -163,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 875f521..65d9379 100644 --- a/test/integration/whole-mesh-full.test.ts +++ b/test/integration/whole-mesh-full.test.ts @@ -66,13 +66,18 @@ 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(); 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. */ @@ -350,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); } @@ -526,6 +553,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 +588,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}`, @@ -632,6 +662,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, 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(" ")); });