adopted-store-cross-node, two-node-db and the three whole-mesh beds each carried a private loader; they drifted. The ace loader never resolved a runtime artifact, so a module the mesh builds travelled unresolved; whole-mesh-full still asked for 'registry' and 'firewall', which the catalogue names distribution and nftables, and swallowed the miss as NOT ASSIGNED. One loader now (novox/hq 04-ISSUES/073).
185 lines
9.5 KiB
TypeScript
185 lines
9.5 KiB
TypeScript
/**
|
|
* CROSS-NODE REACHABILITY OF THE ADOPTED BROKER (novox/hq issue 055).
|
|
*
|
|
* Phase 3 made the foundation's broker the ordinary `lavinmq` module, adopted in place on the
|
|
* control-node ([ADR 0078](../../../hq/02-DECISIONS/0078-the-store-and-broker-are-modules.md)) — one
|
|
* broker, bound mesh-wide. Every existing two-node bed co-locates a provider with its consumer, so
|
|
* the one thing none of them prove is the whole point of "one broker": a consumer on a DIFFERENT
|
|
* node opening the shared broker over the overlay.
|
|
*
|
|
* This bed does exactly that. `anchor` is the control-node: it raises the foundation (mesh-store,
|
|
* mesh-broker, mesh-controller) and adopts `lavinmq` there, so `mesh-broker` is the one broker.
|
|
* `node2` joins the mesh and runs ONLY `amqp-ping`, which `requires: [amqp]` and provides nothing.
|
|
* The grant it gets must resolve to the broker on anchor and reach it by anchor's `.internal`
|
|
* overlay name. The assertions are the proof: amqp-ping's bound file names `anchor.internal`, its
|
|
* vhost exists on anchor's `mesh-broker`, and its container stays up (it connected).
|
|
*
|
|
* MESH_LAB_INCUS='sudo -n incus'
|
|
* MESH_LAB_HOST_BINARY=.../mesh-host/mesh-host
|
|
* MESH_LAB_BUNDLE=.../mesh-host/examples/foundation-first-node.lock
|
|
* MESH_LAB_CATALOG=.../mesh-catalog/modules
|
|
*/
|
|
import { test, before, after } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { existsSync } 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, foundationBundle, catalogueIsPresent, catalogueModule, needsBrokerAccount } from "./harness.ts";
|
|
import type { HeldImage } from "../../src/pinning.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 foundation bundle"
|
|
: catalogueIsPresent();
|
|
|
|
const SCENARIO = "adopted-store-cross-node";
|
|
const NODE = "node2";
|
|
|
|
|
|
let instanceId = "";
|
|
let held: HeldImage[] = [];
|
|
|
|
function quote(s: string): string { return `'${s.replaceAll("'", `'\\''`)}'`; }
|
|
|
|
async function on(machine: string, 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(machine: string, command: string, timeoutMs?: number): Promise<string> {
|
|
const { out, ok } = await on(machine, command, timeoutMs);
|
|
if (!ok) throw new Error(`${machine}: ${command}\n${out}`);
|
|
return out;
|
|
}
|
|
/** The control plane, a container on anchor. */
|
|
async function mesh(command: string, timeoutMs?: number): Promise<string> {
|
|
return must("anchor", `docker exec mesh-controller /mesh-controller ${command}`, timeoutMs);
|
|
}
|
|
function bundleFor(images: HeldImage[]): string { return foundationBundle(bundle, images); }
|
|
|
|
/** The catalogue's manifest as the lab runs it (harness), and whether it needs a broker account. */
|
|
function loadManifest(name: string): { manifest: string; broker: boolean } {
|
|
const manifest = catalogueModule(name, held);
|
|
return { manifest, broker: needsBrokerAccount(manifest) };
|
|
}
|
|
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;
|
|
}
|
|
/** Register a catalog module, issue its broker account if it needs one, and assign it to a node. */
|
|
async function install(name: string, node: string): Promise<void> {
|
|
const { manifest, broker } = loadManifest(name);
|
|
await must("anchor", `printf %s ${quote(manifest)} > /tmp/${name}.json && docker cp /tmp/${name}.json mesh-controller:/${name}.json`);
|
|
await mesh(`module add /${name}.json`);
|
|
if (broker) await mesh(`module issue ${name} --node ${node}`);
|
|
await mesh(`assign ${node} ${name}`);
|
|
}
|
|
|
|
before(async () => {
|
|
if (skip) return;
|
|
const raised = await raise(loadScenario(`scenarios/${SCENARIO}.yml`), { onProgress: (m) => console.log(`raise: ${m}`) });
|
|
instanceId = raised.instanceId;
|
|
held = raised.images;
|
|
|
|
// anchor raises the foundation from its bundle — store, broker and control plane.
|
|
await must("anchor", `cat > /tmp/foundation.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`);
|
|
await must("anchor", `${HOST_PATH} apply /tmp/foundation.lock`, 900_000);
|
|
const up = await must("anchor", `docker ps --format '{{.Names}}'`);
|
|
for (const c of ["mesh-store", "mesh-broker", "mesh-controller"]) {
|
|
assert.match(up, new RegExp(c), `the foundation did not raise ${c}:\n${up}`);
|
|
}
|
|
|
|
// Both machines join the one mesh and run a host so they apply what they are pushed.
|
|
for (const machine of ["anchor", NODE]) {
|
|
await mesh(`node add ${machine}`);
|
|
const token = tokenFrom(await mesh(`token issue --node ${machine}`));
|
|
const said = await must(machine, `${HOST_PATH} enrol --token ${quote(token)}`);
|
|
assert.match(said, new RegExp(`enrolled as ${machine}`), said);
|
|
await must(machine, `nohup ${HOST_PATH} run > /var/log/mesh-host.log 2>&1 & sleep 3`);
|
|
}
|
|
}, { timeout: 2_700_000 });
|
|
|
|
after(async () => {
|
|
if (process.env["MESH_LAB_KEEP"]) { console.log(`MESH_LAB_KEEP set — leaving ${instanceId} standing`); return; }
|
|
if (instanceId) await destroy(instanceId);
|
|
await destroyAll(`${SCENARIO}-`);
|
|
}, { timeout: 900_000 });
|
|
|
|
test("a consumer on a joined node opens the adopted broker on the control-node over the overlay", {
|
|
skip, timeout: 2_400_000,
|
|
}, async () => {
|
|
// The overlay, so the provider's binding address is its `.internal` overlay name, not loopback.
|
|
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");
|
|
await mesh(`assign ${NODE} networking`);
|
|
// The packet filter on the control-node, so the from:mesh rule (not a bare 0.0.0.0 bind) is what
|
|
// admits node2 to the broker — the firewall half of issue 055.
|
|
await install("nftables", "anchor");
|
|
|
|
// Adopt the broker as the lavinmq module ON ANCHOR: same mesh-broker container, plus its
|
|
// provisioner that mints a vhost per consumer.
|
|
await install("lavinmq", "anchor");
|
|
await mesh(`push anchor`, 600_000);
|
|
|
|
// The consumer on the joined node — requires amqp, provides nothing.
|
|
await install("amqp-ping", NODE);
|
|
await mesh(`push ${NODE}`, 900_000);
|
|
|
|
// The consumer minted its grant against anchor's broker only when node2 was composed
|
|
// (a provision secret is a side-effect of composing the CONSUMER). anchor's provisioner
|
|
// learns of a cross-node consumer only when anchor is composed again, so push it once more to
|
|
// mint the vhost. Adding a cross-node consumer means pushing the provider node too (issue 055).
|
|
await mesh(`push anchor`, 600_000);
|
|
|
|
// amqp-ping's container comes up and stays up (a broker it could not reach would crash-loop it).
|
|
const psName = async () => (await on(NODE, `docker ps -a --format '{{.Names}}\t{{.Status}}'`)).out;
|
|
const deadline = Date.now() + 600_000;
|
|
let ps = "";
|
|
while (Date.now() < deadline) {
|
|
ps = await psName();
|
|
if (/amqp-ping\s+Up/.test(ps)) break;
|
|
await new Promise((r) => setTimeout(r, 8_000));
|
|
}
|
|
assert.match(ps, /amqp-ping\s+Up/, `amqp-ping did not come up on ${NODE}:\n${ps}`);
|
|
|
|
// THE PROOF (055): the binding written to the joined consumer names the control-node's overlay
|
|
// address, not a loopback or the consumer's own node.
|
|
const bound = (await on(NODE, `cat /var/lib/amqp-ping/amqp.json 2>&1`)).out;
|
|
assert.match(bound, /anchor\.internal/,
|
|
`the amqp grant does not point at the control-node over the overlay:\n${bound}`);
|
|
|
|
// And it is real on the far side: a vhost for this consumer exists on anchor's mesh-broker. Poll,
|
|
// because the provider's provisioner reconciles the remote consumer's grant a moment after push.
|
|
const vhostDeadline = Date.now() + 120_000;
|
|
let vhosts = "";
|
|
while (Date.now() < vhostDeadline) {
|
|
vhosts = (await on("anchor", `docker exec mesh-broker lavinmqctl list_vhosts 2>&1`)).out;
|
|
if (/amqp-ping|node2/.test(vhosts)) break;
|
|
await new Promise((r) => setTimeout(r, 5_000));
|
|
}
|
|
if (!/amqp-ping|node2/.test(vhosts)) {
|
|
// Diagnostics for a cross-node provisioning gap: what the provider's provisioner was given, and
|
|
// what it and the consumer logged.
|
|
const grants = (await on("anchor", `cat /var/lib/lavinmq-module/grants/mesh.json 2>&1`)).out;
|
|
const provLog = (await on("anchor", `docker logs mesh-lavinmq 2>&1 | tail -30`)).out;
|
|
const pingLog = (await on(NODE, `docker logs amqp-ping 2>&1 | tail -30`)).out;
|
|
assert.fail(`no vhost minted on the control-node's broker for the joined consumer.\n` +
|
|
`vhosts:\n${vhosts}\n\nprovisioner grants (anchor):\n${grants}\n\n` +
|
|
`mesh-lavinmq log:\n${provLog}\n\namqp-ping log:\n${pingLog}`);
|
|
}
|
|
|
|
// Steady a moment, then confirm it did not crash-loop after connecting.
|
|
await new Promise((r) => setTimeout(r, 15_000));
|
|
assert.match(await psName(), /amqp-ping\s+Up/, `amqp-ping did not stay up on ${NODE}`);
|
|
|
|
console.log(`bound: ${bound.trim()}\nvhosts: ${vhosts.trim()}`);
|
|
});
|