diff --git a/test/beds-read-the-catalogue.test.ts b/test/beds-read-the-catalogue.test.ts new file mode 100644 index 0000000..091a2ee --- /dev/null +++ b/test/beds-read-the-catalogue.test.ts @@ -0,0 +1,102 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { catalogueIsPresent, catalogueDir } from "./integration/harness.ts"; + +/** + * A bed installs a catalogue module by reading the catalogue, never by carrying a copy. + * + * The beds used to build the manifests they install inline, as literals taken from the catalogue + * when each bed was written. The copies did not move when the catalogue did: six modules were + * converted to file-delivered secrets and not one bed ran the converted shape, because every bed + * ran its own copy (novox/hq 04-ISSUES/073). "Proven in the lab" then meant "the copy was proven". + * + * 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 + * 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. + */ + +/** + * Beds that still carry an inline copy of a catalogue module's manifest, and why. Three reasons + * recur, and each names the work that removes the entry: + * + * BESIDE the catalogue's module CLAIMS the foundation's container (postgres claims mesh-store, + * lavinmq mesh-broker) and adopts it in place; the bed raises a second one beside the + * foundation's instead. Reading the catalogue changes what the bed raises — it would + * adopt — and the bed's assertions with it. + * WEARING the bed proves a mesh mechanism (a grant, a credential, a restart, a route) with a + * module cut down to the shape the mechanism needs — no upstream server, a secret in the + * environment, a requirement edge removed — and gives it a catalogue name. It is a mesh + * test wearing a catalogue module's name. It should carry a name of its own, or read the + * catalogue and meet the module's real requirements. + * DIFFERS a module bed whose copy differs from the catalogue in more than the lab may rewrite + * (an image, a port, an address). Reading the catalogue is the fix and needs a run. + */ +const STILL_CARRIED: Record = { + "assigned-catalogue-apps.test.ts": { modules: ["postgres", "mongodb", "unifi", "marrytts"], + why: "BESIDE (postgres); DIFFERS (unifi takes its credentials from the environment, mongodb and marrytts drop listens)" }, + "assigned-catalogue-media.test.ts": { modules: ["sonarr", "radarr"], + why: "DIFFERS: both drop the route requirement the catalogue declares, and take their API keys from the environment" }, + "assigned-catalogue-small.test.ts": { modules: ["postgres", "minio", "redis", "plex"], + why: "BESIDE (postgres); DIFFERS (minio's root password by env-file, redis minting its own secret instead of the vault's, plex without its server)" }, + "assigned-model-usage.test.ts": { modules: ["postgres"], why: "BESIDE" }, + "assigned-two-node-db.test.ts": { modules: ["redis", "baserow", "letta"], + why: "DIFFERS: redis mints its own secret, baserow drops its route requirement, letta drops its ports" }, + "lavinmq-bed.test.ts": { modules: ["lavinmq", "amqp-ping"], + why: "BESIDE (lavinmq, with a bootstrap step and a data directory the catalogue has not got); DIFFERS (amqp-ping names its entrypoint)" }, + "assigned-grafana.test.ts": { modules: ["grafana"], why: "WEARING: the sidecar alone, no Grafana, no route" }, + "assigned-plex.test.ts": { modules: ["plex"], why: "WEARING: the sidecar alone, no Plex, the token in the environment" }, + "assigned-redis.test.ts": { modules: ["redis"], why: "WEARING: its own secret, a lab seal key in the environment" }, + "assigned-sonarr.test.ts": { modules: ["sonarr"], why: "WEARING: the sidecar alone against a forged config.xml" }, + "mesh-grant-end-to-end.test.ts": { modules: ["redis"], why: "WEARING: a grant mechanism test" }, + "minio-grant-end-to-end.test.ts": { modules: ["minio"], why: "WEARING: a grant mechanism test, the root password by env-file" }, + "postgres-grant-end-to-end.test.ts": { modules: ["postgres"], why: "WEARING: a grant mechanism test, the superuser by env-file" }, + "provider-on-backend-network.test.ts": { modules: ["redis"], why: "WEARING: a network mechanism test" }, + "provider-uses-mesh-credential.test.ts": { modules: ["redis"], why: "WEARING: a credential mechanism test" }, + "runtime-restart-on-config.test.ts": { modules: ["grafana"], why: "WEARING: a restart mechanism test" }, + "route-forwarding.test.ts": { modules: ["route-proxy", "hello-web"], + why: "WEARING: route-proxy without its certificate authority, hello-web with the route shape ADR 0066 replaced" }, + "mesh.test.ts": { modules: ["postgres", "builder", "umami"], + why: "WEARING: a postgres with no resources, a builder that builds itself, an umami that is another module of that name" }, +}; + +const beds = resolve(import.meta.dirname, "integration"); + +test("a bed that installs a catalogue module reads the catalogue", (t) => { + const absent = catalogueIsPresent(); + if (absent) { + // Said, not silent: a check that cannot see the catalogue has checked nothing. + t.skip(`cannot check — ${absent}`); + return; + } + const names = new Set(readdirSync(catalogueDir(), { withFileTypes: true }) + .filter((d) => d.isDirectory() && existsSync(resolve(catalogueDir(), d.name, "module.json"))) + .map((d) => d.name)); + + const offences: string[] = []; + for (const file of readdirSync(beds).filter((f) => f.endsWith(".test.ts")).sort()) { + const text = readFileSync(resolve(beds, file), "utf8"); + const found = new Set(); + // A manifest literal: the module's name with its version close behind it. A `module:` key + // elsewhere (a table of what to register, a grant entry) has no version and is not one. + 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]!); + } + const declared = STILL_CARRIED[file]; + for (const name of [...found].sort()) { + if (declared?.modules.includes(name)) continue; + offences.push(`${file}: an inline manifest for the catalogue's '${name}'`); + } + for (const name of declared?.modules ?? []) { + if (!found.has(name)) offences.push(`${file}: declared as still carrying '${name}', and it does not — remove the declaration`); + } + } + assert.deepEqual(offences, [], + `a bed carries a copy of a catalogue manifest; read it with catalogueModule() from the harness:\n ${offences.join("\n ")}`); +}); diff --git a/test/integration/anthropic-bed.test.ts b/test/integration/anthropic-bed.test.ts index c9d3409..eb89404 100644 --- a/test/integration/anthropic-bed.test.ts +++ b/test/integration/anthropic-bed.test.ts @@ -49,7 +49,7 @@ 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, catalogueModule, catalogueIsPresent } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -62,7 +62,7 @@ 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 = "anthropic-bed"; const MACHINE = "anchor"; @@ -194,8 +194,6 @@ after(async () => { test("model access refreshes on the manager node and delivers only the access token, never the refresh token", { skip, timeout: 1_500_000, }, async () => { - const managerImage = pinned("mesh-runtime-anthropic-manager"); - const consumerImage = pinned("mesh-runtime-anthropic-consumer"); // --- the licence, and the manager as its holder ------------------------------------------------ // The manager module is a HOLDER, named the licence's manager. It is delivered the refresh token; @@ -207,37 +205,15 @@ test("model access refreshes on the manager node and delivers only the access to await mesh(`licence use personal ${MACHINE} anthropic-manager`); // --- the manager module, deployed so the host delivers its bound facts -------------------------- - // Inline manifest mirroring the committed module.json: model-access holder, refresh token bound as a - // sealed secret, no node-key mount. The scheduled container installs as present state (ADR 0053); - // the test drives adopt/refresh directly for a deterministic flow rather than waiting on cron. - const managerManifest = JSON.stringify({ - module: "anthropic-manager", - version: "1", - requires: ["model-access"], - binds: { "model-access": "/var/lib/mesh/anthropic-manager/model.json" }, - secrets: { "model-access": "/var/lib/mesh/anthropic-manager/refresh-token" }, - "own-secrets": { broker: "/var/lib/mesh/anthropic-manager/broker" }, - emits: ["module.anthropic-manager.usage.read"], - resources: [ - { id: "mesh-state", type: "directory", path: "/var/lib/mesh/anthropic-manager", mode: "0700" }, - { id: "out", type: "directory", path: "/var/lib/mesh/anthropic-manager/out", mode: "0700" }, - { - id: "refresh", type: "container", name: "mesh-anthropic-manager-refresh", - image: managerImage, network: "host", schedule: "*/9 * * * *", - args: ["run", "/app/modules/anthropic-manager/dist/refresh/index.js"], - volumes: ["/var/lib/mesh/anthropic-manager:/run/state"], - env: { - MESH_ANTHROPIC_LICENCE: "personal", - MESH_ANTHROPIC_TOKEN_ENDPOINT: "http://127.0.0.1:9099/token", - MESH_ANTHROPIC_USAGE_ENDPOINT: "http://127.0.0.1:9099/usage", - MESH_MODEL_ACCESS_SECRET_FILE: "/run/state/refresh-token", - MESH_MODEL_ACCESS_BIND_FILE: "/run/state/model.json", - MESH_ANTHROPIC_ACCESS_OUT: "/run/state/out/access-token", - MESH_ANTHROPIC_GRANT_OUT: "/run/state/out/grant.json", - MESH_ANTHROPIC_USAGE_OUT: "/run/state/out/usage.json", - }, - }, - ], + // The catalogue's own manifest (novox/hq 04-ISSUES/073): model-access holder, refresh token bound + // as a sealed secret, no node-key mount. The scheduled container installs as present state (ADR + // 0053); the test drives adopt/refresh directly for a deterministic flow rather than waiting on + // cron. The one lab rewrite: the OAuth endpoints point at the stub this bed raises below. + const managerManifest = catalogueModule("anthropic-manager", held, { + env: { refresh: { + MESH_ANTHROPIC_TOKEN_ENDPOINT: "http://127.0.0.1:9099/token", + MESH_ANTHROPIC_USAGE_ENDPOINT: "http://127.0.0.1:9099/usage", + } }, }); await must(`printf %s ${quote(managerManifest)} > /tmp/anthropic-manager.json && docker cp /tmp/anthropic-manager.json mesh-controller:/anthropic-manager.json`); await mesh(`module add /anthropic-manager.json`); @@ -329,32 +305,8 @@ test("model access refreshes on the manager node and delivers only the access to assert.match(submitted, /sealed to 1 holder/, submitted); // --- 5. deliver: deploy the consumer and push; it gets the sealed access token ------------------- - const consumerManifest = JSON.stringify({ - module: "anthropic-consumer", - version: "1", - requires: ["model-access"], - binds: { "model-access": "/var/lib/anthropic-consumer/model.json" }, - secrets: { "model-access": "/var/lib/anthropic-consumer/access-token" }, - "own-secrets": { broker: "/var/lib/mesh/anthropic-consumer/broker" }, - emits: ["module.anthropic-consumer.usage.session"], - resources: [ - { id: "mesh-state", type: "directory", path: "/var/lib/mesh/anthropic-consumer", mode: "0700" }, - { id: "state", type: "directory", path: "/var/lib/anthropic-consumer", mode: "0700" }, - { id: "claude-home", type: "directory", path: "/var/lib/anthropic-consumer/claude", mode: "0700" }, - { - id: "apply", type: "container", name: "mesh-anthropic-consumer-apply", - image: consumerImage, network: "host", schedule: "*/9 * * * *", - args: ["run", "/app/modules/anthropic-consumer/dist/apply/index.js"], - volumes: ["/var/lib/anthropic-consumer:/run/state"], - env: { - MESH_MODEL_ACCESS_SECRET_FILE: "/run/state/access-token", - MESH_MODEL_ACCESS_BIND_FILE: "/run/state/model.json", - MESH_CLAUDE_CREDENTIALS_FILE: "/run/state/claude/.credentials.json", - MESH_CLAUDE_IDENTITY_FILE: "/run/state/claude/.claude.json", - }, - }, - ], - }); + // The catalogue's own manifest (novox/hq 04-ISSUES/073). + const consumerManifest = catalogueModule("anthropic-consumer", held); await must(`printf %s ${quote(consumerManifest)} > /tmp/anthropic-consumer.json && docker cp /tmp/anthropic-consumer.json mesh-controller:/anthropic-consumer.json`); await mesh(`module add /anthropic-consumer.json`); await mesh(`module issue anthropic-consumer --node ${MACHINE}`); diff --git a/test/integration/assigned-audit.test.ts b/test/integration/assigned-audit.test.ts index 22603fb..ebca781 100644 --- a/test/integration/assigned-audit.test.ts +++ b/test/integration/assigned-audit.test.ts @@ -22,7 +22,7 @@ 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, catalogueModule, catalogueIsPresent } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -35,7 +35,7 @@ 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 = "audit-node"; const MACHINE = "anchor"; @@ -145,26 +145,10 @@ after(async () => { test("the mesh assigns the audit logger, and it consumes over the account the mesh delivered", { skip, timeout: 900_000, }, async () => { - // The assigned-module manifest (mesh-catalog), its runtime image the ID the machine holds. - const manifest = JSON.stringify({ - module: "audit-logger", - version: "1", - consumes: ["#"], - "own-secrets": { broker: "/var/lib/audit-logger/broker" }, - resources: [ - { id: "state", type: "directory", path: "/var/lib/audit-logger", mode: "0700" }, - { id: "trail", type: "directory", path: "/var/lib/audit-logger/trail", mode: "0700" }, - { - id: "run", type: "container", name: "mesh-audit-logger", image: pinned("mesh-runtime-audit"), - network: "host", - volumes: [ - "/var/lib/audit-logger/broker:/run/secrets/broker:ro", - "/var/lib/audit-logger/trail:/trail", - ], - env: { MESH_BROKER_FILE: "/run/secrets/broker", AUDIT_LOG: "/trail/audit.log" }, - }, - ], - }); + // The catalogue's manifest (novox/hq 04-ISSUES/073). Its runtime artifact is the image this + // scenario stocks under the module's slug, `mesh-runtime-audit` — built by scripts/build-runtime-image.sh + // before build-module-runtime.sh generalised it, and named as it was. + const manifest = catalogueModule("audit-logger", held, { artifacts: { runtime: "mesh-runtime-audit" } }); await must(`printf %s ${quote(manifest)} > /tmp/audit.json && docker cp /tmp/audit.json mesh-controller:/audit.json`); await mesh("module add /audit.json"); diff --git a/test/integration/assigned-catalogue-mqtt.test.ts b/test/integration/assigned-catalogue-mqtt.test.ts index 762738c..527a842 100644 --- a/test/integration/assigned-catalogue-mqtt.test.ts +++ b/test/integration/assigned-catalogue-mqtt.test.ts @@ -36,7 +36,7 @@ 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, catalogueModule, catalogueIsPresent } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -49,7 +49,7 @@ 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 = "catalogue-mqtt"; const MACHINE = "anchor"; @@ -158,101 +158,11 @@ test("the mesh assigns mosquitto: a run-once step seeds dynsec before the broker skip, timeout: 1_500_000, }, async () => { // mosquitto's dynsec config: the plugin refuses to start unless dynamic-security.json holds an - // admin client, so the store MUST be seeded first. The `run-once` bootstrap container is declared - // BEFORE `server` (the broker) and reuses the module's runtime image; the host runs it to - // completion, then starts the broker. The runtime `server`/`runtime` shape mirrors the committed - // manifest, with images pinned to what this scenario serves by digest. - const mosquittoConf = - "persistence true\n" + - "persistence_location /mosquitto/data\n\n" + - "log_dest stdout\n" + - "log_type warning\n" + - "log_type error\n" + - "log_type notice\n\n" + - "# Every client authenticates; identities and their per-topic ACLs are managed\n" + - "# at runtime by the dynamic security plugin, whose store the plugin itself owns.\n" + - "allow_anonymous false\n" + - "plugin /usr/lib/mosquitto_dynamic_security.so\n" + - "plugin_opt_config_file /mosquitto/data/dynamic-security.json\n\n" + - "# MQTT listener\n" + - "listener 1883\n\n" + - "# MQTT-over-WebSockets listener\n" + - "listener 8081\n" + - "protocol websockets\n"; - - const manifest = JSON.stringify({ - module: "mosquitto", - version: "1", - provides: [{ name: "mqtt-topic", scope: "mesh" }], - serves: { "mqtt-topic": {} }, - emits: ["module.mosquitto.topic.provisioned", "module.mosquitto.topic.deprovisioned"], - // The events entrypoint subscribes to its own lifecycle events (an audit log), so it consumes - // them too — declared, or the foundation never makes the queue the runtime binds (ADR 0046). - consumes: ["module.mosquitto.topic.provisioned", "module.mosquitto.topic.deprovisioned"], - receives: { "mqtt-topic": "/var/lib/mosquitto-module/grants/mesh.json" }, - grants: { "mqtt-topic": "/var/lib/mosquitto-module/grants" }, - "own-secrets": { - admin: "/var/lib/mosquitto-module/admin.secret", - broker: "/var/lib/mesh/mosquitto/broker", - }, - resources: [ - { id: "mesh-state", type: "directory", path: "/var/lib/mesh/mosquitto", mode: "0700" }, - { id: "state", type: "directory", path: "/var/lib/mosquitto-module", mode: "0700" }, - { id: "grants-dir", type: "directory", path: "/var/lib/mosquitto-module/grants", mode: "0700" }, - // The broker runs as uid 1883, so the shared data directory it seeds into and persists to is - // its own. - { id: "data", type: "directory", path: "/services/mosquitto/data", mode: "0700", owner: "1883:1883" }, - { - id: "server-conf", type: "file", path: "/var/lib/mosquitto-module/mosquitto.conf", - mode: "0600", owner: "1883:1883", content: mosquittoConf, - }, - { id: "net", type: "network", name: "mosquitto" }, - // THE run-once step: seed dynsec offline, once, before the broker. It reuses the runtime image - // (`mesh-tools run ` imports mosquitto's bootstrap entrypoint, which writes the - // admin client into dynamic-security.json and chowns it to the broker's uid, then exits). It is - // declared BEFORE `server`; the host runs it to completion and requires exit 0 before starting - // the broker. - { - id: "bootstrap", type: "container", name: "mosquitto-bootstrap", - image: pinned("mesh-runtime-mosquitto"), "run-once": true, - volumes: [ - "/services/mosquitto/data:/mosquitto/data", - "/var/lib/mosquitto-module/admin.secret:/run/secrets/admin:ro", - ], - env: { - MESH_PROVISION_MQTT: "mosquitto:1883", - MESH_PROVISION_ADMIN_USER: "mesh-admin", - MESH_PROVISION_PASSWORD_FILE: "/run/secrets/admin", - MESH_DYNSEC_FILE: "/mosquitto/data/dynamic-security.json", - }, - args: ["run", "/app/modules/mosquitto/dist/bootstrap/index.js"], - }, - { - id: "server", type: "container", name: "mosquitto", image: pinned("eclipse-mosquitto"), - network: "mosquitto", ports: ["1883", "8081"], - volumes: [ - "/services/mosquitto/data:/mosquitto/data", - "/var/lib/mosquitto-module/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro", - ], - }, - { - id: "runtime", type: "container", name: "mesh-mosquitto", - image: pinned("mesh-runtime-mosquitto"), network: "mosquitto", - volumes: [ - "/var/lib/mesh/mosquitto/broker:/run/secrets/broker:ro", - "/var/lib/mosquitto-module/grants:/var/lib/mosquitto-module/grants:ro", - "/var/lib/mosquitto-module/admin.secret:/run/secrets/admin:ro", - ], - env: { - MESH_BROKER_FILE: "/run/secrets/broker", - MESH_RECEIVES: "/var/lib/mosquitto-module/grants/mesh.json", - MESH_PROVISION_MQTT: "mosquitto:1883", - MESH_PROVISION_ADMIN_USER: "mesh-admin", - MESH_PROVISION_PASSWORD_FILE: "/run/secrets/admin", - }, - }, - ], - }); + // admin client, so the store MUST be seeded first. The catalogue's manifest declares a `run-once` + // bootstrap container BEFORE `server` (the broker), reusing the module's runtime image; the host + // runs it to completion, then starts the broker. The manifest is the catalogue's own, its runtime + // artifact the image this scenario stocked (novox/hq 04-ISSUES/073). + const manifest = catalogueModule("mosquitto", held); await must(`printf %s ${quote(manifest)} > /tmp/mosquitto.json && docker cp /tmp/mosquitto.json mesh-controller:/mosquitto.json`); await mesh("module add /mosquitto.json"); diff --git a/test/integration/assigned-model-usage.test.ts b/test/integration/assigned-model-usage.test.ts index f3194b8..fbea8f6 100644 --- a/test/integration/assigned-model-usage.test.ts +++ b/test/integration/assigned-model-usage.test.ts @@ -38,7 +38,7 @@ 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, onTheMachine, deriveTheFilterOn, catalogueModule, catalogueIsPresent } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -51,7 +51,7 @@ 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 = "model-usage-bed"; /** The node that carries the postgres provider and the model-usage consumer. anchor carries only the @@ -190,7 +190,9 @@ test("usage events are upserted into model-usage's store — latest-per-key, bot }, async () => { // ================================================================================================ // THE MANIFESTS — postgres verbatim from two-node-db (its server publishes 5432 so the consumer - // reaches it), and model-usage the committed catalogue shape with its images pinned. + // reaches it): a SECOND postgres beside the foundation's store, which the catalogue's postgres would + // instead claim and adopt in place. Still an inline copy, declared in beds-read-the-catalogue.test.ts + // (novox/hq 04-ISSUES/073). model-usage is the catalogue's. // ================================================================================================ const postgresManifest = JSON.stringify({ module: "postgres", @@ -233,45 +235,12 @@ test("usage events are upserted into model-usage's store — latest-per-key, bot ], }); - // --- model-usage: requires postgres-database, owns a provisioned store, consumes module.*.usage.*, - // runs a run-once migrate then the long-lived event consumer. Both containers on the host network so - // they reach the granted postgres (at the provider's address the mesh writes) and the broker. ------ - const modelUsageManifest = JSON.stringify({ - module: "model-usage", - version: "1", - // `mesh_laptop_model-usage` is 23 chars, over the 20 an S3 access key keeps (ADR 0049); a short - // slug makes the consumer identity `mesh_laptop_usage` (17). db/role/`as` all derive from it. - slug: "usage", - capabilities: ["container-runtime"], - requires: ["postgres-database"], - contributes: { "postgres-database": { name: "model_usage" } }, - binds: { "postgres-database": "/var/lib/model-usage/database.json" }, - secrets: { "postgres-database": "/var/lib/model-usage/database.secret" }, - consumes: ["module.*.usage.*"], - "own-secrets": { broker: "/var/lib/mesh/model-usage/broker" }, - resources: [ - { id: "mesh-state", type: "directory", path: "/var/lib/mesh/model-usage", mode: "0700" }, - { id: "state", type: "directory", path: "/var/lib/model-usage", mode: "0700" }, - // The connection string carries the password, so it reaches the runtime as a file the mesh - // templates (novox/hq ADR 0086), the shape the catalogue's manifest has. - { - id: "database-url", type: "file", path: "/var/lib/model-usage/database.url", mode: "0600", - content: - "postgresql://${bound:postgres-database:as}:${secret:postgres-database}@" + - "${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\n", - }, - { - id: "runtime", type: "container", name: "mesh-model-usage", - image: pinned("mesh-runtime-model-usage"), network: "host", - volumes: [ - "/var/lib/mesh/model-usage/broker:/run/secrets/broker:ro", - "/var/lib/model-usage:/run/state", - "/var/lib/model-usage/database.url:/run/secrets/database-url:ro", - ], - env: { MESH_BROKER_FILE: "/run/secrets/broker", DATABASE_URL_FILE: "/run/secrets/database-url" }, - }, - ], - }); + // --- model-usage: the catalogue's own manifest (novox/hq 04-ISSUES/073). It requires + // postgres-database, owns a provisioned store, consumes module.*.usage.*, and its runtime is on the + // host network so it reaches the granted postgres (at the provider's address the mesh writes) and + // the broker. Its slug keeps the consumer identity under the 20 characters an S3 access key allows + // (ADR 0049). ------------------------------------------------------------------------------------ + const modelUsageManifest = catalogueModule("model-usage", held); async function addIssueAssign(name: string, manifest: string): Promise { await must("anchor", `printf %s ${quote(manifest)} > /tmp/${name}.json && docker cp /tmp/${name}.json mesh-controller:/${name}.json`); diff --git a/test/integration/assigned-tools-confluence.test.ts b/test/integration/assigned-tools-confluence.test.ts index 00f3fed..faafcf1 100644 --- a/test/integration/assigned-tools-confluence.test.ts +++ b/test/integration/assigned-tools-confluence.test.ts @@ -29,7 +29,7 @@ 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, catalogueModule, catalogueIsPresent } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -42,7 +42,7 @@ 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 = "tools-confluence"; const MACHINE = "anchor"; @@ -155,35 +155,9 @@ test("the mesh assigns confluence: its tools-only runtime comes up and serves th // confluence is tools-only and outbound-only: no service, no listener, no provisioner — just a // broker-bound runtime serving the module's tools. Its own token is a mesh-minted own-secret; the // lab has no real Confluence, so the token points at nothing — and that is the case under test: the - // runtime must serve every tool regardless. The runtime container name and shape mirror the - // committed manifest, with the image pinned to what this scenario serves by digest. - const manifest = JSON.stringify({ - module: "confluence", - version: "1", - "own-secrets": { - token: "/var/lib/confluence/token", - broker: "/var/lib/mesh/confluence/broker", - }, - resources: [ - { id: "mesh-state", type: "directory", path: "/var/lib/mesh/confluence", mode: "0700" }, - { id: "state", type: "directory", path: "/var/lib/confluence", mode: "0700" }, - { id: "config", type: "file", path: "/var/lib/confluence/config.json", merge: "json", content: "{}", mode: "0600" }, - { - id: "runtime", type: "container", name: "mesh-runtime-confluence", - image: pinned("mesh-runtime-confluence"), network: "host", - volumes: [ - "/var/lib/confluence/config.json:/run/config/config.json:ro", - "/var/lib/confluence/token:/run/secrets/token:ro", - "/var/lib/mesh/confluence/broker:/run/secrets/broker:ro", - ], - env: { - MESH_CONFLUENCE_TOKEN_FILE: "/run/secrets/token", - MESH_CONFLUENCE_CONFIG_FILE: "/run/config/config.json", - MESH_BROKER_FILE: "/run/secrets/broker", - }, - }, - ], - }); + // runtime must serve every tool regardless. The manifest is the catalogue's own, its runtime + // artifact the image this scenario stocked (novox/hq 04-ISSUES/073). + const manifest = catalogueModule("confluence", held); await must(`printf %s ${quote(manifest)} > /tmp/confluence.json && docker cp /tmp/confluence.json mesh-controller:/confluence.json`); await mesh("module add /confluence.json"); diff --git a/test/integration/assigned-tools-gitlab.test.ts b/test/integration/assigned-tools-gitlab.test.ts index 622882d..77b127f 100644 --- a/test/integration/assigned-tools-gitlab.test.ts +++ b/test/integration/assigned-tools-gitlab.test.ts @@ -28,7 +28,7 @@ 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, catalogueModule, catalogueIsPresent } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -41,7 +41,7 @@ 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 = "tools-gitlab"; const MACHINE = "anchor"; @@ -154,35 +154,9 @@ test("the mesh assigns gitlab: its tools-only runtime comes up and serves the fu // gitlab is tools-only and outbound-only: no service, no listener, no provisioner — just a // broker-bound runtime serving the module's tools. Its own token is a mesh-minted own-secret; the // lab has no real GitLab, so the token points at nothing — and that is the case under test: the - // runtime must serve every tool regardless. The runtime container name and shape mirror the - // committed manifest, with the image pinned to what this scenario serves by digest. - const manifest = JSON.stringify({ - module: "gitlab", - version: "1", - "own-secrets": { - token: "/var/lib/gitlab/token", - broker: "/var/lib/mesh/gitlab/broker", - }, - resources: [ - { id: "mesh-state", type: "directory", path: "/var/lib/mesh/gitlab", mode: "0700" }, - { id: "state", type: "directory", path: "/var/lib/gitlab", mode: "0700" }, - { id: "config", type: "file", path: "/var/lib/gitlab/config.json", merge: "json", content: "{}", mode: "0600" }, - { - id: "runtime", type: "container", name: "mesh-runtime-gitlab", - image: pinned("mesh-runtime-gitlab"), network: "host", - volumes: [ - "/var/lib/gitlab/config.json:/run/config/config.json:ro", - "/var/lib/gitlab/token:/run/secrets/token:ro", - "/var/lib/mesh/gitlab/broker:/run/secrets/broker:ro", - ], - env: { - MESH_GITLAB_TOKEN_FILE: "/run/secrets/token", - MESH_GITLAB_CONFIG_FILE: "/run/config/config.json", - MESH_BROKER_FILE: "/run/secrets/broker", - }, - }, - ], - }); + // runtime must serve every tool regardless. The manifest is the catalogue's own, its runtime + // artifact the image this scenario stocked (novox/hq 04-ISSUES/073). + const manifest = catalogueModule("gitlab", held); await must(`printf %s ${quote(manifest)} > /tmp/gitlab.json && docker cp /tmp/gitlab.json mesh-controller:/gitlab.json`); await mesh("module add /gitlab.json"); diff --git a/test/integration/harness.ts b/test/integration/harness.ts index 313a6b0..eb87ab9 100644 --- a/test/integration/harness.ts +++ b/test/integration/harness.ts @@ -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-` 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; + /** + * 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>; + /** + * 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>; +} + +/** + * 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 }[]; + build?: unknown; + }; + const artifacts: Record = { 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}: "" }).`); + 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 { diff --git a/test/integration/local-model-bed.test.ts b/test/integration/local-model-bed.test.ts index 7a01fe0..7263ff9 100644 --- a/test/integration/local-model-bed.test.ts +++ b/test/integration/local-model-bed.test.ts @@ -26,7 +26,7 @@ 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, catalogueModule, catalogueIsPresent } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -39,7 +39,7 @@ 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 = "local-model-bed"; const MACHINE = "anchor"; @@ -153,47 +153,13 @@ after(async () => { test("a node hosting a model answers model-access, and the consumer is handed its endpoint", { skip, timeout: 1_500_000, }, async () => { - const ollamaImage = pinned("ollama/ollama"); - - // The provider: ollama runs the model server and `provides: ["model-access"]` at node scope, serving - // its port and model. It mints nothing — provides/serves are declaration the mesh reads, so there is - // no runtime container, only the server. - const ollamaManifest = JSON.stringify({ - module: "ollama", - version: "1", - capabilities: ["container-runtime"], - provides: [{ name: "model-access", scope: "node" }], - listens: [{ port: 11434, protocol: "tcp", from: "machine", why: "local consumers reaching the model server" }], - serves: { "model-access": { port: 11434, model: "llama3.2" } }, - resources: [ - { id: "state", type: "directory", path: "/services/ollama", mode: "0700" }, - { - id: "server", type: "container", name: "ollama", - image: ollamaImage, network: "host", env: { OLLAMA_HOST: "0.0.0.0:11434" }, - volumes: ["/services/ollama:/root/.ollama"], - }, - ], - }); - - // The consumer: it requires model-access and is answered by the local node. No secret (the local - // server is keyless), only the bound endpoint facts, templated into an openai.env the mesh writes. - const consumerManifest = JSON.stringify({ - module: "local-model-consumer", - version: "1", - slug: "local", - requires: ["model-access"], - binds: { "model-access": "/var/lib/local-model-consumer/model.json" }, - resources: [ - { id: "state", type: "directory", path: "/var/lib/local-model-consumer", mode: "0700" }, - { id: "config", type: "directory", path: "/var/lib/local-model-consumer/config", mode: "0700" }, - { - id: "openai-env", type: "file", path: "/var/lib/local-model-consumer/config/openai.env", mode: "0600", - content: - "OPENAI_BASE_URL=http://${bound:model-access:at}:${bound:model-access:port}/v1\n" + - "OPENAI_MODEL=${bound:model-access:model}\nOPENAI_API_KEY=local\n", - }, - ], - }); + // Both manifests are the catalogue's own (novox/hq 04-ISSUES/073). The provider: ollama runs the + // model server and `provides: ["model-access"]` at node scope, serving its port and model. It mints + // nothing — provides/serves are declaration the mesh reads, so there is no runtime container, only + // the server. The consumer requires model-access and is answered by the local node: no secret (the + // local server is keyless), only the bound endpoint facts, templated into an openai.env the mesh writes. + const ollamaManifest = catalogueModule("ollama", held); + const consumerManifest = catalogueModule("local-model-consumer", held); await addAssign("ollama", ollamaManifest); await addAssign("local-model-consumer", consumerManifest); diff --git a/test/integration/openai-bed.test.ts b/test/integration/openai-bed.test.ts index f80f327..885640c 100644 --- a/test/integration/openai-bed.test.ts +++ b/test/integration/openai-bed.test.ts @@ -24,7 +24,7 @@ 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, catalogueModule, catalogueIsPresent } from "./harness.ts"; import type { HeldImage } from "../../src/pinning.ts"; const capability = await labIsUsable(); @@ -37,7 +37,7 @@ 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 = "openai-bed"; const MACHINE = "anchor"; @@ -156,7 +156,6 @@ after(async () => { test("a static-key model-access licence delivers the operator's API key to the consumer, unchanged", { skip, timeout: 1_500_000, }, async () => { - const consumerImage = pinned("mesh-runtime-openai-consumer"); // --- the licence, a record with vendor openai (static-key) ------------------------------------- // No manager: a static-key licence has none (mesh-controller refuses `licence manager` on it). The @@ -172,33 +171,11 @@ test("a static-key model-access licence delivers the operator's API key to the c await mesh(`licence key personal --file /openai-key`); // --- deploy the consumer ----------------------------------------------------------------------- - // Inline manifest mirroring the committed module.json: a model-access holder whose delivered key + // The catalogue's own manifest (novox/hq 04-ISSUES/073): a model-access holder whose delivered key // arrives at its secret path. The scheduled apply container installs as present state (ADR 0053) and // its image is pulled at apply (schedule-pull); the test drives apply directly for a deterministic // flow rather than waiting on cron. - const consumerManifest = JSON.stringify({ - module: "openai-consumer", - version: "1", - requires: ["model-access"], - binds: { "model-access": "/var/lib/openai-consumer/model.json" }, - secrets: { "model-access": "/var/lib/openai-consumer/api-key" }, - resources: [ - { id: "state", type: "directory", path: "/var/lib/openai-consumer", mode: "0700" }, - { id: "config", type: "directory", path: "/var/lib/openai-consumer/config", mode: "0700" }, - { - id: "apply", type: "container", name: "mesh-openai-consumer-apply", - image: consumerImage, network: "host", schedule: "*/5 * * * *", - args: ["run", "/app/modules/openai-consumer/dist/apply/index.js"], - volumes: ["/var/lib/openai-consumer:/run/state"], - env: { - MESH_MODEL_ACCESS_SECRET_FILE: "/run/state/api-key", - MESH_MODEL_ACCESS_BIND_FILE: "/run/state/model.json", - MESH_OPENAI_ENV_FILE: "/run/state/config/openai.env", - MESH_OPENAI_CREDENTIALS_FILE: "/run/state/config/auth.json", - }, - }, - ], - }); + const consumerManifest = catalogueModule("openai-consumer", held); await must(`printf %s ${quote(consumerManifest)} > /tmp/openai-consumer.json && docker cp /tmp/openai-consumer.json mesh-controller:/openai-consumer.json`); await mesh(`module add /openai-consumer.json`); await mesh(`module issue openai-consumer --node ${MACHINE}`);