Files
mesh-lab/test/integration/whole-mesh-full.test.ts
T
jschoubben 591f2a641c whole-mesh-full: prove the dry-run fixes (fail2ban hostable, credential own-secrets)
Re-runs the capstone from main after the dry-run fixes merged.

fail2ban: added to the novox set. The capability fix (intrusion-prevention ->
firewall) makes it HOSTABLE — it is now assigned, not refused — which is the
gate. Its service reaching active is a host concern the offline lab cannot meet
(the VM ships nftables but not fail2ban, and the isolated segment has no route to
the package mirror, so pacman cannot fetch it), so fail2ban joins GAPS_NOVOX: its
failed package resource is tolerated like firewall's oneshot nftables.service.

7 credential sidecars: before the push, a FAKE app credential is delivered for
each (plex/bazarr/ombi/home-assistant/nzbget/qbittorrent on ace, umami on novox)
through the real operator path — `secret accept <node> <module> <name> --from`.
The bed asserts each sidecar advances PAST its old "no credential" crash (it reads
the delivered value); app-auth failure against the real app with a bogus value is
expected and not gated.

Result: SUITE_EXIT=0. Both node-plans converge on one substrate (novox 13/13
core, ace 17/17 core), fail2ban hostable, all 7 sidecars past their crash.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-08 20:05:04 +02:00

549 lines
28 KiB
TypeScript

/**
* The FULL mesh: both server sets on ONE substrate, converging together — the final stage of the
* whole-mesh rehearsal (novox/hq). Combines whole-mesh-novox.test.ts and whole-mesh-ace.test.ts.
*
* anchor — substrate ONLY (store, broker, control).
* novox — the 18-module novox set (whole-mesh-novox): providers, web apps, route-proxy, mailu,
* firewall, fail2ban. fail2ban is now HOSTABLE: the dry-run fixes (mesh-control/catalog
* main) changed its declared capability from the never-detected "intrusion-prevention" to
* "firewall", the detector every node with nft already advertises.
* ace — the 24-module ace set (whole-mesh-ace): the media/home stack; its /services/media
* library is pre-created so the ADR-0051 `accesses` resolve.
*
* An overlay is placed across all three so cross-node `at` resolves. Each service node is
* self-contained (its own postgres/redis), so nothing crosses a node boundary except enrolment and
* the shared broker/store on anchor. The four modules both nodes run (postgres, redis, mssql,
* portainer) are ADDED once and assigned to each node; each gets its own per-node broker account.
*
* THE DRY-RUN FIXES THIS RUN PROVES (mesh-control + mesh-catalog main):
* - fail2ban is HOSTABLE (capability "firewall"): it is assigned, not refused. Before, it declared
* the never-detected "intrusion-prevention" capability, so no node could host it and its
* un-hostable assignment refused the whole node's push. Hostability is the gate. Its service
* reaching active is a host concern this offline lab cannot meet — the VM ships nftables (so the
* firewall detector is advertised) but not fail2ban, and the isolated segment has no route to the
* package mirror, so pacman cannot fetch it. That is a documented lab gap, reported not gated.
* - the 7 tool-runtime credential modules (ace: plex, bazarr, ombi, home-assistant, nzbget,
* qbittorrent; novox: umami) now read their app credential from an operator-provided own-secret.
* This bed delivers a FAKE value for each through the real operator path (`secret accept`)
* BEFORE the push, and gates on the sidecar getting PAST its old "no credential" crash (it reads
* the delivered value). A fake value will not authenticate against the real app — the sidecar may
* still fail at app-auth, which is expected and does NOT gate; only the crash being GONE gates.
*
* It otherwise tolerates the SAME known gaps the per-server beds proved and escalated (the credential
* sidecars' app-auth failures, photos/mailu, and firewall's oneshot nftables.service); it gates green
* on each node's CORE converging whole and on no NON-GAP resource failing to apply — i.e. the two
* node-plans converge together on one substrate.
*
* MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock
*/
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
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 modulesEnv = process.env["MESH_LAB_MODULES"] ?? "";
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 = "whole-mesh-full";
const catalogDir = process.env["MESH_LAB_CATALOG"]
?? (modulesEnv ? resolve(dirname(dirname(dirname(modulesEnv))), "mesh-catalog", "modules") : "")
?? resolve(process.cwd(), "..", "mesh-catalog", "modules");
const MEDIA_DIRS = [
"/services/media/series", "/services/media/anime", "/services/media/movies",
"/services/media/music", "/services/media/audiobooks", "/services/media/downloads",
"/services/media/books",
];
type Mod = { name: string; containers: string[]; node?: boolean; runOnce?: string[] };
/** The novox node's 17-module set (fail2ban dropped). CORE gates; the rest are documented gaps. */
const NOVOX: Mod[] = [
{ name: "postgres", containers: ["postgres", "mesh-postgres"] },
{ name: "redis", containers: ["redis", "mesh-redis"] },
{ name: "minio", containers: ["minio", "mesh-minio"] },
{ name: "mongodb", containers: ["mongo", "mesh-mongodb"] },
{ name: "mssql", containers: ["mssql", "mesh-mssql"] },
{ name: "keycloak", containers: ["keycloak", "mesh-keycloak"] },
{ name: "gitea", containers: ["gitea", "mesh-gitea"] },
{ name: "nextcloud", containers: ["nextcloud", "mesh-nextcloud"] },
{ name: "umami", containers: ["umami", "mesh-umami"] },
{ name: "photos", containers: ["photos", "mesh-photos"] },
{ name: "invoicing", containers: ["invoicing-app", "invoicing-api"] },
{ name: "portainer", containers: ["portainer", "mesh-portainer"] },
{ name: "verdaccio", containers: ["verdaccio", "mesh-verdaccio"] },
{ name: "registry", containers: ["mesh-registry"] },
{ name: "route-proxy", containers: ["route-proxy"] },
{
name: "mailu",
containers: [
"mailu-resolver", "mailu-redis", "mailu-admindb", "mailu-admin", "mailu-imap",
"mailu-smtp", "mailu-antispam", "mailu-webmail", "mailu-front", "mesh-mailu",
],
},
{ name: "firewall", containers: [], node: true },
{ name: "fail2ban", containers: [], node: true },
];
const CORE_NOVOX = new Set([
"postgres", "redis", "minio", "mongodb", "mssql",
"keycloak", "gitea", "nextcloud", "invoicing",
"portainer", "verdaccio", "registry", "route-proxy",
]);
const GAPS_NOVOX = new Set(["umami", "photos", "mailu", "firewall", "fail2ban"]);
/** The ace node's 24-module set. */
const ACE: Mod[] = [
{ name: "postgres", containers: ["postgres", "mesh-postgres"] },
{ name: "redis", containers: ["redis", "mesh-redis"] },
{ name: "mssql", containers: ["mssql", "mesh-mssql"] },
{ name: "sonarr", containers: ["sonarr", "mesh-sonarr"] },
{ name: "radarr", containers: ["radarr", "mesh-radarr"] },
{ name: "lidarr", containers: ["lidarr", "mesh-lidarr"] },
{ name: "plex", containers: ["plex", "mesh-plex"] },
{ name: "bazarr", containers: ["bazarr", "mesh-bazarr"] },
{ name: "nzbget", containers: ["nzbget", "mesh-nzbget"] },
{ name: "qbittorrent", containers: ["qbittorrent", "mesh-qbittorrent"] },
{ name: "jackett", containers: ["jackett", "mesh-jackett"] },
{ name: "ombi", containers: ["ombi", "mesh-ombi"] },
{ name: "tautulli", containers: ["tautulli", "mesh-tautulli"] },
{ name: "bookshelf", containers: ["bookshelf", "mesh-bookshelf"] },
{ name: "home-assistant", containers: ["home-assistant", "mesh-home-assistant"] },
{ name: "mosquitto", containers: ["mosquitto", "mesh-mosquitto"], runOnce: ["mosquitto-bootstrap"] },
{ name: "influxdb", containers: ["influxdb", "mesh-influxdb"] },
{ name: "grafana", containers: ["grafana", "mesh-grafana"] },
{ name: "baserow", containers: ["baserow", "mesh-baserow"] },
{ name: "letta", containers: ["letta", "mesh-letta"] },
{ name: "nodered", containers: ["nodered", "mesh-nodered"] },
{ name: "searxng", containers: ["valkey", "searxng", "mesh-searxng"] },
{ name: "unifi", containers: ["unifi-controller", "mesh-unifi"] },
{ name: "portainer", containers: ["portainer", "mesh-portainer"] },
];
const CORE_ACE = new Set([
"postgres", "redis", "mssql",
"sonarr", "radarr", "lidarr", "jackett", "tautulli", "bookshelf",
"mosquitto", "influxdb", "grafana", "baserow", "nodered", "searxng", "unifi", "portainer",
]);
const GAPS_ACE = new Set(["plex", "bazarr", "nzbget", "qbittorrent", "ombi", "home-assistant", "letta"]);
const PLAN: { node: string; mods: Mod[]; core: Set<string>; gaps: Set<string> }[] = [
{ node: "novox", mods: NOVOX, core: CORE_NOVOX, gaps: GAPS_NOVOX },
{ node: "ace", mods: ACE, core: CORE_ACE, gaps: GAPS_ACE },
];
/**
* Host-port remaps (per module — host ports are per-VM, so novox's and ace's never clash). Union of
* both per-server beds' remaps.
*/
const REMAP: Record<string, Record<string, string>> = {
nextcloud: { "80": "8090:80" },
umami: { "3000": "3090:3000" },
invoicing: { "80": "8091:80", "9000": "9091:9000" },
qbittorrent: { "8080": "8090:8080" },
searxng: { "8080": "8092:8080" },
nzbget: { "6789": "6790:6789" },
};
/**
* The 7 tool-runtime credential modules (novox/hq dry-run fix). Each now reads its app credential
* from an operator-provided own-secret (`name`, an own-secret path in its module.json), mounted into
* the sidecar at MESH_*_FILE. This bed delivers a FAKE value for each via the real operator path
* (`secret accept <node> <module> <name> --from <file>`) BEFORE the push, and asserts the sidecar
* gets PAST `crash` — the exact message its client threw when nothing was mounted. A fake value does
* not authenticate against the real app, so the sidecar may still fail later at app-auth (expected,
* not gated); only the "no credential" crash being GONE proves the wiring and gates.
*/
const CREDENTIALS: { node: string; module: string; name: string; crash: string }[] = [
{ node: "ace", module: "plex", name: "token", crash: "no Plex token" },
{ node: "ace", module: "bazarr", name: "api-key", crash: "no Bazarr API key" },
{ node: "ace", module: "ombi", name: "api-key", crash: "no Ombi API key" },
{ node: "ace", module: "home-assistant", name: "token", crash: "no Home Assistant token" },
{ node: "ace", module: "nzbget", name: "password", crash: "NZBGet not configured" },
{ node: "ace", module: "qbittorrent", name: "password", crash: "qBittorrent not configured" },
{ node: "novox", module: "umami", name: "admin", crash: "admin password is not set" },
];
let instanceId = "";
let stocked: string[] = [];
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;
}
async function mesh(command: string, timeoutMs?: number): Promise<string> {
return must("anchor", `docker exec mesh-control /mesh-control ${command}`, timeoutMs);
}
function repositoryFor(reference: string): string {
const withoutDigest = reference.split("@")[0] ?? reference;
const lastColon = withoutDigest.lastIndexOf(":");
const lastSlash = withoutDigest.lastIndexOf("/");
return lastColon > lastSlash ? withoutDigest.slice(0, lastColon) : withoutDigest;
}
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\n ${stocked.join("\n ")}`);
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 loadManifest(name: string): { manifest: string; broker: boolean } {
const path = resolve(catalogDir, name, "module.json");
const m = JSON.parse(readFileSync(path, "utf8")) as {
resources?: { type: string; image?: string; ports?: string[] }[];
};
const remap = REMAP[name] ?? {};
for (const r of m.resources ?? []) {
if (r.type !== "container") continue;
if (typeof r.image === "string") r.image = pinned(repositoryFor(r.image));
if (Array.isArray(r.ports)) r.ports = r.ports.map((p) => remap[p] ?? p);
}
const manifest = JSON.stringify(m);
return { manifest, broker: manifest.includes("MESH_BROKER_FILE") };
}
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;
}
interface NodeState {
reached: boolean;
applied: boolean;
current: boolean;
waiting: boolean;
wrong?: { outcome: string; refused?: string | undefined; failed?: { id: string; error: string }[] | undefined } | undefined;
raw: string;
}
async function nodeState(node: string): Promise<NodeState> {
const asked = await on("anchor", `docker exec mesh-control /mesh-control status --json`);
if (!asked.ok) return { reached: false, applied: false, current: false, waiting: false, raw: asked.out };
let state: {
wrong: { node: string; outcome: string; refused?: string; failed?: { id: string; error: string }[] }[];
waiting: { node: string }[];
reported: { node: string; outcome: string; current: boolean }[];
};
try {
state = JSON.parse(asked.out);
} catch {
return { reached: false, applied: false, current: false, waiting: false, raw: asked.out };
}
const word = state.reported.find((r) => r.node === node);
const bad = state.wrong.find((w) => w.node === node);
return {
reached: true,
applied: word?.outcome === "applied",
current: !!word?.current,
waiting: state.waiting.some((w) => w.node === node),
wrong: bad ? { outcome: bad.outcome, refused: bad.refused, failed: bad.failed } : undefined,
raw: asked.out,
};
}
async function psMapOf(node: string): Promise<Map<string, string>> {
const out = (await on(node, `docker ps -a --format '{{.Names}}\t{{.Status}}'`)).out;
const map = new Map<string, string>();
for (const line of out.split("\n")) {
const [n, ...rest] = line.split("\t");
if (n) map.set(n.trim(), rest.join("\t").trim());
}
return map;
}
before(async () => {
if (skip) return;
assert.ok(existsSync(catalogDir), `mesh-catalog modules not found at ${catalogDir}`);
const raised = await raise(loadScenario(`scenarios/${SCENARIO}.yml`), {
onProgress: (m) => console.log(`raise: ${m}`),
});
instanceId = raised.instanceId;
stocked = raised.images;
await must("anchor", `cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`);
await must("anchor", `${HOST_PATH} apply /tmp/substrate.lock`, 900_000);
const up = await must("anchor", `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}`);
}
for (const machine of ["anchor", "novox", "ace"]) {
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`);
}
// The operator provides ace's media library (ADR 0051 accesses confirm the paths, create nothing).
await must("ace", `mkdir -p ${MEDIA_DIRS.join(" ")}`);
}, { timeout: 3_000_000 });
after(async () => {
if (instanceId) await destroy(instanceId);
await destroyAll(`${SCENARIO}-`);
}, { timeout: 900_000 });
test("both server sets converge together on one substrate", { skip, timeout: 3_600_000 }, async () => {
// Overlay across all three, so every node's private address exists and cross-node `at` resolves.
await mesh("overlay place anchor --hub --endpoint 192.0.2.10:51820 --site lab");
await mesh("overlay place novox --site lab");
await mesh("overlay place ace --site lab");
await mesh("assign anchor networking");
await mesh("assign novox networking");
await mesh("assign ace networking");
// Add every unique module ONCE (the four shared modules are added once, assigned to each node), then
// issue a per-node broker account and assign, resiliently.
const added = new Map<string, boolean>(); // name -> needs broker
async function ensureAdded(name: string): Promise<boolean> {
const known = added.get(name);
if (known !== undefined) return known;
const { manifest, broker } = loadManifest(name);
await must("anchor", `printf %s ${quote(manifest)} > /tmp/${name}.json && docker cp /tmp/${name}.json mesh-control:/${name}.json`);
await mesh(`module add /${name}.json`);
added.set(name, broker);
return broker;
}
const assigned: Record<string, Set<string>> = { novox: new Set(), ace: new Set() };
const refused: Record<string, { name: string; why: string }[]> = { novox: [], ace: [] };
for (const { node, mods } of PLAN) {
for (const { name } of mods) {
try {
const broker = await ensureAdded(name);
if (broker) await mesh(`module issue ${name} --node ${node}`);
await mesh(`assign ${node} ${name}`);
assigned[node]!.add(name);
} catch (err) {
const why = (err as Error).message.split("\n").map((l) => l.trim()).filter(Boolean).slice(1, 5).join(" | ");
refused[node]!.push({ name, why });
console.log(`NOT ASSIGNED ${node}/${name}: ${why}`);
}
}
}
// Operator-provided app credentials (novox/hq dry-run fix). BEFORE the push, hand the mesh a FAKE
// value for each of the 7 credential modules through the real operator path — `secret accept`,
// which seals the value to the node and records it as `accepted` (the mesh will not invent one).
// The push then delivers it to the sidecar's own-secret path. The `--from` file is staged into the
// mesh-control container (one file per distinct secret name). A module the node could not host is
// skipped (its secret has nowhere to go).
const credentialDelivered = new Map<string, boolean>();
for (const name of new Set(CREDENTIALS.map((c) => c.name))) {
await must("anchor", `printf %s ${quote(`fake-${name}-value`)} > /tmp/fake-${name} && docker cp /tmp/fake-${name} mesh-control:/fake-${name}`);
}
for (const c of CREDENTIALS) {
if (!assigned[c.node]!.has(c.module)) {
credentialDelivered.set(`${c.node}/${c.module}`, false);
console.log(`CREDENTIAL SKIPPED ${c.node}/${c.module}: not assigned, nowhere to deliver`);
continue;
}
try {
await mesh(`secret accept ${c.node} ${c.module} ${c.name} --from /fake-${c.name}`);
credentialDelivered.set(`${c.node}/${c.module}`, true);
} catch (err) {
credentialDelivered.set(`${c.node}/${c.module}`, false);
console.log(`CREDENTIAL ACCEPT FAILED ${c.node}/${c.module}: ${(err as Error).message.split("\n").slice(0, 3).join(" | ")}`);
}
}
// ONE push per node.
const pushError: Record<string, string> = { novox: "", ace: "" };
for (const node of ["novox", "ace"]) {
try {
await mesh(`push ${node}`, 240_000);
} catch (err) {
pushError[node] = (err as Error).message;
console.log(`PUSH REJECTED (${node}):\n${pushError[node]}`);
}
}
// Wait for both nodes' CORE containers to come up (they pull concurrently from the one registry).
const psMaps: Record<string, Map<string, string>> = { novox: new Map(), ace: new Map() };
for (const { node, mods, core } of PLAN) {
if (pushError[node]) continue;
const coreContainers = mods.filter((m) => core.has(m.name) && assigned[node]!.has(m.name)).flatMap((m) => m.containers);
const until = Date.now() + 2_700_000;
while (Date.now() < until) {
psMaps[node] = await psMapOf(node);
if (coreContainers.every((c) => (psMaps[node]!.get(c) ?? "").startsWith("Up"))) break;
await new Promise((r) => setTimeout(r, 10000));
}
}
await new Promise((r) => setTimeout(r, 20000)); // let first-boot bounces settle
// ================================================================================================
// Per-node report + gating. GREEN = each node's push accepted, every CORE module converged whole,
// no NON-GAP resource failed to apply, fail2ban is hostable (assigned, not refused), and every
// credential sidecar advanced past its "no credential" crash. Tolerated: the credential sidecars'
// app-auth failures (bogus fake value), photos/mailu, firewall's oneshot nftables.service, and
// fail2ban's package (the offline lab cannot fetch it — a documented host gap).
// ================================================================================================
const users = (await on("anchor", `docker exec mesh-broker lavinmqctl list_users 2>&1`)).out;
const allProblems: string[] = [];
const report: string[] = ["================ FULL MESH CONVERGENCE ================"];
for (const { node, mods, core, gaps } of PLAN) {
const psMap = psMaps[node] = await psMapOf(node);
const st = await nodeState(node);
const running = (name: string): boolean => (psMap.get(name) ?? "").startsWith("Up");
const ranOnce = (name: string): boolean => !psMap.has(name) || /^(Up|Exited \(0\))/.test(psMap.get(name) ?? "");
const failedResources = st.wrong?.failed ?? [];
report.push(`\n---- node ${node}: reached=${st.reached} applied=${st.applied} current=${st.current} waiting=${st.waiting} ----`);
if (pushError[node]) report.push(` PUSH REJECTED: ${pushError[node].split("\n").slice(0, 6).join("\n ")}`);
if (st.wrong) {
report.push(` NODE WRONG: outcome=${st.wrong.outcome}`);
for (const f of failedResources) report.push(` failed ${f.id}: ${f.error}`);
}
for (const r of refused[node]!) report.push(` REFUSED ${r.name}: ${r.why}`);
const coreFailures: string[] = [];
for (const mod of mods) {
if (!assigned[node]!.has(mod.name)) continue;
const states = mod.containers.map((c) => `${c}:${running(c) ? "UP" : (psMap.get(c) ?? "MISSING")}`);
const ok = mod.containers.every(running) && (mod.runOnce ?? []).every(ranOnce);
const tag = core.has(mod.name) ? (ok ? "OK " : "FAIL") : (ok ? "ok " : "GAP ");
report.push(` ${core.has(mod.name) ? "*" : " "} ${mod.name.padEnd(15)} ${tag} ${states.join(" ")}`);
if (core.has(mod.name) && !ok) coreFailures.push(mod.name);
}
const issuedHere = mods.filter((m) => new RegExp(`${node}-${m.name}\\b`).test(users)).length;
report.push(` broker accounts: ${issuedHere} present for ${node}`);
// Gate: push accepted, all CORE up, no NON-GAP resource failed. A failed resource names its
// owning module inside the error (`applying "firewall.load": …`), not in `id` (which is the outer
// "apply" key), so the owner is extracted from either — and a failure owned by a KNOWN_GAP module
// (firewall's oneshot nftables.service) is tolerated.
const gapOwnerOf = (f: { id: string; error: string }): string => {
const m = f.error.match(/applying "([^".]+)\./);
return m?.[1] ?? (f.id.split(".")[0] ?? "");
};
if (pushError[node]) allProblems.push(`${node}: push rejected`);
if (coreFailures.length) allProblems.push(`${node}: CORE not converged: ${coreFailures.join(", ")}`);
const nonGapFailed = failedResources.filter((f) => !gaps.has(gapOwnerOf(f)));
if (nonGapFailed.length) allProblems.push(`${node}: non-gap resource failed: ${nonGapFailed.map((f) => `${f.id} (${f.error.slice(0, 60)})`).join(", ")}`);
}
// ================================================================================================
// The dry-run fixes, proved by name.
// ================================================================================================
// fail2ban is now HOSTABLE (capability "firewall"): what the dry-run fix buys is that a node can
// host it at all. Before, it declared the never-detected "intrusion-prevention" capability, so NO
// node could host it AND its un-hostable assignment refused the whole node's push. So the GATE is
// hostability: it must be ASSIGNED and NOT refused.
//
// Its systemd service reaching active is a SEPARATE, host-level concern this offline lab cannot
// satisfy: the VM base image ships `nftables` (so firewall's package resolves and the `firewall`
// detector is advertised — which is exactly why fail2ban is now hostable) but NOT `fail2ban`, and
// the lab segment (RFC 5737 192.0.2.0/24) has no route to the package mirror, so pacman times out
// fetching fail2ban and its deps. That is a documented LAB gap (fail2ban ∈ GAPS_NOVOX, so its
// failed `fail2ban.package` resource is tolerated like firewall's oneshot nftables.service) — it is
// reported, not gated. On an online node the package installs and the service runs.
{
const refusedF2B = refused["novox"]!.find((r) => r.name === "fail2ban");
const assignedF2B = assigned["novox"]!.has("fail2ban");
const active = (await on("novox", `systemctl is-active fail2ban 2>&1`)).out.trim();
const pkg = (await on("novox", `pacman -Q fail2ban 2>&1`)).out.trim();
report.push(`\n---- fail2ban (novox): HOSTABLE assigned=${assignedF2B} refused=${refusedF2B ? "YES" : "no"} | service=${active} package="${pkg}" ----`);
if (refusedF2B) {
allProblems.push(`fail2ban still not hostable on novox: ${refusedF2B.why}`);
} else if (!assignedF2B) {
allProblems.push(`fail2ban was not assigned to novox`);
}
if (active !== "active") {
report.push(` service not active — offline lab could not install the package (documented gap, not gated); detail:`);
report.push(` ${(await on("novox", `systemctl status fail2ban --no-pager 2>&1 | head -8`)).out}`);
}
}
// The 7 credential sidecars: each got its fake own-secret, so each must have advanced PAST the old
// "no credential" crash (it read the delivered value). It may still fail at app-auth against the
// real app with a bogus value — that is expected and does NOT gate; only the crash being gone does.
report.push(`\n---- credential sidecars: past the "no credential" crash? (fake secret delivered) ----`);
for (const c of CREDENTIALS) {
const container = `mesh-${c.module}`;
const psMap = psMaps[c.node]!;
const status = (psMap.get(container) ?? "MISSING").split(" ")[0] ?? "MISSING";
const delivered = credentialDelivered.get(`${c.node}/${c.module}`) ?? false;
const logs = (await on(c.node, `docker logs ${container} 2>&1 | tail -60`)).out;
const stillCrashes = logs.includes(c.crash);
const appAuth = logs.split("\n").reverse().find((l) => /fail|reject|error|401|403|refused/i.test(l) && !l.includes(c.crash))?.trim().slice(0, 90) ?? "";
report.push(` ${c.node}/${c.module.padEnd(15)} secret=${delivered ? "delivered" : "SKIPPED"} sidecar=${status.padEnd(10)} crash("${c.crash}")=${stillCrashes ? "STILL PRESENT" : "gone"}${appAuth ? ` last:"${appAuth}"` : ""}`);
if (delivered && stillCrashes) {
allProblems.push(`${c.node}/${c.module}: credential wiring did not take — sidecar still crashes "${c.crash}"`);
}
if (!delivered && assigned[c.node]!.has(c.module)) {
allProblems.push(`${c.node}/${c.module}: fake credential was not delivered (secret accept failed)`);
}
}
const summary = report.join("\n");
console.log(summary);
// Cross-node identity proof: each node's own scoped broker accounts exist and are distinct — the
// two node-plans share one broker without colliding (both run a `postgres`, `redis`, `mssql`).
for (const acct of ["novox-postgres", "ace-postgres", "novox-redis", "ace-redis"]) {
if (!new RegExp(acct).test(users)) allProblems.push(`missing broker account ${acct}`);
}
// Diagnostics for any CORE failure (the gaps are expected; a CORE failure is what we must see).
for (const { node, mods, core } of PLAN) {
const psMap = psMaps[node]!;
for (const mod of mods) {
if (!core.has(mod.name) || !assigned[node]!.has(mod.name)) continue;
for (const c of mod.containers) {
if (psMap.has(c) && !(psMap.get(c) ?? "").startsWith("Up")) {
console.log(`\n---- ${node} logs: ${c} (${psMap.get(c)}) ----\n${(await on(node, `docker logs ${c} 2>&1 | tail -25`)).out}`);
}
}
}
}
assert.deepEqual(allProblems, [], `the full mesh did not converge together:\n ${allProblems.join("\n ")}\n\n${summary}`);
});