Files
mesh-lab/test/integration/genesis-keeps-its-bus.test.ts
T
jochen abc022eece
mesh/merge-gate head abc022eece61 merged into trunk 383176a7eab3 — pass: the change touches no module of the mesh's graph
mesh/repo-check head abc022eece61 merged into trunk 383176a7eab3 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/delivery delivered
Name the build agent where genesis needs the module that builds (hq issue 596)
The beds checked the catalogue for builder/module.json before running genesis and the mesh beds held
'builder' among a raised mesh's modules; the catalogue retired builder for build-agent on 2026-10-03
(ADR 0190), so every genesis bed stopped before the installer ran.
2026-10-13 02:29:19 +02:00

270 lines
14 KiB
TypeScript

/**
* GENESIS KEEPS ITS BUS through the broker step (novox/hq ADR 0333 §5, issue 590).
*
* The installer raises the bus from its bundle and, at its broker step (step 18 of 24), adopts it as the
* catalogue's `nats` module. Until ADR 0333 the bundle raised the bus as `mesh-broker` on docker volumes,
* and the module keeps it as `mesh-broker-nats` on directories of the machine: adopting it would have raised
* a second bus beside the first, so the installer refused there and genesis stopped. ADR 0333 has the bundle
* raise the bus where the module keeps it, so the adoption replaces the server's image once and keeps
* everything else.
*
* The bed runs the installer from bare twice. The first run is given a catalogue without the `nats` module,
* so it stops at the broker step by name, before anything is registered — the moment just before the step,
* with everything genesis wrote on the bus by then. The bed reads the bus there: its containers, and every
* stream holding anything. The second run, given the whole catalogue, finds the steps before done and goes
* on through the broker step. The bed reads the bus again, and passes only when (ADR 0333 §5):
*
* - exactly one bus container runs, and it is mesh-broker-nats, on the module's image now;
* - every stream that held something before the step is still there, the same stream, as long or longer;
* - the mesh has heard from the first node since.
*
* The judgement is test/integration/bus-kept.ts, held without a lab by test/bus-kept.test.ts.
*
* **A bed that could not reach the step says so and does not fail**: a first run that stopped earlier than
* the broker step — a pull the internet refused, a build that broke — proves nothing about the step, and the
* test is skipped with the reason. A second run that stops AT the broker step is the fault, and fails. One
* that stops after it has crossed it: what it stopped on is reported, and the bus is judged.
*
* MESH_LAB_HOST_BINARY=<mesh-host>/mesh-host MESH_LAB_BOOTSTRAP_BINARY=<mesh-host>/mesh-bootstrap
* MESH_LAB_BUNDLE=<mesh-host>/examples/foundation-first-node.lock
* MESH_LAB_CATALOG=<mesh-catalog>/modules
* MESH_LAB_REPOS=<a directory holding mesh-controller, mesh-tools, mesh-sdk and mesh-catalog checkouts>
* — served by the forge machine at each checkout's HEAD; the catalogue's is the one built from
* MESH_LAB_KEEP=1 leave it standing
*/
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { cpSync, existsSync, mkdtempSync, rmSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { loadScenario } from "../../src/declaration/parse.ts";
import { raise } from "../../src/lifecycle/raise.ts";
import { destroy, exec, push } from "../../src/lifecycle/operate.ts";
import { bootstrapBinaryPath, hostBinaryPath } from "../../src/lifecycle/place.ts";
import { labIsUsable, destroyAll, foundationBundle } from "./harness.ts";
import { genesis, type GenesisResult } from "./genesis.ts";
import { BUS, busContainers, busNotKept, runningFrom, streamsFrom, written, type Seen } from "./bus-kept.ts";
const SCENARIO = "genesis-keeps-its-bus";
const ANCHOR = "anchor";
const FORGE = "forge";
const FORGE_ADDRESS = "192.0.2.30";
const REGISTRY = "192.0.2.10:5000";
/** The repositories the installer clones, served by the forge at each checkout's HEAD. */
const REPOS = ["mesh-controller", "mesh-tools", "mesh-sdk", "mesh-catalog"] as const;
/** The step this bed is about, as the installer names it. */
const BROKER = "broker";
/** The installer's steps before it, in its order: a first run stopped at one of these never reached it. */
const BEFORE_BROKER = ["preflight", "load", "build", "bundle", "apply", "verify", "enrol", "operator", "registry",
"publish", "control-plane", "retire", "builder", "packages", "sdk", "base", "store"];
const capability = await labIsUsable();
const binary = hostBinaryPath();
const installer = bootstrapBinaryPath();
const bundle = process.env["MESH_LAB_BUNDLE"] ?? "";
const catalogDir = process.env["MESH_LAB_CATALOG"] ?? "";
const repos = process.env["MESH_LAB_REPOS"] ?? "";
const KEEP = !!process.env["MESH_LAB_KEEP"];
const FIXED_ID = process.env["MESH_LAB_INSTANCE_ID"] ?? (KEEP ? "genesis-keeps-its-bus-live" : undefined);
const missingRepo = REPOS.find((r) => !repos || !existsSync(resolve(repos, r, ".git")));
const skip =
!capability.usable ? capability.why :
!binary ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" :
!installer ? "MESH_LAB_BOOTSTRAP_BINARY is not set to a built mesh-bootstrap" :
!bundle || !existsSync(bundle) ? "MESH_LAB_BUNDLE is not set to the genesis bundle" :
!catalogDir || !existsSync(join(catalogDir, "nats", "module.json")) ? "MESH_LAB_CATALOG is not set to mesh-catalog/modules" :
missingRepo ? `MESH_LAB_REPOS holds no ${missingRepo} checkout` :
false;
let instanceId = "";
/** Why the bed never reached the step, or "" when it did. */
let unreached = "";
let first: GenesisResult | undefined;
let second: GenesisResult | undefined;
let seenBefore: Seen | undefined;
let seenAfter: Seen | undefined;
let nodeLine = "";
const heads: Record<string, string> = {};
const report: string[] = [];
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;
}
/** Each repository at its checkout's HEAD, served from the forge machine over git's protocol as `main`. */
async function raiseForge(): Promise<void> {
const dir = mkdtempSync(join(tmpdir(), "mesh-lab-590-forge-"));
try {
for (const repo of REPOS) {
const checkout = resolve(repos, repo);
heads[repo] = execFileSync("git", ["-C", checkout, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
const bare = join(dir, `${repo}.git`);
execFileSync("git", ["init", "-q", "--bare", bare]);
// No collection after the push: one run in the background rewrites the objects while they are carried.
execFileSync("git", ["-C", bare, "config", "receive.autogc", "false"]);
execFileSync("git", ["-C", checkout, "push", "-q", "--force", bare, "HEAD:refs/heads/main"], { stdio: "pipe" });
}
const tar = join(tmpdir(), `mesh-lab-590-forge-${process.pid}.tar`);
execFileSync("tar", ["-cf", tar, "-C", dir, "."]);
await push(instanceId, FORGE, tar, "/tmp/forge.tar");
rmSync(tar, { force: true });
} finally {
rmSync(dir, { recursive: true, force: true });
}
// Owned by the daemon's user, or git refuses to serve it ("dubious ownership") and says nothing.
await must(FORGE, `mkdir -p /srv/git && tar --no-same-owner -xf /tmp/forge.tar -C /srv/git && chown -R root:root /srv/git && ` +
`git daemon --base-path=/srv/git --export-all --reuseaddr --detach --listen=${FORGE_ADDRESS} --pid-file=/run/git-daemon.pid`);
for (let i = 0; ; i++) {
if ((await on(ANCHOR, `git ls-remote git://${FORGE_ADDRESS}/mesh-catalog.git main`)).ok) break;
if (i >= 30) throw new Error(`the anchor cannot reach the forge at git://${FORGE_ADDRESS}/`);
await new Promise((r) => setTimeout(r, 2_000));
}
}
/** What the anchor shows of its bus: the containers it runs, and every stream the bus holds. */
async function look(): Promise<Seen> {
const ps = await must(ANCHOR, `docker ps --format '{{.Names}}\t{{.Image}}\t{{.Ports}}'`);
// The monitor, published on the anchor's loopback by the bundle and by the module alike.
let jsz = "";
for (let i = 0; ; i++) {
const asked = await on(ANCHOR, `curl -sf --max-time 10 'http://127.0.0.1:8222/jsz?accounts=true&streams=true'`);
if (asked.ok) { jsz = asked.out; break; }
if (i >= 30) throw new Error(`the bus's monitor does not answer on the anchor: ${asked.out}`);
await new Promise((r) => setTimeout(r, 2_000));
}
return { running: runningFrom(ps), streams: streamsFrom(jsz) };
}
function stoppedAt(r: GenesisResult): string {
return r.step.replace(/^step \d+ of \d+, /, "");
}
before(async () => {
if (skip) return;
const raised = await raise(loadScenario(`scenarios/${SCENARIO}.yml`), {
onProgress: (m) => console.log(`raise: ${m}`),
...(FIXED_ID ? { instanceId: FIXED_ID } : {}),
});
instanceId = raised.instanceId;
console.log(`INSTANCE ${instanceId}${KEEP ? " (KEEP — will be left standing)" : ""}`);
try {
await raiseForge();
report.push(` forge ${REPOS.map((r) => `${r} ${heads[r]?.slice(0, 8)}`).join(", ")}`);
} catch (err) {
unreached = `the forge was not raised, so the installer never ran: ${(err as Error).message}`;
return;
}
const forge = (repo: string) => `git://${FORGE_ADDRESS}/${repo}.git`;
const run = (catalogue: string) => genesis({
instanceId,
node: ANCHOR,
installer: installer as string,
catalogDir: catalogue,
catalogueModules: ["distribution", "build-agent", "postgres"],
bundleTemplate: foundationBundle(bundle, []),
registry: REGISTRY,
source: forge("mesh-controller"),
sourceRef: heads["mesh-controller"] as string,
toolsSource: forge("mesh-tools"),
sdkSource: forge("mesh-sdk"),
catalogSource: forge("mesh-catalog"),
// A refusal is the answer, and a re-run is the second half of this bed, not a retry.
attempts: 1,
verify: false,
log: (m) => console.log(m),
});
// 1. From bare to the broker step, and no further: the catalogue the installer reads has no nats module.
const withoutTheBus = mkdtempSync(join(tmpdir(), "mesh-lab-590-catalogue-"));
try {
const nats = join(catalogDir, "nats");
cpSync(catalogDir, withoutTheBus, { recursive: true, filter: (src) => src !== nats && !src.startsWith(nats + "/") });
first = await run(withoutTheBus);
} catch (err) {
unreached = `the installer could not be run: ${(err as Error).message}`;
return;
} finally {
rmSync(withoutTheBus, { recursive: true, force: true });
}
report.push(...first.report);
if (first.ok || stoppedAt(first) !== BROKER) {
unreached = first.ok
? "the first run finished without the nats module, so the broker step never stopped it"
: `the first run stopped at ${first.step || "no step it named"}, before the broker step:\n${first.why}`;
return;
}
if (!/nats module's manifest could not be read/.test(first.said)) {
unreached = `the first run stopped at the broker step for another reason than the missing module:\n${first.why}`;
return;
}
seenBefore = await look();
report.push(` before the step ${busContainers(seenBefore.running).map((b) => `${b.name} on ${b.image}`).join("; ")}`);
report.push(` ${written(seenBefore).length} stream(s) holding something: ` +
written(seenBefore).map((s) => `${s.name} (last ${s.lastSeq})`).join(", "));
if (written(seenBefore).length === 0) {
unreached = "nothing was written on the bus before the broker step, so whether its streams survive it says nothing";
return;
}
// 2. The whole catalogue: the steps before are done already, and the broker step adopts the bus.
try {
second = await run(catalogDir);
} catch (err) {
unreached = `the installer could not be run again: ${(err as Error).message}`;
return;
}
report.push(...second.report);
if (!second.ok && BEFORE_BROKER.includes(stoppedAt(second))) {
unreached = `the second run stopped at ${second.step}, before the broker step it was run again for:\n${second.why}`;
return;
}
seenAfter = await look();
report.push(` after the step ${busContainers(seenAfter.running).map((b) => `${b.name} on ${b.image}`).join("; ") || "no bus"}`);
const nodes = await on(ANCHOR, `docker exec mesh-controller /mesh-controller node list`, 60_000);
nodeLine = nodes.out.split("\n").map((l) => l.trim()).find((l) => l.startsWith(`${ANCHOR} `)) ?? "";
report.push(` node list ${nodeLine || nodes.out.trim()}`);
}, { timeout: 7_200_000 });
after(async () => {
if (KEEP) {
console.log(`\nLEFT STANDING: ${instanceId} — not destroyed (MESH_LAB_KEEP).`);
return;
}
if (instanceId) await destroy(instanceId);
await destroyAll(`${SCENARIO}-`);
}, { timeout: 900_000 });
test("genesis adopts its bus as the nats module in place: one bus, its streams kept, the node connected", {
skip, timeout: 7_200_000,
}, async (t) => {
console.log(report.join("\n"));
if (unreached) {
t.skip(`ADR 0333 §5 NOT REACHED: ${unreached}`);
return;
}
const ran = second as GenesisResult;
// Stopped AT the broker step: the adoption itself refused or failed — the fault, not a missed bed.
assert.ok(ran.ok || stoppedAt(ran) !== BROKER,
`genesis stopped at its broker step and did not adopt the bus:\n${ran.why}`);
if (!ran.ok) {
console.log(`\nthe second run crossed the broker step and stopped later, at ${ran.step} — not this bed's ` +
`question:\n${ran.why}`);
}
const wrong = busNotKept(seenBefore as Seen, seenAfter as Seen, nodeLine, ran.said);
assert.deepEqual(wrong, [], `genesis did not keep its bus through the broker step:\n - ${wrong.join("\n - ")}`);
console.log(`\nVERDICT: one bus, ${BUS}, on the module's image; ${written(seenBefore as Seen).length} stream(s) ` +
`kept; ${ANCHOR} connected.`);
});