Draw a scenario, from the declaration and from the hypervisor

`mesh-lab diagram` renders a scenario as draw.io, from either source, through
one layout — so a difference between what was asked for and what exists is a
difference you can see.

The shape says what a resource is and is fixed per kind. The badges say what is
true about that particular one and come entirely from metadata: translation,
forwardability, mapping expiry, refuses-inbound, container-or-VM, running. The
interesting properties of a network are exactly the ones with no visual
consequence — a translated address looks identical to an untranslated one.

For the live picture to be a record rather than a restatement, raise now writes
down what it applied: a segment's kind, ranges and MTU on the link; a gateway's
translation, forwardability and expiry on the gateway; inbound: deny on the
machine. Every behavioural tag is written AFTER the thing works, never at
creation — a failed raise leaves wreckage standing on purpose, and a picture of
that wreckage must not badge translation the router never got.

The pairing earned itself immediately: drawn side by side, every virtual machine
held no addresses. A container's interface carries the device's name and a VM
names its own, so joining them by name silently dropped one whole class of
machine. Fixed by joining on MAC.

Also brings tests under the typecheck gate, which caught integration timeouts
being passed as a 4th argument and therefore ignored entirely.
This commit is contained in:
2026-08-24 22:53:00 +02:00
parent ca2bbab836
commit 2243618f01
14 changed files with 993 additions and 37 deletions
+99 -20
View File
@@ -10,6 +10,9 @@ import { raise } from "../../src/lifecycle/raise.ts";
import { destroy, exec, list, restore, snapshot } from "../../src/lifecycle/operate.ts";
import { incus, incusOk } from "../../src/incus/client.ts";
import { labIsUsable, destroyAll } from "./harness.ts";
import { diagramFromLive } from "../../src/diagram/from-live.ts";
import { diagramFromDeclaration } from "../../src/diagram/from-declaration.ts";
import { toDrawio } from "../../src/diagram/drawio.ts";
const capability = await labIsUsable();
const skip = capability.usable ? false : `lab not usable: ${capability.why}`;
@@ -21,13 +24,13 @@ before(async () => {
const scenario = loadScenario("scenarios/behind-nat.yml");
const raised = await raise(scenario, {});
instanceId = raised.instanceId;
}, { timeout: 900_000 });
});
after(async () => {
if (instanceId) await destroy(instanceId);
}, { timeout: 400_000 });
});
test("ADR 0031 — the lab provides the underlay and NOTHING of the overlay", { skip }, async () => {
test("ADR 0031 — the lab provides the underlay and NOTHING of the overlay", { skip, timeout: 120_000 }, async () => {
// A scenario that pre-built peering would certify its own work. Whatever the mesh is
// responsible for must be absent from a freshly raised machine.
const { stdout } = await exec(instanceId, "home-server", [
@@ -38,23 +41,23 @@ test("ADR 0031 — the lab provides the underlay and NOTHING of the overlay", {
]);
const counts = stdout.trim().split("\n").map((n) => Number(n.trim()));
assert.deepEqual(counts, [0, 0, 0], "a raised machine carries no overlay, no mesh config");
}, { timeout: 120_000 });
});
test("ADR 0031 — the declared address IS what the machine holds", { skip }, async () => {
const { stdout } = await exec(instanceId, "home-server", ["ip", "-o", "-4", "addr", "show"]);
assert.match(stdout, /192\.168\.1\.135\/24/);
});
test("design — raise waits for USABLE, not for the call to return", { skip }, async () => {
test("design — raise waits for USABLE, not for the call to return", { skip, timeout: 120_000 }, async () => {
// The measured gap is 3.4s to 14.3s. Reporting the earlier number is transport reported
// as effect. If raise has returned, every machine must answer immediately.
for (const machine of ["anchor", "home-server"]) {
const { stdout } = await exec(instanceId, machine, ["sh", "-c", "echo alive"]);
assert.equal(stdout.trim(), "alive", `${machine} was not usable when raise returned`);
}
}, { timeout: 120_000 });
});
test("ADR 0033 — a router is scenery: containers, while machines are virtual machines", { skip }, async () => {
test("ADR 0033 — a router is scenery: containers, while machines are virtual machines", { skip, timeout: 120_000 }, async () => {
const json = (await incusOk(["list", "--format", "json"], 30_000)) ?? "[]";
const all = JSON.parse(json) as { name?: string; type?: string; config?: Record<string, string> }[];
const mine = all.filter((i) => i.config?.["user.mesh-lab.instance"] === instanceId);
@@ -68,16 +71,16 @@ test("ADR 0033 — a router is scenery: containers, while machines are virtual m
`${item.name} is a ${item.type} but ${isRouter ? "is" : "is not"} a router`,
);
}
}, { timeout: 120_000 });
});
test("design — NAT: a private address is not reachable from outside", { skip }, async () => {
test("design — NAT: a private address is not reachable from outside", { skip, timeout: 120_000 }, async () => {
const { stdout } = await exec(instanceId, "anchor", [
"sh", "-c", "ping -c1 -W2 192.168.1.135 >/dev/null 2>&1 && echo reachable || echo unreachable",
]);
assert.equal(stdout.trim(), "unreachable");
}, { timeout: 120_000 });
});
test("design — published: reachable at the GATEWAY's address, never its own", { skip }, async () => {
test("design — published: reachable at the GATEWAY's address, never its own", { skip, timeout: 180_000 }, async () => {
await exec(instanceId, "home-server", [
"sh", "-c", "nohup python3 -m http.server 8080 --bind 0.0.0.0 >/tmp/s.log 2>&1 & sleep 2",
]);
@@ -85,9 +88,9 @@ test("design — published: reachable at the GATEWAY's address, never its own",
"sh", "-c", "curl -s -m5 -o /dev/null -w '%{http_code}' http://192.0.2.50:8080/ || echo failed",
]);
assert.equal(stdout.trim(), "200", "the forwarded port did not reach the machine behind NAT");
}, { timeout: 180_000 });
});
test("design — snapshots are WHOLE-scenario: restore returns every machine", { skip }, async () => {
test("design — snapshots are WHOLE-scenario: restore returns every machine", { skip, timeout: 600_000 }, async () => {
// Restoring a subset would produce a mesh that has never existed, so faults found there
// would be artefacts of the lab.
await exec(instanceId, "anchor", ["sh", "-c", "echo dirty > /root/marker"]);
@@ -102,23 +105,99 @@ test("design — snapshots are WHOLE-scenario: restore returns every machine", {
const { stdout } = await exec(instanceId, machine, ["cat", "/root/marker"]);
assert.equal(stdout.trim(), "dirty", `${machine} was not returned to the snapshot`);
}
}, { timeout: 600_000 });
});
test("design — restore leaves the scenario USABLE, not merely running", { skip }, async () => {
test("design — restore leaves the scenario USABLE, not merely running", { skip, timeout: 120_000 }, async () => {
// The restore call returns in under a second while the agent is still starting. Reporting
// that as restored would be transport reported as effect.
const { stdout } = await exec(instanceId, "anchor", ["sh", "-c", "echo alive"]);
assert.equal(stdout.trim(), "alive");
}, { timeout: 120_000 });
});
test("ADR 0032 — the workstation has no route into the scenario", { skip }, async () => {
test("ADR 0032 — the workstation has no route into the scenario", { skip, timeout: 60_000 }, async () => {
// Reachability is asked from INSIDE. If the workstation could reach a scenario address,
// two scenarios carrying the same prefix would put one's traffic in the other.
const { stdout } = await incus(["exec", `mlab-${instanceId}-anchor`, "--", "echo", "inside"]);
assert.equal(stdout.trim(), "inside", "exec is the only way in, and it works");
}, { timeout: 60_000 });
});
test("housekeeping — destroy removes machines, routers and segments", { skip }, async () => {
// The diagram tests read the instance the file raised, so they run before the one that
// tears it down. Ordering is load-bearing here: appended after the destroy test they read
// an instance that no longer existed, and reported it as the diagram failing.
test("the live diagram reads the hypervisor, and a VM's addresses are not lost", { skip }, async () => {
// A container's interface carries the device's name; a virtual machine names its own, so
// joining addresses to devices by name attached every address to a container and none to
// a VM. The picture then showed machines that looked like they had failed to come up.
const drawn = await diagramFromLive(instanceId);
const server = drawn.machines.find((m) => m.name === "home-server");
assert.ok(server, "home-server missing from the live picture");
assert.equal(server.kind, "machine");
assert.ok(
server.attachments.some((a) => a.addresses.some((address) => address.startsWith("192.168.1.135"))),
`a virtual machine's addresses were not read back: ${JSON.stringify(server.attachments)}`,
);
});
test("the live diagram draws what exists, never what was asked for", { skip, timeout: 300_000 }, async () => {
// Every property shown must have come off the hypervisor. Proved by changing the running
// system and watching only the live picture move.
const scenario = loadScenario("scenarios/behind-nat.yml");
const declared = diagramFromDeclaration(scenario);
const before = await diagramFromLive(instanceId);
assert.equal(before.machines.length, declared.machines.length, "the two pictures disagree on size");
const anchor = (await list())
.find((i) => i.instanceId === instanceId)
?.machines.find((m) => m.machine === "anchor");
assert.ok(anchor, "anchor not found");
await incus(["stop", anchor.name], 120_000);
try {
const after = await diagramFromLive(instanceId);
assert.equal(after.machines.find((m) => m.name === "anchor")?.status, "Stopped");
// The declaration has not changed, and neither has its picture.
assert.equal(
diagramFromDeclaration(scenario).machines.find((m) => m.name === "anchor")?.status,
undefined,
"a declared picture reported a runtime status it cannot know",
);
} finally {
await incus(["start", anchor.name], 120_000);
}
});
test("ADR 0033 — the live diagram distinguishes scenery from a node", { skip }, async () => {
// The router is drawn as a router because the hypervisor says it is a container tagged as
// a gateway — not because the diagram re-read the scenario and inferred it.
const drawn = await diagramFromLive(instanceId);
const gateway = drawn.machines.find((m) => m.kind === "router");
assert.ok(gateway, "no gateway in the live picture");
assert.ok(gateway.notes.includes("container"), "the gateway is not reported as scenery");
assert.ok(gateway.notes.some((n) => n.startsWith("NAT ")), "translation is not shown");
assert.ok(gateway.notes.includes("forwardable"), "forwardability is not shown");
assert.ok(
gateway.notes.some((n) => /mappings expire \d+s/.test(n)),
"the declared mapping expiry was not recorded on the gateway",
);
const segments = new Map(drawn.segments.map((s) => [s.name, s]));
assert.equal(segments.get("hosting")?.kind, "public", "segment kind was not recorded at raise");
assert.equal(segments.get("home")?.behind, "hosting", "the tree was not recovered from the gateway tags");
assert.equal(segments.get("home")?.depth, 1);
});
test("a picture nobody can open is not a picture", { skip }, async () => {
// The first generated file was unparseable — HTML labels concatenated into an XML
// attribute. Checked here against real output as well as against the fixtures, because
// live labels carry names and addresses the declared ones never contain.
const xml = toDrawio(await diagramFromLive(instanceId));
for (const match of xml.matchAll(/(?:value|label|tooltip)="([^"]*)"/g)) {
assert.doesNotMatch(match[1] ?? "", /[<>]/, "raw markup reached an XML attribute");
}
const ids = [...xml.matchAll(/<(?:mxCell|object) [^>]*id="([^"]*)"/g)].map((m) => m[1]);
assert.equal(new Set(ids).size, ids.length, "duplicate cell id — draw.io drops one silently");
});
test("housekeeping — destroy removes machines, routers and segments", { skip, timeout: 400_000 }, async () => {
const before = (await list()).find((i) => i.instanceId === instanceId);
assert.ok(before, "the instance should exist before it is destroyed");
@@ -130,4 +209,4 @@ test("housekeeping — destroy removes machines, routers and segments", { skip }
assert.equal(after, undefined, "the instance should be gone");
instanceId = "";
await destroyAll("behind-nat-");
}, { timeout: 400_000 });
});