e2e: the whole grant, mesh-driven — a consumer authenticates with what the mesh delivered
Assigns a redis provider and a module that requires redis-cache; the mesh mints one password, seals a copy to each end, writes redis its contributions and the consumer its bound file, and the host unseals each side. redis's provisioner creates the ACL user under the mesh's login with the mesh's password, and the consumer's delivered credential authenticates (PONG). Nothing is placed by the test — the provider/consumer contract (ADR 0053) working as one thing, no shared key anywhere. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* The whole grant, mesh-driven end to end — novox/hq ADR 0053 with nothing hand-written.
|
||||
*
|
||||
* The earlier provider tests put the contributions file and the password on disk by hand, standing
|
||||
* in for the control plane. This one does not: a provider (redis) and a consumer (a module that
|
||||
* `requires` redis-cache) are both assigned, and the *mesh* mints the password, seals a copy to each
|
||||
* end, writes redis its contributions file and the consumer its bound file, and the host unseals
|
||||
* each side's secret. redis's provisioner — reading only what the mesh wrote — creates the ACL user
|
||||
* under the login the mesh derived, with the password the mesh minted. The proof is the consumer's
|
||||
* end: the credential the mesh delivered *it* authenticates against the login redis created for it.
|
||||
* Mint on one side and create on the other agreeing, with no shared key and nothing placed by the
|
||||
* test, is the entire provider/consumer contract working as one thing.
|
||||
*
|
||||
* MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock
|
||||
* scripts/build-module-runtime.sh redis builds mesh-runtime-redis:development, which
|
||||
* scenarios/redis-node.yml stocks.
|
||||
*/
|
||||
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
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 } from "./harness.ts";
|
||||
|
||||
const capability = await labIsUsable();
|
||||
const binary = hostBinaryPath();
|
||||
const bundle = process.env["MESH_LAB_BUNDLE"] ?? "";
|
||||
|
||||
const skip = !capability.usable
|
||||
? `lab not usable: ${capability.why}`
|
||||
: !binary || !existsSync(binary)
|
||||
? "MESH_LAB_HOST_BINARY is not set to a built mesh-host"
|
||||
: !bundle || !existsSync(bundle)
|
||||
? "MESH_LAB_BUNDLE is not set to a substrate bundle (mesh-host examples/)"
|
||||
: false;
|
||||
|
||||
const SCENARIO = "redis-node";
|
||||
const MACHINE = "anchor";
|
||||
|
||||
let instanceId = "";
|
||||
let stocked: string[] = [];
|
||||
|
||||
function quote(s: string): string {
|
||||
return `'${s.replaceAll("'", `'\\''`)}'`;
|
||||
}
|
||||
|
||||
async function on(command: string, timeoutMs?: number): Promise<{ out: string; ok: boolean }> {
|
||||
const { stdout } = await exec(instanceId, MACHINE, [
|
||||
"sh", "-c", `exec 2>&1\n${command}\necho "__exit=$?"`,
|
||||
], timeoutMs);
|
||||
const marker = stdout.lastIndexOf("__exit=");
|
||||
if (marker < 0) return { out: stdout, ok: false };
|
||||
return { out: stdout.slice(0, marker), ok: stdout.slice(marker + 7).trim() === "0" };
|
||||
}
|
||||
|
||||
async function must(command: string, timeoutMs?: number): Promise<string> {
|
||||
const { out, ok } = await on(command, timeoutMs);
|
||||
if (!ok) throw new Error(`${MACHINE}: ${command}\n${out}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function mesh(command: string, timeoutMs?: number): Promise<string> {
|
||||
return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs);
|
||||
}
|
||||
|
||||
function pinned(repository: string): string {
|
||||
const found = stocked.find((r) => r.slice(r.indexOf("/") + 1, r.indexOf("@")) === repository);
|
||||
assert.ok(found, `the scenario stocks no ${repository}; it serves ${stocked.join(", ")}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
function bundleFor(images: string[]): string {
|
||||
let text = readFileSync(bundle, "utf8");
|
||||
for (const ref of images) {
|
||||
const repository = ref.slice(ref.indexOf("/") + 1, ref.indexOf("@"));
|
||||
const escaped = repository.replaceAll("/", "\\/").replaceAll(".", "\\.");
|
||||
text = text.replaceAll(new RegExp(`[A-Za-z0-9_.:-]+\\/${escaped}@sha256:[0-9a-f]+`, "g"), ref);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function tokenFrom(said: string): string {
|
||||
const found = said.split("\n").map((l) => l.trim()).find((l) => l.length > 100 && !l.includes(" "));
|
||||
assert.ok(found, `no token in:\n${said}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
async function settled(withinMs = 480_000): Promise<void> {
|
||||
const until = Date.now() + withinMs;
|
||||
let last = "";
|
||||
while (Date.now() < until) {
|
||||
const asked = await on(`docker exec mesh-control /mesh-control status --json`);
|
||||
if (asked.ok) {
|
||||
try {
|
||||
const state = JSON.parse(asked.out) as {
|
||||
wrong: { node: string; outcome: string }[];
|
||||
waiting: { node: string }[];
|
||||
reported: { node: string; outcome: string; current: boolean }[];
|
||||
};
|
||||
const bad = state.wrong.find((w) => w.node === MACHINE);
|
||||
if (bad) throw new Error(`${MACHINE} did not apply what it was sent: ${bad.outcome}\n${asked.out}`);
|
||||
const word = state.reported.find((r) => r.node === MACHINE);
|
||||
if (!state.waiting.some((w) => w.node === MACHINE) && word?.outcome === "applied" && word.current) return;
|
||||
last = asked.out;
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.includes("did not apply")) throw err;
|
||||
last = asked.out;
|
||||
}
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
}
|
||||
throw new Error(`${MACHINE} never caught up within ${Math.round(withinMs / 1000)}s. Last:\n${last}`);
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
if (skip) return;
|
||||
|
||||
const raised = await raise(loadScenario(`scenarios/${SCENARIO}.yml`), {
|
||||
onProgress: (m) => console.log(`raise: ${m}`),
|
||||
});
|
||||
instanceId = raised.instanceId;
|
||||
stocked = raised.images;
|
||||
|
||||
await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`);
|
||||
await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000);
|
||||
const up = await must(`docker ps --format '{{.Names}}'`);
|
||||
for (const c of ["mesh-store", "mesh-broker", "mesh-control"]) {
|
||||
assert.match(up, new RegExp(c), `the substrate did not raise ${c}:\n${up}`);
|
||||
}
|
||||
|
||||
await mesh(`node add ${MACHINE}`);
|
||||
const token = tokenFrom(await mesh(`token issue --node ${MACHINE}`));
|
||||
await must(`${HOST_PATH} enrol --token ${quote(token)}`);
|
||||
await must(`nohup ${HOST_PATH} run > /var/log/mesh-host.log 2>&1 & sleep 3`);
|
||||
}, { timeout: 1_800_000 });
|
||||
|
||||
after(async () => {
|
||||
if (instanceId) await destroy(instanceId);
|
||||
await destroyAll(`${SCENARIO}-`);
|
||||
}, { timeout: 600_000 });
|
||||
|
||||
test("the mesh grants a consumer redis's cache, and the credential it delivers authenticates", {
|
||||
skip, timeout: 900_000,
|
||||
}, async () => {
|
||||
// The PROVIDER: redis in its committed shape — server and a broker-bound runtime on the private
|
||||
// redis network, the runtime running the provisioner.
|
||||
const redisManifest = JSON.stringify({
|
||||
module: "redis",
|
||||
version: "1",
|
||||
provides: [{ name: "redis-cache", scope: "mesh" }],
|
||||
serves: { "redis-cache": {} },
|
||||
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: "net", type: "network", name: "redis" },
|
||||
{
|
||||
id: "server", type: "container", name: "redis", image: pinned("redis"), network: "redis",
|
||||
ports: ["6379"],
|
||||
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: "redis",
|
||||
volumes: [
|
||||
"/var/lib/mesh/redis/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/redis-module/grants:/var/lib/redis-module/grants:ro",
|
||||
"/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",
|
||||
MESH_PROVISION_REDIS: "redis:6379",
|
||||
MESH_PROVISION_PASSWORD_FILE: "/run/secrets/default",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// The CONSUMER: a module that requires redis-cache and no more. It runs no code here — the mesh
|
||||
// delivers it a bound file (where redis is, and the login to present) and its sealed password,
|
||||
// which the host unseals onto the machine. That delivery is exactly what a real consumer reads.
|
||||
const consumerManifest = JSON.stringify({
|
||||
module: "cacheuser",
|
||||
version: "1",
|
||||
requires: ["redis-cache"],
|
||||
// `contributes` (not just `requires`) is what makes a consumer *ask* — the grant forms from a
|
||||
// contribution. It must be non-empty; redis's provisioner ignores the value (it uses the login
|
||||
// the mesh derives), so the name is only what marks this module as wanting a cache.
|
||||
contributes: { "redis-cache": { name: "cacheuser" } },
|
||||
binds: { "redis-cache": "/var/lib/cacheuser/redis.json" },
|
||||
secrets: { "redis-cache": "/var/lib/cacheuser/redis.secret" },
|
||||
resources: [{ id: "state", type: "directory", path: "/var/lib/cacheuser", mode: "0700" }],
|
||||
});
|
||||
|
||||
await must(`printf %s ${quote(redisManifest)} > /tmp/redis.json && docker cp /tmp/redis.json mesh-control:/redis.json`);
|
||||
await mesh("module add /redis.json");
|
||||
await mesh(`module issue redis --node ${MACHINE}`);
|
||||
await mesh(`assign ${MACHINE} redis`);
|
||||
|
||||
await must(`printf %s ${quote(consumerManifest)} > /tmp/cacheuser.json && docker cp /tmp/cacheuser.json mesh-control:/cacheuser.json`);
|
||||
await mesh("module add /cacheuser.json");
|
||||
await mesh(`assign ${MACHINE} cacheuser`);
|
||||
|
||||
await mesh(`push ${MACHINE}`);
|
||||
await settled();
|
||||
|
||||
// The mesh matched the two and wrote redis its contributions file — the test wrote nothing here.
|
||||
const contributions = await must(`cat /var/lib/redis-module/grants/mesh.json`);
|
||||
assert.match(contributions, /"as"/, `the mesh did not write redis a contributions file:\n${contributions}`);
|
||||
|
||||
// The mesh delivered the consumer its bound file and its unsealed password.
|
||||
let boundRaw = "";
|
||||
const untilBound = Date.now() + 60_000;
|
||||
while (Date.now() < untilBound) {
|
||||
const got = await on(`cat /var/lib/cacheuser/redis.json 2>/dev/null`);
|
||||
if (got.ok && /"as"/.test(got.out)) { boundRaw = got.out; break; }
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
}
|
||||
assert.match(boundRaw, /"as"/, `the consumer was never told about its cache:\n${boundRaw}`);
|
||||
const bound = JSON.parse(boundRaw) as { as: string; from: string; provision: string };
|
||||
assert.equal(bound.provision, "redis-cache");
|
||||
assert.ok(bound.from, `the consumer was not told which node serves its cache:\n${boundRaw}`);
|
||||
const as = bound.as;
|
||||
const password = (await must(`cat /var/lib/cacheuser/redis.secret`)).trim();
|
||||
assert.ok(as && password, `the consumer's login or password was empty (as=${as})`);
|
||||
|
||||
// redis's provisioner, reading only the mesh's contributions, created the ACL user. Wait for it.
|
||||
const adminPw = await must(`cat /var/lib/redis-module/default.secret`);
|
||||
let acl = "";
|
||||
const untilAcl = Date.now() + 60_000;
|
||||
while (Date.now() < untilAcl) {
|
||||
acl = (await on(`docker exec redis redis-cli -a ${quote(adminPw)} --no-auth-warning ACL LIST 2>/dev/null`)).out;
|
||||
if (acl.includes(as)) break;
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
}
|
||||
assert.match(acl, new RegExp(as.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
|
||||
`redis never created the login the mesh granted (${as}):\n${(await on(`docker logs mesh-redis 2>&1 | tail -30`)).out}\n---\n${acl}`);
|
||||
|
||||
// THE PROOF, from the consumer's side: the credential the mesh delivered *it* — the login from its
|
||||
// bound file, the password from its secret — authenticates against the login redis created. Mint
|
||||
// and create agreeing across the two ends, with no shared key and nothing the test placed.
|
||||
const authed = await on(`docker exec redis redis-cli --user ${quote(as)} --pass ${quote(password)} --no-auth-warning PING 2>&1`);
|
||||
assert.doesNotMatch(authed.out, /WRONGPASS|NOPERM|no password/i,
|
||||
`the consumer's mesh-delivered credential did not authenticate — the two ends do not agree:\n${authed.out}`);
|
||||
assert.match(authed.out, /PONG/, `expected PONG authenticating as the granted consumer:\n${authed.out}`);
|
||||
});
|
||||
Reference in New Issue
Block a user