Beds read the catalogue: a shared loader, eight beds converted, the rest declared

catalogueModule() in the harness reads a module's manifest from the catalogue and
rewrites only what the lab must: the build section goes, each artifact becomes the
image the machine holds, images are pinned, and a bed may declare a host-port remap
or a lab-local address. confluence, gitlab, openai-consumer, audit-logger, ollama,
local-model-consumer, model-usage, mosquitto, anthropic-manager and
anthropic-consumer now install the catalogue's manifest. A unit test refuses any
inline copy naming a catalogue module unless the bed is declared with its reason;
the declared list is the debt (novox/hq 04-ISSUES/073).
This commit is contained in:
2026-09-21 14:27:49 +02:00
parent f2d29491b2
commit 2456b2f533
10 changed files with 259 additions and 361 deletions
+97 -7
View File
@@ -237,14 +237,104 @@ export async function assertUniversalInvariants(
/** The packet filter genesis installs on the control-node, by the name the catalogue gives it. */
export const FILTER_MODULE = "nftables";
/** The catalogue's manifest for a module, under either spelling of MESH_LAB_CATALOG: the modules
* directory, or the checkout that holds it. */
export function catalogueManifest(module: string): string {
const dir = process.env["MESH_LAB_CATALOG"] ?? "";
for (const candidate of [resolve(dir, module, "module.json"), resolve(dir, "modules", module, "module.json")]) {
if (existsSync(candidate)) return candidate;
// --- the catalogue: a bed installs a module by reading its manifest, never by carrying a copy ----
/**
* The catalogue's `modules/` directory: MESH_LAB_CATALOG under either spelling (the checkout, or
* its modules directory), else the checkout beside this one, the way the main layout has it.
*
* Beds used to build the manifests they install inline, as literals copied from the catalogue when
* each bed was written. The copies did not move when the catalogue did, so a catalogue change was
* proven nowhere — and a bed that installs a copy proves the copy (novox/hq 04-ISSUES/073). A bed
* reads the catalogue, or it does not install a catalogue module; `beds-read-the-catalogue.test.ts`
* refuses an inline copy that names one.
*/
export function catalogueDir(): string {
const named = process.env["MESH_LAB_CATALOG"];
const candidates = named
? [resolve(named, "modules"), resolve(named)]
: [resolve(process.cwd(), "..", "mesh-catalog", "modules")];
for (const dir of candidates) {
if (existsSync(resolve(dir, "mesh-controller", "module.json"))) return dir;
}
throw new Error(`no manifest for ${module} under MESH_LAB_CATALOG=${dir || "(unset)"}`);
throw new Error(
`no catalogue: MESH_LAB_CATALOG=${named ?? "(unset)"} and nothing at ${candidates.join(", ")}`);
}
/** Whether a catalogue is where a bed will look — for a skip guard, which says so instead of failing. */
export function catalogueIsPresent(): string | false {
try { catalogueDir(); return false; } catch (err) { return (err as Error).message; }
}
/** The catalogue's manifest for a module, as a path. */
export function catalogueManifest(module: string): string {
const path = resolve(catalogueDir(), module, "module.json");
if (!existsSync(path)) throw new Error(`no manifest for ${module} at ${path}`);
return path;
}
/** What the lab may rewrite in a catalogue manifest, and nothing else. */
export interface ForTheLab {
/**
* The image repository each build artifact was built as on this workstation, by artifact name.
* A module's own runtime is `mesh-runtime-<module>` by default — what `scripts/build-module-runtime.sh`
* tags and what the scenarios stock; a bed names it only where the scenario stocks another name.
* An artifact this does not name is refused: the bed must say what stands in for the builder.
*/
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.
*/
ports?: Record<string, Record<string, string>>;
/**
* 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.
*/
env?: Record<string, Record<string, string>>;
}
/**
* A catalogue manifest as a machine in the lab can run it: the mesh's build section gone (the lab
* stocks images rather than building), each artifact replaced by the image the machine holds for it,
* every image pinned to what the machine holds or the upstream digest the catalogue pins, and the
* declared lab rewrites applied. Everything else is the catalogue's, verbatim — which is the point.
*/
export function catalogueModule(module: string, held: HeldImage[], lab: ForTheLab = {}): string {
const m = JSON.parse(readFileSync(catalogueManifest(module), "utf8")) as {
resources?: { id: string; type: string; image?: string; artifact?: string; ports?: string[]; env?: Record<string, string> }[];
build?: unknown;
};
const artifacts: Record<string, string> = { runtime: `mesh-runtime-${module}`, ...(lab.artifacts ?? {}) };
for (const r of m.resources ?? []) {
if (r.type !== "container") continue;
if (typeof r.artifact === "string") {
const repository = artifacts[r.artifact];
assert.ok(repository,
`${module}'s container '${r.id}' names the "${r.artifact}" artifact, which the mesh would ` +
`build. The lab does not build: the bed must say which stocked image stands in for it ` +
`(artifacts: { ${r.artifact}: "<repository>" }).`);
const reference = referenceFor(held, repository);
assert.ok(reference,
`${module}'s "${r.artifact}" artifact is ${repository} and this scenario stocked no such ` +
`image. Add it to the scenario's images: and build it (scripts/build-module-runtime.sh ${module}).`);
r.image = reference;
delete r.artifact;
} 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);
const env = lab.env?.[r.id];
if (env) r.env = { ...(r.env ?? {}), ...env };
}
delete m.build;
return JSON.stringify(m);
}
/** Whether a manifest's runtime dials the broker — the module then needs a scoped broker account. */
export function needsBrokerAccount(manifest: string): boolean {
return manifest.includes("MESH_BROKER_FILE");
}
function shellQuote(s: string): string {