Two properties the design claims and neither had been run. A push to a machine that is switched off must not be lost — a machine is disconnected as an ordinary situation, not an exception. The queue is durable and the message persistent, which ought to be enough, but a lost declaration is silent and "ought to be" is not a property. It waits: the machine's host is stopped, the push happens, nothing changes on the machine, and when it listens again it applies what it missed with no second push and nobody saying anything. Getting there found a real fault, now fixed in mesh-host and recorded as 04-ISSUES/011: the machine stopped at the first failing resource, so one broken module blocked every module after it for ever. The evidence was the broker's queues being EMPTY — the declaration had been delivered and read. And removal: two modules assigned, one unassigned, and the machine loses exactly that one's file while keeping the other's — and keeps the store, broker and control plane it raised from its own bundle, which the mesh never declared and must never remove. Two of my own traps recorded in the test, because both cost real time: `pkill -f` matches the shell running it, which kills the connection carrying the command and hangs the caller for ever; and a test that depends on state another test left behind fails for a reason that has nothing to do with what it claims.
343 lines
18 KiB
TypeScript
343 lines
18 KiB
TypeScript
/**
|
|
* A mesh, raised from nothing, joined by two machines, delivering a credential neither the mesh
|
|
* nor the broker can read.
|
|
*
|
|
* Everything before this proves a part. This proves the parts meet — which is the thing the
|
|
* project keeps saying cannot be checked any other way (novox/hq ADR 0001: every fault of
|
|
* 2026-08-22 was found in production because nothing could be stood up locally).
|
|
*
|
|
* It needs a host binary and the substrate bundle:
|
|
*
|
|
* MESH_LAB_HOST_BINARY=.../mesh-host
|
|
* MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock
|
|
*
|
|
* The bundle's image references are rewritten to the ones this scenario's own registry serves.
|
|
* A digest belongs to whatever registry serves it, so a committed bundle names a registry that is
|
|
* not this one — rewriting is what makes it applicable rather than a placeholder to tidy away.
|
|
*/
|
|
|
|
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 = "two-nodes";
|
|
let instanceId = "";
|
|
|
|
function quote(s: string): string {
|
|
return `'${s.replaceAll("'", `'\\''`)}'`;
|
|
}
|
|
|
|
async function on(machine: string, command: string): Promise<{ out: string; ok: boolean }> {
|
|
const { stdout } = await exec(instanceId, machine, [
|
|
"sh", "-c", `${command} 2>&1; echo "__exit=$?"`,
|
|
]);
|
|
const marker = stdout.lastIndexOf("__exit=");
|
|
return { out: stdout.slice(0, marker), ok: Number(stdout.slice(marker + 7).trim()) === 0 };
|
|
}
|
|
|
|
async function must(machine: string, command: string): Promise<string> {
|
|
const { out, ok } = await on(machine, command);
|
|
if (!ok) throw new Error(`${machine}: ${command}\n${out}`);
|
|
return out;
|
|
}
|
|
|
|
/** The control plane, which runs in a container on the first node. */
|
|
async function mesh(command: string): Promise<string> {
|
|
return must("anchor", `docker exec mesh-control /mesh-control ${command}`);
|
|
}
|
|
|
|
/**
|
|
* The bundle, with every image reference pointed at this scenario's registry.
|
|
*
|
|
* Matched by repository rather than by the whole reference, because the address and the digest
|
|
* both differ from whatever the committed bundle names — and a bundle that names the wrong
|
|
* registry is not wrong, it is built for a different target.
|
|
*/
|
|
function bundleFor(images: string[]): string {
|
|
let text = readFileSync(bundle, "utf8");
|
|
for (const pinned of images) {
|
|
const repository = pinned.slice(pinned.indexOf("/") + 1, pinned.indexOf("@"));
|
|
const escaped = repository.replaceAll("/", "\\/").replaceAll(".", "\\.");
|
|
text = text.replaceAll(
|
|
new RegExp(`[A-Za-z0-9_.:-]+\\/${escaped}@sha256:[0-9a-f]+`, "g"),
|
|
pinned,
|
|
);
|
|
}
|
|
return text;
|
|
}
|
|
|
|
/** Take a token out of what `token issue` printed. It is the one base64url blob on its own line. */
|
|
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;
|
|
}
|
|
|
|
before(async () => {
|
|
if (skip) return;
|
|
const raised = await raise(loadScenario(`scenarios/${SCENARIO}.yml`), {});
|
|
instanceId = raised.instanceId;
|
|
|
|
// The first node raises everything from a file rather than from a bundle built into the binary,
|
|
// because the digests are this registry's and are not known until it is up.
|
|
await must("anchor", `cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`);
|
|
await must("anchor", `${HOST_PATH} apply /tmp/substrate.lock`);
|
|
}, { timeout: 1_800_000 });
|
|
|
|
after(async () => {
|
|
if (instanceId) await destroy(instanceId);
|
|
await destroyAll(`${SCENARIO}-`);
|
|
}, { timeout: 600_000 });
|
|
|
|
test("a bare machine becomes a mesh", { skip, timeout: 600_000 }, async () => {
|
|
const running = await must("anchor", `docker ps --format '{{.Names}}'`);
|
|
for (const container of ["mesh-store", "mesh-broker", "mesh-control"]) {
|
|
assert.match(running, new RegExp(container), `${container} is not running`);
|
|
}
|
|
// Answering, not merely up. A container that is running is not a control plane that replies —
|
|
// a distinction this project has already paid for once.
|
|
assert.ok((await mesh("status")).length > 0);
|
|
});
|
|
|
|
test("both machines join it, and the token is all they need", { skip, timeout: 900_000 }, async () => {
|
|
for (const [machine, node] of [["anchor", "anchor"], ["laptop", "laptop"]] as const) {
|
|
await mesh(`node add ${node}`);
|
|
const token = tokenFrom(await mesh(`token issue --node ${node}`));
|
|
// No --name. The token says what the mesh calls the machine, which is the fault this walk
|
|
// found the first time it was run.
|
|
const said = await must(machine, `${HOST_PATH} enrol --token ${quote(token)}`);
|
|
assert.match(said, new RegExp(`enrolled as ${node}`), said);
|
|
assert.match(said, /sealing key/, "no sealing key was generated");
|
|
}
|
|
|
|
const recorded = await must("anchor",
|
|
`docker exec mesh-store psql -U postgres -d inventory -qAt ` +
|
|
`-c "select name from node where sealing_key is not null order by name"`);
|
|
assert.equal(recorded.trim().split("\n").map((l) => l.trim()).sort().join(","), "anchor,laptop",
|
|
"the mesh did not record a sealing key for both machines");
|
|
});
|
|
|
|
test("a credential reaches both ends and the mesh holds neither", { skip, timeout: 900_000 }, async () => {
|
|
// The whole argument, on real machines: the two ends must hold the SAME password, and it must
|
|
// appear nowhere the mesh or the broker could read it.
|
|
await must("anchor", `printf %s '{"module":"postgres","version":"1",` +
|
|
`"provides":[{"name":"database","scope":"mesh"}],"serves":{"database":{"port":5432}},` +
|
|
`"grants":{"database":"/var/lib/mesh-host/grants"},` +
|
|
`"receives":{"database":"/var/lib/mesh-host/grants/mesh.json"},"resources":[]}' > /tmp/pg.json`);
|
|
await must("anchor", `printf %s '{"module":"meshboard","version":"1",` +
|
|
`"requires":["database"],"contributes":{"database":{"name":"meshboard"}},` +
|
|
`"binds":{"database":"/etc/meshboard/database.json"},` +
|
|
`"secrets":{"database":"/etc/meshboard/database.password"},"resources":[]}' > /tmp/app.json`);
|
|
await must("anchor", `docker cp /tmp/pg.json mesh-control:/pg.json`);
|
|
await must("anchor", `docker cp /tmp/app.json mesh-control:/app.json`);
|
|
await mesh("module add /pg.json");
|
|
await mesh("module add /app.json");
|
|
|
|
await mesh("overlay place anchor --hub --endpoint 192.0.2.10:51820 --site lab");
|
|
await mesh("overlay place laptop --site lab");
|
|
for (const node of ["anchor", "laptop"]) await mesh(`assign ${node} networking`);
|
|
await mesh("assign anchor postgres");
|
|
await mesh("assign laptop meshboard");
|
|
|
|
for (const machine of ["anchor", "laptop"]) {
|
|
await must(machine, `nohup ${HOST_PATH} run > /var/log/mesh-host.log 2>&1 & sleep 3`);
|
|
}
|
|
await mesh("push");
|
|
await new Promise((r) => setTimeout(r, 8000));
|
|
|
|
const onConsumer = (await must("laptop", `cat /etc/meshboard/database.password`)).trim();
|
|
const onProvider = (await must("anchor", `cat /var/lib/mesh-host/grants/laptop.secret`)).trim();
|
|
assert.ok(onConsumer.length >= 40, `the consumer's credential is ${onConsumer.length} characters`);
|
|
assert.equal(onConsumer, onProvider,
|
|
"the two ends hold different passwords, so nothing could ever authenticate");
|
|
|
|
// Only the machine it is for may read it.
|
|
assert.match(await must("laptop", `stat -c %a /etc/meshboard/database.password`), /^600/);
|
|
|
|
// And it is nowhere it could have been read on the way. The declaration crossed the broker; the
|
|
// database is the control plane's; the state is what the node reported back.
|
|
for (const [machine, where] of [
|
|
["laptop", "/var/lib/mesh-host/declared.json"],
|
|
["laptop", "/var/lib/mesh-host/state.json"],
|
|
["anchor", "/var/lib/mesh-host/declared.json"],
|
|
] as const) {
|
|
const { out } = await on(machine, `grep -c ${quote(onConsumer)} ${where}`);
|
|
assert.equal(out.trim(), "0", `the password is in ${where} on ${machine}`);
|
|
}
|
|
const inTheMesh = await must("anchor",
|
|
`docker exec mesh-store psql -U postgres -d inventory -qAt ` +
|
|
`-c "select count(*) from secret where for_consumer like '%${onConsumer}%' ` +
|
|
`or for_provider like '%${onConsumer}%'"`);
|
|
assert.equal(inTheMesh.trim(), "0", "the control plane's database holds the password in the clear");
|
|
});
|
|
|
|
test("the consumer is also told where its database is", { skip, timeout: 300_000 }, async () => {
|
|
// A password with no address is not a connection. This is the readable half, which stays
|
|
// readable on purpose — it is the secret half that could not be composed, not this one.
|
|
const told = JSON.parse(await must("laptop", `cat /etc/meshboard/database.json`));
|
|
assert.equal(told.from, "anchor");
|
|
assert.equal(told.at, "anchor.internal");
|
|
assert.equal(told.serves.port, 5432);
|
|
// And the name it resolves to was written by the mesh as well, on this machine.
|
|
assert.match(await must("laptop", `grep anchor.internal /etc/hosts`), /10\.42\.0\.\d+/);
|
|
});
|
|
|
|
test("when a machine cannot do what it was told, the mesh says which and why", { skip, timeout: 900_000 }, async () => {
|
|
// Demonstrated with inserted rows first, which proves the query and not the path. This sends a
|
|
// real machine something it will genuinely fail at, and asks the mesh afterwards.
|
|
//
|
|
// A package that does not exist, because that is a failure of the ordinary kind: the host tries,
|
|
// the package manager says no, and some of the declaration is applied and some is not — which
|
|
// is the situation `status` exists to distinguish from a machine that refused everything.
|
|
await must("anchor", `printf %s '{"module":"impossible","version":"1","resources":[` +
|
|
`{"id":"nothing","type":"package","package":"a-package-that-does-not-exist"}]}' > /tmp/imp.json`);
|
|
await must("anchor", `docker cp /tmp/imp.json mesh-control:/imp.json`);
|
|
await mesh("module add /imp.json");
|
|
await mesh("assign laptop impossible");
|
|
await mesh("push laptop");
|
|
await new Promise((r) => setTimeout(r, 10_000));
|
|
|
|
const said = await mesh("status");
|
|
assert.match(said, /not doing what they were told/, said);
|
|
assert.match(said, /laptop/, said);
|
|
// The machine's own words about the resource that failed, not a summary written at this end.
|
|
assert.match(said, /impossible\.nothing/, `the failing resource is not named:\n${said}`);
|
|
|
|
// And the distinction survives: this machine FAILED, it did not refuse. Refused means it is
|
|
// exactly as it was; failed means it is in a state nobody declared, and they are fixed in
|
|
// different places.
|
|
assert.match(said, /laptop\s+failed/, said);
|
|
|
|
// The other machine is not implicated.
|
|
assert.doesNotMatch(said.split("not heard from")[0] ?? said, /anchor\s+(failed|refused)/,
|
|
"a machine that did as it was told is listed as wrong");
|
|
});
|
|
|
|
test("a declaration waits for a machine that is switched off", { skip, timeout: 900_000 }, async () => {
|
|
// A machine is disconnected as an ordinary situation, not an exception (novox/hq ADR 0004), so
|
|
// a push to one that is not listening must wait rather than vanish. The queue is durable and the
|
|
// message persistent, which ought to be enough — but a lost declaration is silent, and "ought to
|
|
// be" is not a property.
|
|
//
|
|
// The machine is not merely idle here: its host is stopped, so nothing is consuming its queue.
|
|
|
|
// Nothing this test asserts should depend on what another left behind. The machine still has a
|
|
// deliberately-impossible module from the test above, and while that is assigned the mesh never
|
|
// updates its account of what the machine holds — a partial report is not an account, on
|
|
// purpose (novox/hq 04-ISSUES/010).
|
|
await mesh("unassign laptop impossible");
|
|
|
|
// Stop listening, and prove it stopped — a test that pushed to a machine that was still running
|
|
// would pass having checked nothing.
|
|
//
|
|
// By process name, never by matching the command line: `pkill -f` matches the shell running it
|
|
// too, which kills the connection carrying the command and hangs the caller waiting for a reply
|
|
// that will never come. Cost an hour once, in this file.
|
|
await must("laptop", `pkill -x mesh-host || true; sleep 1`);
|
|
const listening = await on("laptop", `pgrep -x mesh-host`);
|
|
assert.equal(listening.ok, false, "the host is still running, so this proves nothing");
|
|
|
|
await must("anchor", `printf %s '{"module":"while-away","version":"1","resources":[` +
|
|
`{"id":"note","type":"file","path":"/etc/mesh-while-away","content":"waited"}]}' > /tmp/away.json`);
|
|
await must("anchor", `docker cp /tmp/away.json mesh-control:/away.json`);
|
|
await mesh("module add /away.json");
|
|
await mesh("assign laptop while-away");
|
|
await mesh("push laptop");
|
|
|
|
// Nothing has happened on the machine, because nothing is there to do it.
|
|
const before = await on("laptop", `test -f /etc/mesh-while-away`);
|
|
assert.equal(before.ok, false, "a machine with no host applied a declaration");
|
|
|
|
// And now it listens again. No second push, and nobody says anything.
|
|
await must("laptop", `nohup ${HOST_PATH} run > /var/log/mesh-host.log 2>&1 & sleep 8`);
|
|
let arrived = false;
|
|
for (let i = 0; i < 20 && !arrived; i++) {
|
|
arrived = (await on("laptop", `test -f /etc/mesh-while-away`)).ok;
|
|
if (!arrived) await new Promise((r) => setTimeout(r, 2000));
|
|
}
|
|
if (!arrived) {
|
|
// Everything needed to tell "the message was never queued" from "the host never read it".
|
|
const log = await on("laptop", `tail -20 /var/log/mesh-host.log`);
|
|
const queues = await on("anchor",
|
|
`docker exec mesh-broker lavinmqctl list_queues name messages 2>&1 | head -10`);
|
|
const owned = await on("anchor",
|
|
`docker exec mesh-store psql -U postgres -d inventory -qAt -c "select name, outcome from node_report r join node n on n.id=r.node"`);
|
|
assert.fail(`a declaration sent to a switched-off machine was lost\n` +
|
|
`--- the host's log ---\n${log.out}\n--- the broker's queues ---\n${queues.out}\n` +
|
|
`--- what each machine last did ---\n${owned.out}`);
|
|
}
|
|
assert.equal((await must("laptop", `cat /etc/mesh-while-away`)).trim(), "waited");
|
|
|
|
// And the mesh's account of what that machine holds catches up too, or a later declaration
|
|
// would tell it to remove what it has just been given.
|
|
await new Promise((r) => setTimeout(r, 4000));
|
|
const owned = await must("anchor",
|
|
`docker exec mesh-store psql -U postgres -d inventory -qAt ` +
|
|
`-c "select owned from node where name = 'laptop'"`);
|
|
assert.match(owned, /while-away\.note/, `the mesh does not know the machine holds it: ${owned}`);
|
|
});
|
|
|
|
test("unassigning takes away exactly what it should", { skip, timeout: 900_000 }, async () => {
|
|
// Removal is the half nobody tests. The mesh takes away what IT declared and no longer declares,
|
|
// and never what the machine raised for itself from its bundle — which is the fault that
|
|
// destroyed a substrate once (novox/hq 04-ISSUES/010).
|
|
//
|
|
// Two modules, so the test can tell "removed the right one" from "removed everything".
|
|
for (const [name, path] of [["kept", "/etc/mesh-kept"], ["going", "/etc/mesh-going"]] as const) {
|
|
await must("anchor", `printf %s '{"module":"${name}","version":"1","resources":[` +
|
|
`{"id":"note","type":"file","path":"${path}","content":"${name}"}]}' > /tmp/${name}.json`);
|
|
await must("anchor", `docker cp /tmp/${name}.json mesh-control:/${name}.json`);
|
|
await mesh(`module add /${name}.json`);
|
|
await mesh(`assign anchor ${name}`);
|
|
}
|
|
await mesh("push anchor");
|
|
await new Promise((r) => setTimeout(r, 6000));
|
|
assert.ok((await on("anchor", `test -f /etc/mesh-kept`)).ok, "the first module did not arrive");
|
|
assert.ok((await on("anchor", `test -f /etc/mesh-going`)).ok, "the second module did not arrive");
|
|
|
|
await mesh("unassign anchor going");
|
|
await mesh("push anchor");
|
|
await new Promise((r) => setTimeout(r, 6000));
|
|
|
|
assert.equal((await on("anchor", `test -f /etc/mesh-going`)).ok, false,
|
|
"an unassigned module's file is still there");
|
|
assert.ok((await on("anchor", `test -f /etc/mesh-kept`)).ok,
|
|
"unassigning one module took another one's file with it");
|
|
|
|
// And the substrate this machine raised from its own bundle is untouched. It was not declared by
|
|
// the mesh, so the mesh must never remove it — the machine would take its own control plane
|
|
// away, which is exactly what happened before origins existed.
|
|
const running = await must("anchor", `docker ps --format '{{.Names}}'`);
|
|
for (const container of ["mesh-store", "mesh-broker", "mesh-control"]) {
|
|
assert.match(running, new RegExp(container),
|
|
`${container} was removed by a declaration that never declared it`);
|
|
}
|
|
});
|
|
|
|
test("a machine keeps what it was given when the mesh says nothing about it", { skip, timeout: 600_000 }, async () => {
|
|
// The other direction of the same rule. A node that is sent a declaration mentioning none of its
|
|
// private network must not lose it: the network came from a module that is still assigned, and
|
|
// "not in this message" is not "no longer wanted".
|
|
assert.ok((await on("laptop", `test -f /etc/wireguard/mesh0.conf`)).ok,
|
|
"the private network's configuration is gone");
|
|
assert.ok((await on("laptop", `grep -q anchor.internal /etc/hosts`)).ok,
|
|
"the mesh's names are gone");
|
|
});
|