Files
mesh-lab/test/integration/minio-grant-end-to-end.test.ts
T
jschoubben b7ba7534af e2e: the whole grant for an S3 bucket (skipped — blocked on hq issue 010)
Mirrors the postgres bed for minio: a provider (runtime carries mc) + a consumer
requiring s3-bucket, proving the consumer reaches its bucket with the access key and
secret the mesh delivered. It surfaced a real limit: the mesh derives `as` =
mesh_<node>_<module> (22 chars), and an S3 access key is capped at 20, so minio refuses
the service account. The test is correct and skipped pending 04-ISSUES/010, not worked
around.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-05 01:58:01 +02:00

261 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The whole grant for an S3 bucket, mesh-driven — novox/hq ADR 0052/0053, the minio case.
*
* The postgres bed proves the provider/consumer contract for a database. This proves it for object
* storage, on a provider whose code drives the `mc` CLI (so the runtime image carries it): minio is
* assigned, a consumer that requires s3-bucket is assigned, and the mesh mints one secret key, sealing
* a copy to each end. minio's provisioner — reading only the mesh's contributions — creates a bucket
* and a service account under the access key the mesh derived, with the secret it minted. The proof is
* the consumer reaching its bucket with the access key and secret the mesh delivered it. Nothing is
* placed by the test.
*
* MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock
* scripts/build-module-runtime.sh minio builds mesh-runtime-minio:development (with mc), which
* scenarios/minio-node.yml stocks. minio/minio:latest must be in the local daemon.
*/
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"] ?? "";
// Blocked on novox/hq 04-ISSUES/010: the mesh derives `as` = mesh_<node>_<module> (e.g.
// `mesh_anchor_bucketuser`, 22 chars), and an S3 access key is capped at 20 — minio refuses to
// create the service account under it. This test is correct and will pass once the mesh's login
// fits S3's identifier rules; skipped (before hook and test both) until that is decided, rather than
// made to pass by working around the derivation. Remove `blocked ||` to run it once 010 is fixed.
const blocked = "blocked on 04-ISSUES/010 — the mesh's `as` exceeds minio's S3 access-key limit (3–20)";
const skip = blocked
|| (!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 = "minio-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;
}
/** Same rule minio's client uses to name a bucket for a consumer — recomputed so the test knows it. */
function bucketFor(as: string): string {
const name = as.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63);
return name.length >= 3 ? name : `mesh-${name}`;
}
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 an S3 bucket, and the credential it delivers reaches it", {
skip, timeout: 900_000,
}, async () => {
// The PROVIDER: minio in its committed shape — server and a broker-bound runtime (carrying mc) on
// the private minio network, the runtime running the provisioner. No published port on this
// single-node bed; the consumer reaches minio over the private network by name.
const minioManifest = JSON.stringify({
module: "minio",
version: "1",
provides: [{ name: "s3-bucket", scope: "mesh" }],
serves: { "s3-bucket": { scheme: "http", region: "us-east-1" } },
emits: ["module.minio.bucket.created", "module.minio.bucket.removed"],
receives: { "s3-bucket": "/var/lib/minio/grants/mesh.json" },
grants: { "s3-bucket": "/var/lib/minio/grants" },
"own-secrets": { root: "/var/lib/minio/root.secret", broker: "/var/lib/mesh/minio/broker" },
resources: [
{ id: "mesh-state", type: "directory", path: "/var/lib/mesh/minio", mode: "0700" },
{ id: "state", type: "directory", path: "/var/lib/minio", mode: "0700" },
{ id: "grants", type: "directory", path: "/var/lib/minio/grants", mode: "0700" },
{ id: "root-env", type: "file", path: "/var/lib/minio/root.env", mode: "0600", content: "MINIO_ROOT_USER=meshroot\nMINIO_ROOT_PASSWORD=${secret:root}\n" },
{ id: "data", type: "directory", path: "/services/minio/data/data1-1", mode: "0700" },
{ id: "net", type: "network", name: "minio" },
{
id: "server", type: "container", name: "minio", image: pinned("minio/minio"), network: "minio",
args: ["server", "/data", "--console-address", ":9001"],
"env-file": ["/var/lib/minio/root.env"],
volumes: ["/services/minio/data/data1-1:/data"],
},
{
id: "runtime", type: "container", name: "mesh-minio", image: pinned("mesh-runtime-minio"),
network: "minio",
volumes: [
"/var/lib/mesh/minio/broker:/run/secrets/broker:ro",
"/var/lib/minio/grants:/var/lib/minio/grants:ro",
"/var/lib/minio/root.secret:/run/secrets/root:ro",
],
env: {
MESH_MINIO_ENDPOINT: "http://minio:9000",
MESH_MINIO_ROOT_USER: "meshroot",
MESH_MINIO_ROOT_PASSWORD_FILE: "/run/secrets/root",
MESH_BROKER_FILE: "/run/secrets/broker",
MESH_RECEIVES: "/var/lib/minio/grants/mesh.json",
},
},
],
});
const consumerManifest = JSON.stringify({
module: "bucketuser",
version: "1",
requires: ["s3-bucket"],
contributes: { "s3-bucket": { name: "bucketuser" } },
binds: { "s3-bucket": "/var/lib/bucketuser/s3.json" },
secrets: { "s3-bucket": "/var/lib/bucketuser/s3.secret" },
resources: [{ id: "state", type: "directory", path: "/var/lib/bucketuser", mode: "0700" }],
});
await must(`printf %s ${quote(minioManifest)} > /tmp/minio.json && docker cp /tmp/minio.json mesh-control:/minio.json`);
await mesh("module add /minio.json");
await mesh(`module issue minio --node ${MACHINE}`);
await mesh(`assign ${MACHINE} minio`);
await must(`printf %s ${quote(consumerManifest)} > /tmp/bucketuser.json && docker cp /tmp/bucketuser.json mesh-control:/bucketuser.json`);
await mesh("module add /bucketuser.json");
await mesh(`assign ${MACHINE} bucketuser`);
await mesh(`push ${MACHINE}`);
await settled();
// The mesh delivered the consumer its bound file and its unsealed secret.
let boundRaw = "";
const untilBound = Date.now() + 60_000;
while (Date.now() < untilBound) {
const got = await on(`cat /var/lib/bucketuser/s3.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 bucket:\n${boundRaw}`);
const bound = JSON.parse(boundRaw) as { as: string; provision: string };
assert.equal(bound.provision, "s3-bucket");
const accessKey = bound.as;
const secretKey = (await must(`cat /var/lib/bucketuser/s3.secret`)).trim();
assert.ok(accessKey && secretKey, `the consumer's access key or secret was empty (as=${accessKey})`);
const bucket = bucketFor(accessKey);
// THE PROOF: reach the bucket as the consumer, with the access key and secret the mesh delivered
// it. mc listing the consumer's own bucket means the service account, the bucket, and the secret all
// line up across the two ends. A provisioner that set a different secret answers "Access Denied".
const probe =
`mc alias set probe http://minio:9000 ${quote(accessKey)} ${quote(secretKey)} >/dev/null 2>&1 && ` +
`mc ls probe/${quote(bucket)}/`;
let out = { out: "", ok: false };
const untilReach = Date.now() + 90_000;
while (Date.now() < untilReach) {
out = await on(`docker exec mesh-minio sh -c ${quote(probe)} 2>&1`);
if (out.ok) break;
if (/denied/i.test(out.out)) break; // fast-fail: the credential is wrong
await new Promise((r) => setTimeout(r, 3000));
}
assert.doesNotMatch(out.out, /denied/i,
`the consumer could not reach its bucket with the mesh's secret — the two ends do not agree:\n${out.out}`);
assert.ok(out.ok,
`the consumer could not list its granted bucket ${bucket} as ${accessKey}:\n${out.out}\n---\n${(await on(`docker logs mesh-minio 2>&1 | tail -30`)).out}`);
});