diff --git a/test/beds-read-the-catalogue.test.ts b/test/beds-read-the-catalogue.test.ts index 091a2ee..fc1fb70 100644 --- a/test/beds-read-the-catalogue.test.ts +++ b/test/beds-read-the-catalogue.test.ts @@ -16,8 +16,9 @@ import { catalogueIsPresent, catalogueDir } from "./integration/harness.ts"; * So: a manifest literal in a bed that names a catalogue module is refused, unless the bed is * listed below with the reason it still carries one. The list is the debt, and it only shrinks. * - * What this reads: `module: ""` and `"module": ""` with a `version` close behind, in - * test/integration/*.test.ts, against the catalogue's directory names. A bed that hid the name + * What this reads: `module: ""` and `"module": ""` with a `version` close by, either + * order, in test/integration/*.test.ts, against the catalogue's directory names. Skipped aloud + * where MESH_LAB_CATALOG is unset — a skip is reported, never silent. A bed that hid the name * behind a computed string would pass — this is a fence, not a proof, and the reviewer of a bed * that builds a manifest inline is the proof. */ @@ -88,6 +89,10 @@ test("a bed that installs a catalogue module reads the catalogue", (t) => { for (const m of text.matchAll(/(?:^|[\s{,])(?:"module"|module)\s*:\s*"([a-z0-9.-]+)"[^}]{0,160}?(?:"version"|version)\s*:/g)) { if (names.has(m[1]!)) found.add(m[1]!); } + // And the other order — a literal that names its version first. + for (const m of text.matchAll(/(?:^|[\s{,])(?:"version"|version)\s*:\s*"[^"]*"[^}]{0,160}?(?:"module"|module)\s*:\s*"([a-z0-9.-]+)"/g)) { + if (names.has(m[1]!)) found.add(m[1]!); + } const declared = STILL_CARRIED[file]; for (const name of [...found].sort()) { if (declared?.modules.includes(name)) continue; diff --git a/test/catalogue-module.test.ts b/test/catalogue-module.test.ts new file mode 100644 index 0000000..45195e3 --- /dev/null +++ b/test/catalogue-module.test.ts @@ -0,0 +1,87 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { catalogueModule } from "./integration/harness.ts"; +import type { HeldImage } from "../src/pinning.ts"; + +/** + * The shared loader thirteen beds install through (novox/hq 04-ISSUES/073, ADR 0089): it reads + * the catalogue's manifest and rewrites only what the lab must. Checked here against a catalogue + * written by the test, so the rules hold without a lab run. + */ + +const digest = (c: string) => "sha256:" + c.repeat(64); +const held: HeldImage[] = [ + { requested: "mesh-runtime-thing:development", repository: "mesh-runtime-thing", reference: digest("a") }, + { requested: "mesh-helper:development", repository: "mesh-helper", reference: digest("b") }, +]; + +function aCatalogueWith(manifest: object): () => void { + const root = mkdtempSync(join(tmpdir(), "mesh-lab-catalogue-")); + mkdirSync(join(root, "modules", "distribution"), { recursive: true }); + writeFileSync(join(root, "modules", "distribution", "module.json"), "{}"); + mkdirSync(join(root, "modules", "thing")); + writeFileSync(join(root, "modules", "thing", "module.json"), JSON.stringify(manifest)); + const before = process.env["MESH_LAB_CATALOG"]; + process.env["MESH_LAB_CATALOG"] = root; + return () => { + if (before === undefined) delete process.env["MESH_LAB_CATALOG"]; else process.env["MESH_LAB_CATALOG"] = before; + rmSync(root, { recursive: true, force: true }); + }; +} + +const thing = { + module: "thing", version: "1", + resources: [ + { id: "server", type: "container", name: "thing", image: "postgres@" + digest("c"), ports: ["8080", "9090:9090"], env: { A: "1" } }, + { id: "runtime", type: "container", name: "mesh-thing", artifact: "runtime" }, + ], + build: { artifacts: [{ name: "runtime", kind: "image", from: "Dockerfile" }] }, +}; + +test("the runtime artifact becomes the image the machine holds, and the build section goes", () => { + const restore = aCatalogueWith(thing); + try { + const m = JSON.parse(catalogueModule("thing", held)) as { build?: unknown; resources: Record[] }; + assert.equal(m.build, undefined); + const runtime = m.resources.find((r) => r["id"] === "runtime")!; + assert.equal(runtime["image"], digest("a")); + assert.equal(runtime["artifact"], undefined); + // An image already pinned to a digest passes through as written. + assert.equal(m.resources.find((r) => r["id"] === "server")!["image"], "postgres@" + digest("c")); + } finally { restore(); } +}); + +test("an artifact the bed did not name is refused, and a named one resolves to the stocked image", () => { + const helper = { ...thing, resources: [{ id: "helper", type: "container", name: "h", artifact: "helper" }] }; + const restore = aCatalogueWith(helper); + try { + assert.throws(() => catalogueModule("thing", held), /names the "helper" artifact/); + const m = JSON.parse(catalogueModule("thing", held, { artifacts: { helper: "mesh-helper" } })) as { resources: Record[] }; + assert.equal(m.resources[0]!["image"], digest("b")); + assert.throws(() => catalogueModule("thing", held, { artifacts: { helper: "mesh-nothing" } }), /stocked no such image/); + } finally { restore(); } +}); + +test("a host-port remap and a lab address are the only other things that change", () => { + const restore = aCatalogueWith(thing); + try { + const m = JSON.parse(catalogueModule("thing", held, { + ports: { "8080": "8090:8080" }, + env: { server: { B: "2" } }, + })) as { resources: Record[] }; + const server = m.resources.find((r) => r["id"] === "server")!; + assert.deepEqual(server["ports"], ["8090:8080", "9090:9090"]); + assert.deepEqual(server["env"], { A: "1", B: "2" }); + } finally { restore(); } +}); + +test("a manifest the catalogue does not have is refused by name", () => { + const restore = aCatalogueWith(thing); + try { + assert.throws(() => catalogueModule("nothing", held), /no manifest for nothing/); + } finally { restore(); } +}); diff --git a/test/integration/harness.ts b/test/integration/harness.ts index 360cd50..a85422c 100644 --- a/test/integration/harness.ts +++ b/test/integration/harness.ts @@ -241,7 +241,9 @@ export const FILTER_MODULE = "nftables"; /** * 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. + * its modules directory). Named, or absent — never guessed from a sibling path: the receipt claims + * the catalogue the run was pointed at (src/repos.ts), and a catalogue read from somewhere the + * receipt does not name is the drift this exists to close. * * 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 @@ -251,16 +253,14 @@ export const FILTER_MODULE = "nftables"; */ 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")]; + if (!named) throw new Error("MESH_LAB_CATALOG is not set to a checkout of mesh-catalog (or its modules directory)"); + const candidates = [resolve(named, "modules"), resolve(named)]; for (const dir of candidates) { // Known by the registry's manifest, which genesis reads from the catalogue and always will — // not the control plane's, which lives in the control plane's own repository (ADR 0069). if (existsSync(resolve(dir, "distribution", "module.json"))) return dir; } - throw new Error( - `no catalogue: MESH_LAB_CATALOG=${named ?? "(unset)"} and nothing at ${candidates.join(", ")}`); + throw new Error(`no catalogue: MESH_LAB_CATALOG=${named} and no distribution/module.json under ${candidates.join(" or ")}`); } /** Whether a catalogue is where a bed will look — for a skip guard, which says so instead of failing. */