diff --git a/scenarios/two-nodes.yml b/scenarios/two-nodes.yml index cb62fdf..bbfeb6f 100644 --- a/scenarios/two-nodes.yml +++ b/scenarios/two-nodes.yml @@ -47,6 +47,10 @@ images: - mesh-provision-postgres:development # And the proxy, which is what turns a route grant into traffic actually arriving. - mesh-route-proxy:development + # And the cache, with its provisioner — the third provision after a database and a bucket, + # and the first whose tenancy is a keyspace rather than a namespace something else enforces. + - redis:7-alpine + - mesh-provision-redis:development # And the object store's provisioner, so the module describing it can be planned. Without it # that module still names an image nothing serves, and planning it is refused — correctly. - mesh-provision-objectstore:development diff --git a/src/rebuild.ts b/src/rebuild.ts index 245d093..c945348 100644 --- a/src/rebuild.ts +++ b/src/rebuild.ts @@ -59,7 +59,8 @@ export function planned(env: NodeJS.ProcessEnv = process.env): Build[] { builds.push({ what: "images", in: control, - argv: ["make", "image", "builder-image", "provisioner-image", "objectstore-image", "proxy-image"], + argv: ["make", "image", "builder-image", "provisioner-image", "objectstore-image", + "redis-provisioner-image", "proxy-image"], }); const builder = env["MESH_LAB_BUILDER"]; if (builder) { diff --git a/test/integration/mesh.test.ts b/test/integration/mesh.test.ts index 6b91734..bde9368 100644 --- a/test/integration/mesh.test.ts +++ b/test/integration/mesh.test.ts @@ -1969,10 +1969,20 @@ test("the forge runs, on a database the mesh gave it", { skip, timeout: 900_000 "the login exists and the database it owns does not"); // And the forge itself, answering. Not that its container exists — that it serves. + // + // On the port the mesh assigned, not the one the module declared (novox/hq ADR 0038): the + // module says 3000 and the machine publishes wherever the mesh put it. Read from the plan, + // because the plan is the same composition a push sends. + const planned = await mesh("plan anchor --json", 120_000); + const mapping = (JSON.parse(planned.slice(planned.indexOf("{"))).resources as any[]) + .find((r) => r.id === "gitea.server")?.ports + ?.map(String).find((p: string) => p.endsWith(":3000")); + assert.ok(mapping, "the plan does not say where the machine publishes the forge"); + const at = mapping.split(":")[0]; let answered = false; let said = { out: "", ok: false }; for (let i = 0; i < 60 && !answered; i++) { - said = await on("anchor", `curl -sf -o /dev/null -w '%{http_code}' --max-time 5 http://127.0.0.1:3000/`, 30_000); + said = await on("anchor", `curl -sf -o /dev/null -w '%{http_code}' --max-time 5 http://127.0.0.1:${at}/`, 30_000); answered = said.out.trim().startsWith("2") || said.out.trim() === "303"; if (!answered) await new Promise((r) => setTimeout(r, 5000)); } @@ -1990,3 +2000,80 @@ test("the forge runs, on a database the mesh gave it", { skip, timeout: 900_000 await mesh("unassign anchor postgres"); await mesh("push anchor", 300_000); }); + +test("a consumer's cache grant means exactly its own keys", { skip, timeout: 600_000 }, async (t) => { + // The third provision after a database and a bucket, and the first whose tenancy is enforced + // by the store's own ACL rather than by separate namespaces: every consumer shares one + // keyspace, so the grant is a pattern — and the test is that the pattern means what the + // manifest said, in both directions. + const raw = readFileSync(`${process.env["MESH_LAB_MODULES"]}/redis.json`, "utf8"); + const pinned = pinnedInto(raw, stocked); + assert.deepEqual(stillUnpinned(pinned), [], + "redis still names an image nothing serves, so it could not start"); + await must("anchor", `printf %s ${quote(pinned)} > /run-redis.json`); + await must("anchor", `docker cp /run-redis.json mesh-control:/run-redis.json`); + await mesh("module add /run-redis.json"); + + // A consumer with no container: what is under test is the credential's reach, and files on the + // machine are enough to prove it — the same reduction the first credential test makes. + await must("anchor", `printf %s '{"module":"cachetest","version":"1",` + + `"requires":["redis-cache"],` + + `"contributes":{"redis-cache":{"prefix":"cachetest"}},` + + `"binds":{"redis-cache":"/var/lib/cachetest/cache.json"},` + + `"secrets":{"redis-cache":"/var/lib/cachetest/cache.secret"},` + + `"resources":[{"id":"state","type":"directory","path":"/var/lib/cachetest","mode":"0700"}]}' ` + + `> /cachetest.json`); + await must("anchor", `docker cp /cachetest.json mesh-control:/cachetest.json`); + await mesh("module add /cachetest.json"); + + await mesh("assign anchor redis"); + await mesh("assign anchor cachetest"); + await mesh("push anchor", 300_000); + await settled("anchor"); + + t.after(async () => { + for (const name of ["cachetest", "redis"]) { + await mesh(`unassign anchor ${name}`).catch(() => {}); + } + await mesh("push anchor", 300_000).catch(() => {}); + }); + + // What the mesh told each end. The consumer's user name comes from its binding; the user's + // password from the sealed file beside it — both written by the host, neither invented here. + const bound = JSON.parse(await must("anchor", `cat /var/lib/cachetest/cache.json`)); + const user = bound.as; + assert.ok(user?.startsWith("mesh_"), `the binding does not carry a usable user: ${user}`); + const secret = (await must("anchor", `cat /var/lib/cachetest/cache.secret`)).trim(); + + // The provisioner has to have run before anything can authenticate. Waited for via the store + // itself: the user list, asked with the server's own password, which the conf file the host + // wrote holds on the machine. + const admin = (await must("anchor", + `awk '/^requirepass/ {print $2}' /var/lib/redis-module/redis.conf`)).trim(); + let granted = false; + for (let i = 0; i < 40 && !granted; i++) { + const users = (await on("anchor", + `docker exec redis redis-cli --no-auth-warning -a ${quote(admin)} ACL USERS`)).out; + granted = users.includes(user); + if (!granted) await new Promise((r) => setTimeout(r, 3000)); + } + assert.ok(granted, `no user was created for the consumer: +` + + `${(await on("anchor", "docker logs mesh-provision-redis 2>&1 | tail -20")).out}`); + + const asConsumer = (command: string) => + on("anchor", `docker exec redis redis-cli --no-auth-warning ` + + `--user ${quote(user)} --pass ${quote(secret)} ${command}`); + + // Its own keys: usable. + assert.match((await asConsumer("SET cachetest:proof yes")).out, /OK/, + "the consumer cannot write under the prefix it was granted"); + assert.match((await asConsumer("GET cachetest:proof")).out, /yes/, + "the consumer cannot read back what it wrote"); + + // Anyone else's: refused by the store itself, which is the entire point of the grant. + assert.match((await asConsumer("SET other:proof no")).out, /NOPERM|no permissions/i, + "the consumer wrote outside its prefix — the grant means more than the manifest said"); + assert.match((await asConsumer("FLUSHALL")).out, /NOPERM|no permissions/i, + "the consumer can flush the store, which no tenant may"); +}); diff --git a/test/rebuild.test.ts b/test/rebuild.test.ts index 790d7de..cffe81b 100644 --- a/test/rebuild.test.ts +++ b/test/rebuild.test.ts @@ -32,7 +32,8 @@ test("every image the lab runs is rebuilt, not only the control plane's", () => const images = builds.find((b) => b.what === "images"); assert.ok(images, "no image build at all"); for (const target of [ - "image", "builder-image", "provisioner-image", "objectstore-image", "proxy-image", + "image", "builder-image", "provisioner-image", "objectstore-image", + "redis-provisioner-image", "proxy-image", ]) { assert.ok(images.argv.includes(target), `${target} is never built, so the lab runs a stale one`); }