The five beds that read the catalogue read it through the harness

adopted-store-cross-node, two-node-db and the three whole-mesh beds each carried a
private loader; they drifted. The ace loader never resolved a runtime artifact, so a
module the mesh builds travelled unresolved; whole-mesh-full still asked for
'registry' and 'firewall', which the catalogue names distribution and nftables, and
swallowed the miss as NOT ASSIGNED. One loader now (novox/hq 04-ISSUES/073).
This commit is contained in:
2026-09-21 14:33:02 +02:00
parent 2456b2f533
commit e596758db9
6 changed files with 53 additions and 167 deletions
@@ -21,29 +21,25 @@
*/
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { existsSync } from "node:fs";
import { loadScenario } from "../../src/declaration/parse.ts";
import { raise } from "../../src/lifecycle/raise.ts";
import { destroy, exec } from "../../src/lifecycle/operate.ts";
import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts";
import { labIsUsable, destroyAll, foundationBundle, onTheMachine } from "./harness.ts";
import { labIsUsable, destroyAll, foundationBundle, catalogueIsPresent, catalogueModule, needsBrokerAccount } from "./harness.ts";
import type { HeldImage } from "../../src/pinning.ts";
const capability = await labIsUsable();
const binary = hostBinaryPath();
const bundle = process.env["MESH_LAB_BUNDLE"] ?? "";
const modulesEnv = process.env["MESH_LAB_MODULES"] ?? "";
const skip = !capability.usable ? `lab not usable: ${capability.why}`
: !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 foundation bundle"
: false;
: catalogueIsPresent();
const SCENARIO = "adopted-store-cross-node";
const NODE = "node2";
const catalogDir = process.env["MESH_LAB_CATALOG"]
?? (modulesEnv ? resolve(dirname(dirname(dirname(modulesEnv))), "mesh-catalog", "modules") : "")
?? resolve(process.cwd(), "..", "mesh-catalog", "modules");
let instanceId = "";
let held: HeldImage[] = [];
@@ -65,29 +61,12 @@ async function must(machine: string, command: string, timeoutMs?: number): Promi
async function mesh(command: string, timeoutMs?: number): Promise<string> {
return must("anchor", `docker exec mesh-controller /mesh-controller ${command}`, timeoutMs);
}
function pinned(reference: string): string { return onTheMachine(reference, held); }
function bundleFor(images: HeldImage[]): string { return foundationBundle(bundle, images); }
/** Load a committed module.json with its container images rewritten to the scenario's pinned digests. */
/** The catalogue's manifest as the lab runs it (harness), and whether it needs a broker account. */
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; artifact?: string }[];
};
for (const r of m.resources ?? []) {
if (r.type !== "container") continue;
if (typeof r.image === "string") {
// A placeholder image (mesh-runtime-<m>@0…0) resolves to the stocked digest, as redis does.
r.image = pinned(r.image);
} else if (typeof r.artifact === "string") {
// The bundle-model bed does not build, so resolve a module's runtime ARTIFACT to its stocked
// image directly — postgres/lavinmq name their provisioner by artifact, not a placeholder.
r.image = pinned(`mesh-runtime-${name}@sha256:${"0".repeat(64)}`);
delete r.artifact;
}
}
const manifest = JSON.stringify(m);
return { manifest, broker: manifest.includes("MESH_BROKER_FILE") };
const manifest = catalogueModule(name, held);
return { manifest, broker: needsBrokerAccount(manifest) };
}
function tokenFrom(said: string): string {
const found = said.split("\n").map((l) => l.trim()).find((l) => l.length > 100 && !l.includes(" "));
@@ -105,7 +84,6 @@ async function install(name: string, node: string): Promise<void> {
before(async () => {
if (skip) return;
assert.ok(existsSync(catalogDir), `mesh-catalog modules not found at ${catalogDir}`);
const raised = await raise(loadScenario(`scenarios/${SCENARIO}.yml`), { onProgress: (m) => console.log(`raise: ${m}`) });
instanceId = raised.instanceId;
held = raised.images;
+8 -23
View File
@@ -37,13 +37,12 @@
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { existsSync } from "node:fs";
import { loadScenario } from "../../src/declaration/parse.ts";
import { raise } from "../../src/lifecycle/raise.ts";
import { destroy, exec } from "../../src/lifecycle/operate.ts";
import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts";
import { labIsUsable, destroyAll, foundationBundle, onTheMachine } from "./harness.ts";
import { labIsUsable, destroyAll, foundationBundle, onTheMachine, catalogueIsPresent, catalogueModule, needsBrokerAccount } from "./harness.ts";
import type { HeldImage } from "../../src/pinning.ts";
const capability = await labIsUsable();
@@ -56,16 +55,13 @@ const skip = !capability.usable
? "MESH_LAB_HOST_BINARY is not set to a built mesh-host"
: !bundle || !existsSync(bundle)
? "MESH_LAB_BUNDLE is not set to a foundation bundle (mesh-host examples/)"
: false;
: catalogueIsPresent();
const SCENARIO = "two-node-db";
/** The node that carries the whole DB-consumer chain. anchor carries only the foundation. */
const NODE = "laptop";
const modulesEnv = process.env["MESH_LAB_MODULES"] ?? "";
const catalogDir = process.env["MESH_LAB_CATALOG"]
?? (modulesEnv ? resolve(dirname(dirname(dirname(modulesEnv))), "mesh-catalog", "modules") : "")
?? resolve(process.cwd(), "..", "mesh-catalog", "modules");
let instanceId = "";
/** The mesh's own images, as the machines hold them. */
@@ -359,22 +355,11 @@ test("consumers on a joined node get their databases from the one foundation sto
await mesh(`assign ${NODE} ${name}`);
}
// Load a committed catalog module.json with its container images rewritten to this scenario's
// pinned digests, so the foundation store can be ADOPTED in place as the one postgres.
// The catalogue's manifest as the lab runs it (harness), so the foundation store can be ADOPTED
// in place as the one postgres.
function loadManifest(name: string): { manifest: string; broker: boolean } {
const m = JSON.parse(readFileSync(resolve(catalogDir, name, "module.json"), "utf8")) as {
resources?: { type: string; image?: string; artifact?: string }[];
};
for (const r of m.resources ?? []) {
if (r.type !== "container") continue;
if (typeof r.image === "string") r.image = pinned(r.image);
else if (typeof r.artifact === "string") {
r.image = pinned(`mesh-runtime-${name}@sha256:${"0".repeat(64)}`);
delete r.artifact;
}
}
const manifest = JSON.stringify(m);
return { manifest, broker: manifest.includes("MESH_BROKER_FILE") };
const manifest = catalogueModule(name, held);
return { manifest, broker: needsBrokerAccount(manifest) };
}
async function installCatalog(name: string, node: string): Promise<void> {
const { manifest, broker } = loadManifest(name);
+5 -5
View File
@@ -283,10 +283,11 @@ export interface ForTheLab {
*/
artifacts?: Record<string, string>;
/**
* Host-port remaps by container id, where one machine carries modules whose published ports
* collide — `{ server: { "8080": "8090:8080" } }`. The container side never changes.
* Host-port remaps, where one machine carries modules whose published ports collide —
* `{ "8080": "8090:8080" }`, applied to every container of the module. The container side never
* changes.
*/
ports?: Record<string, Record<string, string>>;
ports?: Record<string, string> | undefined;
/**
* Environment a container gets in the lab that it does not get in the mesh — an address the bed
* stands up in place of a real upstream, and nothing else. Merged over the manifest's own.
@@ -323,8 +324,7 @@ export function catalogueModule(module: string, held: HeldImage[], lab: ForTheLa
} else if (typeof r.image === "string") {
r.image = onTheMachine(r.image, held);
}
const remap = lab.ports?.[r.id];
if (remap && Array.isArray(r.ports)) r.ports = r.ports.map((p) => remap[p] ?? p);
if (lab.ports && Array.isArray(r.ports)) r.ports = r.ports.map((p) => lab.ports![p] ?? p);
const env = lab.env?.[r.id];
if (env) r.env = { ...(r.env ?? {}), ...env };
}
+9 -24
View File
@@ -25,19 +25,17 @@
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { existsSync } from "node:fs";
import { loadScenario } from "../../src/declaration/parse.ts";
import { raise } from "../../src/lifecycle/raise.ts";
import { destroy, exec } from "../../src/lifecycle/operate.ts";
import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts";
import { labIsUsable, destroyAll, foundationBundle, onTheMachine, deriveTheFilterOn } from "./harness.ts";
import { labIsUsable, destroyAll, foundationBundle, deriveTheFilterOn, catalogueIsPresent, catalogueModule, needsBrokerAccount } from "./harness.ts";
import type { HeldImage } from "../../src/pinning.ts";
const capability = await labIsUsable();
const binary = hostBinaryPath();
const bundle = process.env["MESH_LAB_BUNDLE"] ?? "";
const modulesEnv = process.env["MESH_LAB_MODULES"] ?? "";
const skip = !capability.usable
? `lab not usable: ${capability.why}`
@@ -45,14 +43,12 @@ const skip = !capability.usable
? "MESH_LAB_HOST_BINARY is not set to a built mesh-host"
: !bundle || !existsSync(bundle)
? "MESH_LAB_BUNDLE is not set to a foundation bundle (mesh-host examples/)"
: false;
: catalogueIsPresent();
const SCENARIO = "whole-mesh-ace";
const NODE = "ace";
const catalogDir = process.env["MESH_LAB_CATALOG"]
?? (modulesEnv ? resolve(dirname(dirname(dirname(modulesEnv))), "mesh-catalog", "modules") : "")
?? resolve(process.cwd(), "..", "mesh-catalog", "modules");
/** The operator-owned media library the ADR-0051 `accesses` point at — pre-created before the push. */
const MEDIA_DIRS = [
@@ -175,27 +171,17 @@ async function mesh(command: string, timeoutMs?: number): Promise<string> {
}
/** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */
function pinned(reference: string): string {
return onTheMachine(reference, held);
}
function bundleFor(images: HeldImage[]): string {
return foundationBundle(bundle, images);
}
/** The catalogue's manifest as the lab runs it (harness). This bed's own loader never resolved a
* runtime ARTIFACT to a stocked image, so every module whose runtime the mesh builds travelled to
* the machine unresolved — the shared loader does (novox/hq 04-ISSUES/073). */
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[] }[];
};
const remap = REMAP[name] ?? {};
for (const r of m.resources ?? []) {
if (r.type !== "container") continue;
if (typeof r.image === "string") r.image = pinned(r.image);
if (Array.isArray(r.ports)) r.ports = r.ports.map((p) => remap[p] ?? p);
}
const manifest = JSON.stringify(m);
return { manifest, broker: manifest.includes("MESH_BROKER_FILE") };
const manifest = catalogueModule(name, held, { ports: REMAP[name] });
return { manifest, broker: needsBrokerAccount(manifest) };
}
function tokenFrom(said: string): string {
@@ -240,7 +226,6 @@ async function nodeState(node: string): Promise<NodeState> {
before(async () => {
if (skip) return;
assert.ok(existsSync(catalogDir), `mesh-catalog modules not found at ${catalogDir}`);
const raised = await raise(loadScenario(`scenarios/${SCENARIO}.yml`), {
onProgress: (m) => console.log(`raise: ${m}`),
+14 -46
View File
@@ -58,21 +58,20 @@ import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { join, resolve } from "node:path";
import { loadScenario } from "../../src/declaration/parse.ts";
import { raise } from "../../src/lifecycle/raise.ts";
import { destroy, exec, instanceNameOf, push } from "../../src/lifecycle/operate.ts";
import {
bootstrapBinaryPath, hostBinaryPath, placeBootstrap, BOOTSTRAP_PATH, HOST_PATH,
} from "../../src/lifecycle/place.ts";
import { labIsUsable, destroyAll, foundationBundle, onTheMachine } from "./harness.ts";
import { labIsUsable, destroyAll, foundationBundle, catalogueDir, catalogueIsPresent, catalogueModule, needsBrokerAccount } from "./harness.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.
@@ -93,7 +92,7 @@ const skip = !capability.usable
? "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;
: catalogueIsPresent();
const SCENARIO = "whole-mesh-full";
/** novox hosts the foundation and the control plane; it is where `mesh` commands run. */
@@ -118,7 +117,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-controller", "builder"];
const CATALOGUE_MODULES = ["distribution", "mesh-controller", "builder"];
/**
* Where this mesh keeps its own images, as the anchor reaches it.
@@ -152,9 +151,8 @@ const PUBLIC_DOMAIN: Record<string, string> = { novox: "novox.incus", ace: "zura
const KEEP = !!process.env["MESH_LAB_KEEP"];
const FIXED_ID = process.env["MESH_LAB_INSTANCE_ID"] ?? (KEEP ? "whole-mesh-full-live" : undefined);
const catalogDir = process.env["MESH_LAB_CATALOG"]
?? (modulesEnv ? resolve(dirname(dirname(dirname(modulesEnv))), "mesh-catalog", "modules") : "")
?? resolve(process.cwd(), "..", "mesh-catalog", "modules");
/** The catalogue's modules directory (harness). Absent, the bed skips — see `skip`. */
const catalogDir = catalogueIsPresent() ? "" : catalogueDir();
const MEDIA_DIRS = [
"/services/media/series", "/services/media/anime", "/services/media/movies",
@@ -201,7 +199,7 @@ const NOVOX: Mod[] = [
// what an operator's `module add` + `assign` would do on a mesh that already has it, and a
// module the installer put there had better survive being asked for a second time. It also keeps
// mesh-registry in the convergence report, where a reader expects to see it.
{ name: "registry", containers: ["mesh-registry"] },
{ name: "distribution", containers: ["mesh-registry"] },
{
name: "mailu",
containers: [
@@ -210,16 +208,16 @@ const NOVOX: Mod[] = [
"mailu-front", "mesh-mailu",
],
},
{ name: "firewall", containers: [], node: true },
{ name: "nftables", containers: [], node: true },
{ name: "fail2ban", containers: [], node: true },
];
const CORE_NOVOX = new Set([
"postgres", "redis", "minio", "mongodb", "mssql", "lavinmq",
"route-proxy", "keycloak", "gitea", "nextcloud", "invoicing", "photos", "novox.be",
"portainer", "verdaccio", "registry",
"portainer", "verdaccio", "distribution",
]);
const GAPS_NOVOX = new Set([
"umami", "mailu", "firewall", "fail2ban", "only-office", "de-spiegel", "amqp-email-forwarder",
"umami", "mailu", "nftables", "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-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.
@@ -348,45 +346,16 @@ async function mesh(command: string, timeoutMs?: number): Promise<string> {
}
/** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */
function pinned(reference: string): string {
return onTheMachine(reference, held);
}
function bundleFor(images: HeldImage[]): string {
return foundationBundle(bundle, images);
}
/** The catalogue's manifest as the lab runs it (harness): artifacts resolved to the images the
* scenario stocked — the lab standing in for the builder — images pinned, host ports remapped. */
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; 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-<module>`, which is not a guess: it is what this repository's
// own `scripts/build-module-runtime.sh <module>` 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);
}
const manifest = JSON.stringify(m);
return { manifest, broker: manifest.includes("MESH_BROKER_FILE") };
const manifest = catalogueModule(name, held, { ports: REMAP[name] });
return { manifest, broker: needsBrokerAccount(manifest) };
}
function tokenFrom(said: string): string {
@@ -745,7 +714,6 @@ async function joinTheMesh(): Promise<void> {
before(async () => {
if (skip) return;
assert.ok(existsSync(catalogDir), `mesh-catalog modules not found at ${catalogDir}`);
const raised = await raise(loadScenario(`scenarios/${SCENARIO}.yml`), {
onProgress: (m) => console.log(`raise: ${m}`),
+10 -40
View File
@@ -36,19 +36,17 @@
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { existsSync } from "node:fs";
import { loadScenario } from "../../src/declaration/parse.ts";
import { raise } from "../../src/lifecycle/raise.ts";
import { destroy, exec } from "../../src/lifecycle/operate.ts";
import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts";
import { labIsUsable, destroyAll, foundationBundle, onTheMachine } from "./harness.ts";
import { labIsUsable, destroyAll, foundationBundle, onTheMachine, catalogueIsPresent, catalogueModule, needsBrokerAccount } from "./harness.ts";
import type { HeldImage } from "../../src/pinning.ts";
const capability = await labIsUsable();
const binary = hostBinaryPath();
const bundle = process.env["MESH_LAB_BUNDLE"] ?? "";
const modulesEnv = process.env["MESH_LAB_MODULES"] ?? "";
const skip = !capability.usable
? `lab not usable: ${capability.why}`
@@ -56,15 +54,12 @@ const skip = !capability.usable
? "MESH_LAB_HOST_BINARY is not set to a built mesh-host"
: !bundle || !existsSync(bundle)
? "MESH_LAB_BUNDLE is not set to a foundation bundle (mesh-host examples/)"
: false;
: catalogueIsPresent();
const SCENARIO = "whole-mesh-novox";
const NODE = "novox";
/** 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");
/**
* The set, in dependency-reading order (the resolver accepts any order). Each row: the module, and
@@ -229,38 +224,14 @@ function bundleFor(images: HeldImage[]): string {
}
/**
* Load a committed module.json, rewrite every container image to the scenario's pinned digest, and
* apply the host-port remaps. Returns the manifest as a string and whether it needs a broker account
* (a runtime that reads MESH_BROKER_FILE — providers and tooled apps do; plain/node modules do not).
* The catalogue's manifest as the lab runs it (harness): images pinned, artifacts resolved to the
* stocked images, the host-port remaps applied. Returns the manifest and whether it needs a broker
* account (a runtime that reads MESH_BROKER_FILE — providers and tooled apps do; plain/node modules
* do not).
*/
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; artifact?: string; ports?: string[] }[];
build?: unknown;
};
const remap = REMAP[name] ?? {};
for (const r of m.resources ?? []) {
if (r.type !== "container") continue;
if (typeof r.image === "string") {
r.image = pinned(r.image);
} else if (typeof r.artifact === "string") {
// Since issue 060 a module's own runtime container names an artifact the mesh's builder
// would fill, not a placeholder image. This bed stocks the image instead of building, so
// map the artifact to the stocked `mesh-runtime-<module>` the machine holds — keyed on the
// MODULE name, not the container's (mailu's runtime container is `mesh-mailu`, its image is
// `mesh-runtime-mailu`). The placeholder digest is what `pinned` already resolves for a
// mesh-built repo, exactly as it did for the old `image` field.
r.image = pinned(`mesh-runtime-${name}@sha256:${"0".repeat(64)}`);
delete r.artifact;
}
if (Array.isArray(r.ports)) r.ports = r.ports.map((p) => remap[p] ?? p);
}
// The build section the mesh's builder would consume: dropped, because this bed stocks the image
// rather than building it. Harmless to leave (the push path never reads it), removed for clarity.
delete m.build;
const manifest = JSON.stringify(m);
return { manifest, broker: manifest.includes("MESH_BROKER_FILE") };
const manifest = catalogueModule(name, held, { ports: REMAP[name] });
return { manifest, broker: needsBrokerAccount(manifest) };
}
function tokenFrom(said: string): string {
@@ -306,7 +277,6 @@ async function nodeState(node: string): Promise<NodeState> {
before(async () => {
if (skip) return;
assert.ok(existsSync(catalogDir), `mesh-catalog modules not found at ${catalogDir}`);
const raised = await raise(loadScenario(`scenarios/${SCENARIO}.yml`), {
onProgress: (m) => console.log(`raise: ${m}`),