From 36f2fd1c2c6b9eccf48dc33932f20c130ce025d8 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 22 Sep 2026 12:26:34 +0200 Subject: [PATCH 1/5] A cache consumer presents the login it was granted, checked over the catalogue; the two-node bed's baserow keeps its own cache and the bed asserts it answers (issue 081) --- scenarios/two-node-db.yml | 7 +- test/beds-read-the-catalogue.test.ts | 4 +- ...ache-consumers-present-their-login.test.ts | 56 +++++++++ test/integration/assigned-two-node-db.test.ts | 112 ++++++------------ 4 files changed, 94 insertions(+), 85 deletions(-) create mode 100644 test/cache-consumers-present-their-login.test.ts diff --git a/scenarios/two-node-db.yml b/scenarios/two-node-db.yml index 9295550..dd9c57c 100644 --- a/scenarios/two-node-db.yml +++ b/scenarios/two-node-db.yml @@ -3,7 +3,8 @@ # The app-postgres provider and the mesh's own foundation store both want host port 5432, so they # cannot share a machine — the collision that blocked this chain single-node. Here the foundation # (store, broker, control) lives on `anchor` and NOTHING else; `laptop` runs the whole chain — -# postgres and redis PROVIDERS plus the baserow and letta CONSUMERS that require them. Both +# the baserow and letta CONSUMERS of the one store (baserow keeps its cache inside its own container, +# novox/hq 081). Both # machines sit on one shared segment and enrol into the one mesh; only enrolment crosses to anchor, # over the underlay both machines already share. Provider and consumers are co-located on laptop, so # no cross-node module comms and no overlay are needed — and the 5432-vs-foundation conflict is gone @@ -25,8 +26,7 @@ machines: inbound: allow memory: 4GiB cpus: 4 - # The whole DB-consumer chain: postgres + redis providers, each a server and a broker-bound - # runtime, plus the baserow and letta consumer services and their tools runtimes — a dozen + # The DB consumers: the baserow and letta services and their tools runtimes — a handful of # containers, two of them memory-hungry app servers (the Baserow all-in-one and the Letta server). # At the 2GiB the two-nodes bed gives this machine it would thrash — its own anchor comment says # so — and convergence would present as "the mesh hangs". Six gigabytes gives it room. @@ -49,7 +49,6 @@ images: # provisioner (ADR 0048). - mesh-runtime-postgres:development - mesh-runtime-lavinmq:development - - mesh-runtime-redis:development - mesh-runtime-baserow:development - mesh-runtime-letta:development diff --git a/test/beds-read-the-catalogue.test.ts b/test/beds-read-the-catalogue.test.ts index d1d7cfd..abe26f4 100644 --- a/test/beds-read-the-catalogue.test.ts +++ b/test/beds-read-the-catalogue.test.ts @@ -50,8 +50,8 @@ const STILL_CARRIED: Record = { "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" }, + "assigned-two-node-db.test.ts": { modules: ["baserow", "letta"], + why: "DIFFERS: 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)" }, }; diff --git a/test/cache-consumers-present-their-login.test.ts b/test/cache-consumers-present-their-login.test.ts new file mode 100644 index 0000000..227c5dc --- /dev/null +++ b/test/cache-consumers-present-their-login.test.ts @@ -0,0 +1,56 @@ +/** + * **A module that takes a shared-cache grant presents the login it was granted** (novox/hq 081). + * + * The cache provider scopes each consumer to an ACL user of its own, confined to keys under its + * login (novox/hq 080). A consumer that hands its software the password and not the login logs in + * as the server's default user: the grant is honoured by the provider and ignored by the consumer, + * and nothing notices while the default user is open. The grant bed proves the provider's half + * against a consumer written to the contract; this holds every catalogue module that asks for the + * cache to its half — the login, as `${bound:redis-cache:as}`, somewhere it hands its software. + * + * What it cannot see is whether the software also keeps its keys under that login: that is the + * software's, and a module whose software cannot (fixed key or channel names in its code) does not + * take the shared cache at all — baserow runs its own, and n8n in its shipped mode needs none. + */ +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"; + +const CACHE = "redis-cache"; + +/** Whether a manifest hands its software the login it is granted for the cache. */ +function presentsTheLogin(manifest: string): boolean { + return manifest.includes(`\${bound:${CACHE}:as}`); +} + +test("the check sees a module that hands its software the password and not the login", () => { + const passwordOnly = JSON.stringify({ module: "m", requires: [CACHE], resources: [ + { id: "env", type: "file", path: "/x", content: `HOST=\${bound:${CACHE}:at}\nPASSWORD=\${secret:${CACHE}}\n` }] }); + const withLogin = JSON.stringify({ module: "m", requires: [CACHE], resources: [ + { id: "env", type: "file", path: "/x", content: `USER=\${bound:${CACHE}:as}\nPASSWORD=\${secret:${CACHE}}\n` }] }); + assert.equal(presentsTheLogin(passwordOnly), false); + assert.equal(presentsTheLogin(withLogin), true); +}); + +test("every catalogue module that takes the shared cache presents the login it was granted", (t) => { + const absent = catalogueIsPresent(); + if (absent) { + t.skip(`cannot check — ${absent}`); + return; + } + const offences: string[] = []; + for (const d of readdirSync(catalogueDir(), { withFileTypes: true })) { + const file = resolve(catalogueDir(), d.name, "module.json"); + if (!d.isDirectory() || !existsSync(file)) continue; + const text = readFileSync(file, "utf8"); + const m = JSON.parse(text) as { requires?: string[] }; + if (!(m.requires ?? []).includes(CACHE)) continue; + if (!presentsTheLogin(text)) offences.push(d.name); + } + assert.deepEqual(offences, [], + `these take the shared cache and never hand their software the login (\${bound:${CACHE}:as}), so they ` + + `log in as the server's default user — present the login, or run a cache of their own:\n ${offences.join("\n ")}`); +}); diff --git a/test/integration/assigned-two-node-db.test.ts b/test/integration/assigned-two-node-db.test.ts index 8708cb3..c87c837 100644 --- a/test/integration/assigned-two-node-db.test.ts +++ b/test/integration/assigned-two-node-db.test.ts @@ -8,15 +8,14 @@ * * This bed proves that across two machines. `anchor` is the control-node: it raises the foundation * and adopts `postgres` there, so `mesh-store` is the one store and `mesh-postgres` its provisioner. - * `laptop` joins and runs the CONSUMERS — baserow and letta, which require `postgres-database` — plus - * a co-located `redis` (which holds no seat). Each consumer's database is minted on the store on - * anchor and reached over the overlay: their bindings name `anchor.internal`, and their minted logins - * authenticate against the store. redis stays co-located on laptop for baserow's cache. + * `laptop` joins and runs the CONSUMERS — baserow and letta, which require `postgres-database`. Each + * consumer's database is minted on the store on anchor and reached over the overlay: their bindings + * name `anchor.internal`, and their minted logins authenticate against the store. baserow's cache is + * its own, inside its container (novox/hq 081). * - * The four manifests are the committed catalogue shapes (novox/hq ADR 0039/0047/0048), verbatim - * from the catalogue-broad bed — postgres publishes 5432 so its consumers connect, redis runs on - * the host network with a lab-local seal key, and baserow/letta wire their servers to the grant the - * mesh writes. They are added, each issued a scoped broker account, assigned to laptop, and pushed + * The three manifests are the committed catalogue shapes (novox/hq ADR 0039/0047/0048), verbatim + * from the catalogue-broad bed — postgres publishes 5432 so its consumers connect, and baserow/letta + * wire their servers to the grant the mesh writes. They are added, each issued a scoped broker account, assigned to laptop, and pushed * ONCE; laptop converges once with every one up, and the two consumers are provisioned against the * database the provider on their own node gave them. * @@ -25,12 +24,11 @@ * MESH_LAB_HOST_BINARY=.../mesh-host * MESH_LAB_BUNDLE=.../examples/foundation-first-node.lock * - * HELPER — stock the four runtimes into the local daemon before the run (some may already be there): + * HELPER — stock the three runtimes into the local daemon before the run (some may already be there): * scripts/build-module-runtime.sh postgres /tmp/postgres.tar - * scripts/build-module-runtime.sh redis /tmp/redis.tar * scripts/build-module-runtime.sh baserow /tmp/baserow.tar * scripts/build-module-runtime.sh letta /tmp/letta.tar - * The service images (postgres:17-alpine, redis:7-alpine, baserow/baserow:latest, letta/letta:latest) + * The service images (postgres:17-alpine, baserow/baserow:latest, letta/letta:latest) * must be in the local daemon too; scenarios/two-node-db.yml stocks all of them, and each node pulls * what it runs from the scenario's own registry by digest. */ @@ -205,64 +203,17 @@ test("consumers on a joined node get their databases from the one foundation sto // they land on changes. // ================================================================================================ - // --- redis: a cache provider on the host network (127.0.0.1:6379). No seal key: the provider - // is handed the minted credential already unsealed by the host (ADR 0048). It carries - // the committed provides/serves/receives/grants so baserow's redis-cache requirement resolves. ---- - const redisManifest = JSON.stringify({ - module: "redis", - version: "1", - provides: [{ name: "redis-cache", scope: "mesh" }], - serves: { "redis-cache": { port: 6379 } }, - emits: ["module.redis.cache.provisioned", "module.redis.cache.deprovisioned"], - consumes: ["module.redis.cache.provisioned", "module.redis.cache.deprovisioned"], - receives: { "redis-cache": "/var/lib/redis-module/grants/mesh.json" }, - grants: { "redis-cache": "/var/lib/redis-module/grants" }, - "own-secrets": { default: "/var/lib/redis-module/default.secret", broker: "/var/lib/mesh/redis/broker" }, - resources: [ - { id: "mesh-state", type: "directory", path: "/var/lib/mesh/redis", mode: "0700" }, - { id: "state", type: "directory", path: "/var/lib/redis-module", mode: "0700" }, - { id: "grants-dir", type: "directory", path: "/var/lib/redis-module/grants", mode: "0700" }, - { id: "data", type: "directory", path: "/services/redis/data", mode: "0700", owner: "999:999" }, - { - id: "server-conf", type: "file", path: "/var/lib/redis-module/redis.conf", mode: "0644", - content: "requirepass ${secret:default}\nappendonly no\ndir /data\n", - }, - { - id: "server", type: "container", name: "redis", image: pinned("redis"), network: "host", - volumes: [ - "/services/redis/data:/data", - "/var/lib/redis-module/redis.conf:/etc/redis/redis.conf:ro", - ], - args: ["/etc/redis/redis.conf"], - }, - { - id: "runtime", type: "container", name: "mesh-redis", image: pinned("mesh-runtime-redis"), - network: "host", - volumes: [ - "/var/lib/mesh/redis/broker:/run/secrets/broker:ro", - "/var/lib/redis-module/grants:/var/lib/redis-module/grants", - "/var/lib/redis-module/default.secret:/run/secrets/default:ro", - ], - env: { - MESH_BROKER_FILE: "/run/secrets/broker", - MESH_RECEIVES: "/var/lib/redis-module/grants/mesh.json", - GRANTS: "/var/lib/redis-module/grants", - MESH_PROVISION_REDIS: "127.0.0.1:6379", - MESH_PROVISION_PASSWORD_FILE: "/run/secrets/default", - }, - }, - ], - }); - - // --- baserow: a consumer that requires postgres-database AND redis-cache; server wired to both - // from the grants the mesh writes, plus a runtime that serves baserow's tools. -------------------- + // --- baserow: a consumer that requires postgres-database, its server wired to the grant the mesh + // writes, plus a runtime that serves baserow's tools. Its cache is its own — the image runs one when + // no REDIS_HOST is given — because baserow keeps keys and channels under fixed names a shared + // cache's per-consumer grant cannot confine (novox/hq 081). -------------------------------------- const baserowManifest = JSON.stringify({ module: "baserow", version: "1", - requires: ["postgres-database", "redis-cache"], + requires: ["postgres-database"], contributes: { "postgres-database": { name: "baserow" } }, - binds: { "postgres-database": "/var/lib/baserow/database.json", "redis-cache": "/var/lib/baserow/redis.json" }, - secrets: { "postgres-database": "/var/lib/baserow/database.secret", "redis-cache": "/var/lib/baserow/redis.secret" }, + binds: { "postgres-database": "/var/lib/baserow/database.json" }, + secrets: { "postgres-database": "/var/lib/baserow/database.secret" }, "own-secrets": { "secret-key": "/var/lib/baserow/secret-key.secret", broker: "/var/lib/mesh/baserow/broker" }, resources: [ { id: "mesh-state", type: "directory", path: "/var/lib/mesh/baserow", mode: "0700" }, @@ -273,8 +224,7 @@ test("consumers on a joined node get their databases from the one foundation sto content: "DATABASE_HOST=${bound:postgres-database:at}\nDATABASE_PORT=${bound:postgres-database:port}\n" + "DATABASE_NAME=${bound:postgres-database:as}\nDATABASE_USER=${bound:postgres-database:as}\n" + - "DATABASE_PASSWORD=${secret:postgres-database}\nREDIS_HOST=${bound:redis-cache:at}\n" + - "REDIS_PORT=${bound:redis-cache:port}\nREDIS_PROTOCOL=redis\nREDIS_PASSWORD=${secret:redis-cache}\n" + + "DATABASE_PASSWORD=${secret:postgres-database}\n" + "SECRET_KEY=${secret:secret-key}\nBASEROW_PUBLIC_URL=http://localhost\n", }, { id: "net", type: "network", name: "baserow" }, @@ -401,8 +351,7 @@ test("consumers on a joined node get their databases from the one foundation sto await mesh(`push anchor`, 600_000); await settled("anchor"); - // The consumers ride laptop; redis is an ordinary co-located provider (it holds no seat). - await addIssueAssign("redis", redisManifest); + // The consumers ride laptop. await addIssueAssign("baserow", baserowManifest); await addIssueAssign("letta", lettaManifest); await mesh(`push ${NODE}`); @@ -429,7 +378,6 @@ test("consumers on a joined node get their databases from the one foundation sto // THE co-residence proof — every module's containers up and stable on the second node. // ================================================================================================ const expected = [ - "redis", "mesh-redis", "baserow", "mesh-baserow", "letta", "mesh-letta", ]; @@ -505,7 +453,7 @@ test("consumers on a joined node get their databases from the one foundation sto // reached from laptop over the shared segment) — named for the node that runs it and the module. // ================================================================================================ const users = await must("anchor", `docker exec mesh-broker lavinmqctl list_users 2>&1`); - for (const acct of ["anchor-postgres", "laptop-redis", "laptop-baserow", "laptop-letta"]) { + for (const acct of ["anchor-postgres", "laptop-baserow", "laptop-letta"]) { assert.match(users, new RegExp(acct), `the scoped account ${acct} is not on the broker:\n${users}`); } @@ -537,14 +485,20 @@ test("consumers on a joined node get their databases from the one foundation sto assert.match(pg.out, /^1$/m, `${mod} could not connect to its granted postgres database as ${bound.as}:\n${pg.out}`); } - // baserow also got its redis-cache binding: the mesh wrote the binding and unsealed the secret, - // and both arrived on the second node (a live redis AUTH is left to the redis single-module bed - // and the open provider-seal-key work). - const redisBound = await waitForBinding("/var/lib/baserow/redis.json"); - assert.equal(redisBound.provision, "redis-cache", `baserow's redis binding is the wrong provision: ${redisBound.provision}`); - assert.ok(redisBound.as, `baserow's redis binding carries no login:\n${JSON.stringify(redisBound)}`); - const redisSecret = (await must(NODE, `cat /var/lib/baserow/redis.secret`)).trim(); - assert.ok(redisSecret.length > 0, "baserow's redis secret was not delivered"); + // baserow's cache is its own: with no REDIS_HOST the image runs one inside the container, under a + // password it makes itself, and the mesh grants nothing (novox/hq 081). Up means it answers on + // loopback — PONG, or the challenge for the password it holds — and baserow logged no refusal. + let cache = { out: "", ok: false }; + const untilCache = Date.now() + 180_000; + while (Date.now() < untilCache) { + cache = await on(NODE, `docker exec baserow redis-cli -h 127.0.0.1 ping 2>&1`); + if (/PONG|NOAUTH/.test(cache.out)) break; + await new Promise((r) => setTimeout(r, 5000)); + } + assert.match(cache.out, /PONG|NOAUTH/, `baserow's own cache is not running inside its container:\n${cache.out}`); + const baserowLog = (await on(NODE, `docker logs baserow 2>&1 | tail -400`)).out; + assert.doesNotMatch(baserowLog, /WRONGPASS|NOPERM|NOAUTH|Error .*connecting to .*6379/i, + `baserow could not use its cache:\n${baserowLog.split("\n").filter((l) => /redis|6379|NOAUTH|WRONGPASS|NOPERM/i.test(l)).slice(-15).join("\n")}`); // Helper: wait for the mesh to write a consumer's bound file with an `as`, and parse it. async function waitForBinding(path: string): Promise<{ as: string; provision: string }> { From 2dfffd6dac90eeda6c2dae1ae9e419b102df009a Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 22 Sep 2026 12:34:59 +0200 Subject: [PATCH 2/5] Review of 081: the bed asserts baserow chose its own cache and it challenges for its password; the check reads only what a module hands its software; stale comments --- scenarios/two-node-db.yml | 5 ++-- scenarios/whole-mesh-ace.yml | 2 +- ...ache-consumers-present-their-login.test.ts | 21 +++++++++++++++- test/integration/assigned-two-node-db.test.ts | 24 ++++++++++++------- test/integration/whole-mesh-ace.test.ts | 4 ++-- 5 files changed, 40 insertions(+), 16 deletions(-) diff --git a/scenarios/two-node-db.yml b/scenarios/two-node-db.yml index dd9c57c..801f35c 100644 --- a/scenarios/two-node-db.yml +++ b/scenarios/two-node-db.yml @@ -6,9 +6,8 @@ # the baserow and letta CONSUMERS of the one store (baserow keeps its cache inside its own container, # novox/hq 081). Both # machines sit on one shared segment and enrol into the one mesh; only enrolment crosses to anchor, -# over the underlay both machines already share. Provider and consumers are co-located on laptop, so -# no cross-node module comms and no overlay are needed — and the 5432-vs-foundation conflict is gone -# because the foundation store is on the OTHER node. +# over the underlay both machines already share. The consumers' databases are minted on the one +# foundation store on anchor and reached over the overlay; laptop runs no provider of its own. scenario: two-node-db segments: diff --git a/scenarios/whole-mesh-ace.yml b/scenarios/whole-mesh-ace.yml index 3ecb8f1..8d8147c 100644 --- a/scenarios/whole-mesh-ace.yml +++ b/scenarios/whole-mesh-ace.yml @@ -3,7 +3,7 @@ # scenarios/whole-mesh-novox.yml; same topology, a different (larger, media-heavy) module set. # # Foundation (store, broker, control) rides `anchor` and NOTHING else; ALL of ace's services ride the -# `ace` node. An overlay is placed so the two DB consumers (baserow, letta) reach the postgres/redis +# `ace` node. An overlay is placed so the two DB consumers (baserow, letta) reach the postgres # providers co-located with them. The media stack (sonarr/radarr/lidarr/plex/bazarr/nzbget/ # qbittorrent/bookshelf) shares the operator-owned library directories under /services/media (ADR # 0051 `accesses`); the test pre-creates them on the node, as the operator would, before the push — diff --git a/test/cache-consumers-present-their-login.test.ts b/test/cache-consumers-present-their-login.test.ts index 227c5dc..8468c2c 100644 --- a/test/cache-consumers-present-their-login.test.ts +++ b/test/cache-consumers-present-their-login.test.ts @@ -21,9 +21,23 @@ import { catalogueIsPresent, catalogueDir } from "./integration/harness.ts"; const CACHE = "redis-cache"; +/** What a module hands its software: every file it writes, and every container's environment and + * arguments. A comment, a `why`, or a declared exception is not handed to anything. */ +function handedToSoftware(manifest: string): string[] { + const m = JSON.parse(manifest) as { resources?: Record[] }; + const out: string[] = []; + for (const r of m.resources ?? []) { + if (typeof r["content"] === "string") out.push(r["content"] as string); + if (r["env"] && typeof r["env"] === "object") out.push(...Object.values(r["env"] as Record).map(String)); + if (Array.isArray(r["args"])) out.push(...(r["args"] as unknown[]).map(String)); + } + return out; +} + /** Whether a manifest hands its software the login it is granted for the cache. */ function presentsTheLogin(manifest: string): boolean { - return manifest.includes(`\${bound:${CACHE}:as}`); + const login = `\${bound:${CACHE}:as}`; + return handedToSoftware(manifest).some((given) => given.includes(login)); } test("the check sees a module that hands its software the password and not the login", () => { @@ -33,6 +47,11 @@ test("the check sees a module that hands its software the password and not the l { id: "env", type: "file", path: "/x", content: `USER=\${bound:${CACHE}:as}\nPASSWORD=\${secret:${CACHE}}\n` }] }); assert.equal(presentsTheLogin(passwordOnly), false); assert.equal(presentsTheLogin(withLogin), true); + // Named only where no software reads it — a declared reason — is not presenting it. + const onlyInAReason = JSON.stringify({ module: "m", requires: [CACHE], resources: [ + { id: "srv", type: "container", name: "s", image: "x", env: { PASSWORD: `\${secret:${CACHE}}` }, + "secrets-in-environment": `the login is \${bound:${CACHE}:as}` }] }); + assert.equal(presentsTheLogin(onlyInAReason), false); }); test("every catalogue module that takes the shared cache presents the login it was granted", (t) => { diff --git a/test/integration/assigned-two-node-db.test.ts b/test/integration/assigned-two-node-db.test.ts index c87c837..81391a1 100644 --- a/test/integration/assigned-two-node-db.test.ts +++ b/test/integration/assigned-two-node-db.test.ts @@ -28,7 +28,7 @@ * scripts/build-module-runtime.sh postgres /tmp/postgres.tar * scripts/build-module-runtime.sh baserow /tmp/baserow.tar * scripts/build-module-runtime.sh letta /tmp/letta.tar - * The service images (postgres:17-alpine, baserow/baserow:latest, letta/letta:latest) + * The service images (postgres, baserow, letta, each pinned by digest) * must be in the local daemon too; scenarios/two-node-db.yml stocks all of them, and each node pulls * what it runs from the scenario's own registry by digest. */ @@ -486,19 +486,25 @@ test("consumers on a joined node get their databases from the one foundation sto } // baserow's cache is its own: with no REDIS_HOST the image runs one inside the container, under a - // password it makes itself, and the mesh grants nothing (novox/hq 081). Up means it answers on - // loopback — PONG, or the challenge for the password it holds — and baserow logged no refusal. + // password it makes itself, and the mesh grants nothing (novox/hq 081). Three things say so: + // baserow said it chose its own; the cache answers on loopback with the challenge for that password + // (PONG would be a cache anyone can use, and fails); and nothing was refused a login. + const baserowLog = async () => (await on(NODE, `docker logs baserow 2>&1`)).out; + let chose = ""; let cache = { out: "", ok: false }; - const untilCache = Date.now() + 180_000; + const untilCache = Date.now() + 240_000; while (Date.now() < untilCache) { + chose = await baserowLog(); cache = await on(NODE, `docker exec baserow redis-cli -h 127.0.0.1 ping 2>&1`); - if (/PONG|NOAUTH/.test(cache.out)) break; + if (/Using embedded baserow redis/.test(chose) && /NOAUTH/.test(cache.out)) break; await new Promise((r) => setTimeout(r, 5000)); } - assert.match(cache.out, /PONG|NOAUTH/, `baserow's own cache is not running inside its container:\n${cache.out}`); - const baserowLog = (await on(NODE, `docker logs baserow 2>&1 | tail -400`)).out; - assert.doesNotMatch(baserowLog, /WRONGPASS|NOPERM|NOAUTH|Error .*connecting to .*6379/i, - `baserow could not use its cache:\n${baserowLog.split("\n").filter((l) => /redis|6379|NOAUTH|WRONGPASS|NOPERM/i.test(l)).slice(-15).join("\n")}`); + assert.match(chose, /Using embedded baserow redis/, + `baserow did not start its own cache:\n${chose.split("\n").filter((l) => /redis/i.test(l)).slice(-15).join("\n")}`); + assert.match(cache.out, /NOAUTH/, + `baserow's own cache does not answer with its password challenge inside the container:\n${cache.out}`); + assert.doesNotMatch(chose, /WRONGPASS|NOPERM/, + `baserow was refused by its cache:\n${chose.split("\n").filter((l) => /WRONGPASS|NOPERM/.test(l)).slice(-15).join("\n")}`); // Helper: wait for the mesh to write a consumer's bound file with an `as`, and parse it. async function waitForBinding(path: string): Promise<{ as: string; provision: string }> { diff --git a/test/integration/whole-mesh-ace.test.ts b/test/integration/whole-mesh-ace.test.ts index 818c40d..5b1d0c5 100644 --- a/test/integration/whole-mesh-ace.test.ts +++ b/test/integration/whole-mesh-ace.test.ts @@ -4,7 +4,7 @@ * whole-mesh-novox.test.ts; same harness and topology, a larger, media-heavy module set. * * Foundation (store, broker, control) rides `anchor` and nothing else; ALL of ace's services ride the - * `ace` node. An overlay is placed so the two DB consumers (baserow, letta) reach the postgres/redis + * `ace` node. An overlay is placed so the two DB consumers (baserow, letta) reach the postgres * providers co-located with them. The media stack shares the operator-owned library directories * under /services/media (ADR 0051 `accesses`): the mesh writes an `access` resource that CONFIRMS * each path exists and mounts it, but creates and chowns none of it — so before() pre-creates those @@ -264,7 +264,7 @@ test("the whole ace service set resolves, installs and converges on one node in }, async () => { for (const d of DROPPED) console.log(`DROPPED ${d.name}: ${d.why}`); - // The overlay, so a consumer's binding `at` is non-empty (baserow/letta reach postgres/redis). + // The overlay, so a consumer's binding `at` is non-empty (baserow/letta reach postgres). await mesh("overlay place anchor --hub --endpoint 192.0.2.10:51820 --site lab"); await mesh(`overlay place ${NODE} --site lab`); await mesh("assign anchor networking"); From 3f71b91fb8d9e4734ff69b34fe42e339d2d49524 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 22 Sep 2026 12:59:28 +0200 Subject: [PATCH 3/5] The two-node bed's timeout report shows the node that did not answer, not always the second one --- test/integration/assigned-two-node-db.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/integration/assigned-two-node-db.test.ts b/test/integration/assigned-two-node-db.test.ts index 81391a1..a2fd3e9 100644 --- a/test/integration/assigned-two-node-db.test.ts +++ b/test/integration/assigned-two-node-db.test.ts @@ -150,12 +150,13 @@ async function settled(node: string, withinMs = 1_200_000): Promise { last = said; await new Promise((r) => setTimeout(r, 5000)); } - // Timed out — capture what the node is actually doing so the failure is diagnosable. - const ps = (await on(NODE, `docker ps -a --format '{{.Names}}\t{{.Status}}'`)).out; - const hostLog = (await on(NODE, `tail -80 /var/log/mesh-host.log`)).out; + // Timed out — capture what the node that did not answer is doing, so the failure is diagnosable. + // Its own machine, not the second node's: the anchor timing out used to print the laptop's log. + const ps = (await on(node, `docker ps -a --format '{{.Names}}\t{{.Status}}'`)).out; + const hostLog = (await on(node, `tail -80 /var/log/mesh-host.log`)).out; throw new Error( `${node} never caught up within ${Math.round(withinMs / 1000)}s.\nLast status:\n${last}\n` + - `--- ${NODE} docker ps -a ---\n${ps}\n--- ${NODE} mesh-host.log tail ---\n${hostLog}`); + `--- ${node} docker ps -a ---\n${ps}\n--- ${node} mesh-host.log tail ---\n${hostLog}`); } before(async () => { From 3ce66e83690f10566d5b97477d04298df8240625 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 22 Sep 2026 13:30:01 +0200 Subject: [PATCH 4/5] The two-node bed's copies declare the secrets they take through the environment, as the catalogue does (ADR 0086) --- test/integration/assigned-two-node-db.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/integration/assigned-two-node-db.test.ts b/test/integration/assigned-two-node-db.test.ts index a2fd3e9..a09f03e 100644 --- a/test/integration/assigned-two-node-db.test.ts +++ b/test/integration/assigned-two-node-db.test.ts @@ -232,6 +232,8 @@ test("consumers on a joined node get their databases from the one foundation sto { id: "server", type: "container", name: "baserow", image: pinned("baserow/baserow"), network: "baserow", "env-file": ["/var/lib/baserow/server.env"], + // Declared as the catalogue declares it (novox/hq ADR 0086, issue 041). + "secrets-in-environment": "baserow reads DATABASE_PASSWORD and SECRET_KEY with os.getenv and has no _FILE twin (settings/base.py); not convertible", volumes: ["/services/baserow/data:/baserow/data"], }, { id: "runtime-config", type: "file", path: "/var/lib/mesh/baserow/config.json", mode: "0600", content: "{}\n", merge: "json" }, @@ -276,6 +278,8 @@ test("consumers on a joined node get their databases from the one foundation sto { id: "server", type: "container", name: "letta", image: pinned("letta/letta"), network: "letta", "env-file": ["/var/lib/letta/server.env"], + // Declared as the catalogue declares it (novox/hq ADR 0086, issue 041). + "secrets-in-environment": "the letta image is env-driven and its file-source support could not be verified; the mesh runtime can take its password from config.json (client.ts) \u2014 not yet converted", }, { id: "runtime-config", type: "file", path: "/var/lib/mesh/letta/config.json", mode: "0600", content: "{}\n", merge: "json" }, { id: "runtime-env", type: "file", path: "/var/lib/letta/runtime.env", mode: "0600", content: "MESH_LETTA_PASSWORD=${secret:server-password}\n" }, @@ -292,6 +296,8 @@ test("consumers on a joined node get their databases from the one foundation sto MESH_LETTA_CONFIG_FILE: "/run/config/config.json", }, "env-file": ["/var/lib/letta/runtime.env"], + // Declared as the catalogue declares it (novox/hq ADR 0086, issue 041). + "secrets-in-environment": "the letta image is env-driven and its file-source support could not be verified; the mesh runtime can take its password from config.json (client.ts) \u2014 not yet converted", "restart-on": ["runtime-config"], }, ], From dba95f7e27bb650f6fd7f127704b617327313616 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 22 Sep 2026 13:46:04 +0200 Subject: [PATCH 5/5] The two-node bed's baserow data directory is 0755, as the catalogue's --- test/integration/assigned-two-node-db.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/integration/assigned-two-node-db.test.ts b/test/integration/assigned-two-node-db.test.ts index a09f03e..58e0591 100644 --- a/test/integration/assigned-two-node-db.test.ts +++ b/test/integration/assigned-two-node-db.test.ts @@ -219,7 +219,9 @@ test("consumers on a joined node get their databases from the one foundation sto resources: [ { id: "mesh-state", type: "directory", path: "/var/lib/mesh/baserow", mode: "0700" }, { id: "state", type: "directory", path: "/var/lib/baserow", mode: "0700" }, - { id: "data", type: "directory", path: "/services/baserow/data", mode: "0700", owner: "9999:9999" }, + // 0755, as the image ships it: the cache it runs for itself does so as another user, who + // must be able to reach its own directory under this one (novox/hq 081). + { id: "data", type: "directory", path: "/services/baserow/data", mode: "0755", owner: "9999:9999" }, { id: "server-env", type: "file", path: "/var/lib/baserow/server.env", mode: "0600", content: