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:
@@ -0,0 +1,149 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { loadScenario } from "../src/declaration/parse.ts";
|
||||
import { diagramFromDeclaration } from "../src/diagram/from-declaration.ts";
|
||||
import { toDrawio } from "../src/diagram/drawio.ts";
|
||||
|
||||
/**
|
||||
* A generated diagram that will not open is worse than no diagram — it looks like a
|
||||
* deliverable and is not one. The first version was unparseable because HTML labels were
|
||||
* concatenated raw into an XML attribute, so these assert the file itself.
|
||||
*/
|
||||
|
||||
function parseCells(xml: string): { id: string; vertex: boolean; edge: boolean; source?: string; target?: string }[] {
|
||||
const cells: ReturnType<typeof parseCells> = [];
|
||||
// <object> wrappers carry the id for any cell with a tooltip; the mxCell inside has none.
|
||||
for (const match of xml.matchAll(/<object ([^>]*?)>/g)) {
|
||||
const id = /id="([^"]*)"/.exec(match[1] ?? "")?.[1];
|
||||
if (id) cells.push({ id, vertex: true, edge: false });
|
||||
}
|
||||
for (const match of xml.matchAll(/<mxCell ([^>]*?)(?:\/>|>)/g)) {
|
||||
const attrs = match[1] ?? "";
|
||||
const get = (name: string) => new RegExp(`${name}="([^"]*)"`).exec(attrs)?.[1];
|
||||
const id = get("id");
|
||||
if (!id) continue;
|
||||
const entry: (typeof cells)[number] = {
|
||||
id,
|
||||
vertex: get("vertex") === "1",
|
||||
edge: get("edge") === "1",
|
||||
};
|
||||
const source = get("source");
|
||||
const target = get("target");
|
||||
if (source) entry.source = source;
|
||||
if (target) entry.target = target;
|
||||
cells.push(entry);
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
const scenario = loadScenario("scenarios/the-ordinary-shape.yml");
|
||||
const xml = toDrawio(diagramFromDeclaration(scenario));
|
||||
|
||||
test("the file is well-formed XML — raw markup in an attribute is not", () => {
|
||||
// No unescaped angle bracket may appear inside a value="..." attribute.
|
||||
for (const match of xml.matchAll(/(?:value|label|tooltip)="([^"]*)"/g)) {
|
||||
assert.doesNotMatch(match[1] ?? "", /[<>]/, "a label carries raw markup into an attribute");
|
||||
}
|
||||
assert.match(xml, /^<mxfile /);
|
||||
assert.match(xml, /<\/mxfile>\s*$/);
|
||||
});
|
||||
|
||||
test("every edge connects two cells that exist", () => {
|
||||
const cells = parseCells(xml);
|
||||
const ids = new Set(cells.map((c) => c.id));
|
||||
for (const edge of cells.filter((c) => c.edge)) {
|
||||
assert.ok(ids.has(edge.source ?? ""), `edge ${edge.id} has no source`);
|
||||
assert.ok(ids.has(edge.target ?? ""), `edge ${edge.id} has no target`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the diagram draws the implicit routers, not only what is written down", () => {
|
||||
// A scenario never names its gateways. A picture that showed only declared machines
|
||||
// would omit every node carrying NAT, forwarding and expiry.
|
||||
const diagram = diagramFromDeclaration(scenario);
|
||||
assert.ok(diagram.machines.some((m) => m.kind === "router"), "no router drawn");
|
||||
assert.ok(diagram.machines.some((m) => m.kind === "transit"), "no transit drawn");
|
||||
});
|
||||
|
||||
test("a router's badges say what the declaration decided", () => {
|
||||
const diagram = diagramFromDeclaration(scenario);
|
||||
const unforwardable = diagram.machines.find((m) => m.notes.some((n) => n.includes("NOT forwardable")));
|
||||
assert.ok(unforwardable, "the unforwardable gateway is not marked as such");
|
||||
assert.ok(
|
||||
diagram.machines.some((m) => m.notes.some((n) => n.includes("mappings expire"))),
|
||||
"a declared mapping expiry is not shown",
|
||||
);
|
||||
});
|
||||
|
||||
test("a metadata fact becomes a badge, and absence of the fact does not", () => {
|
||||
// The point of the badges: the properties worth seeing are the ones with no visual
|
||||
// consequence. A translated address looks exactly like an untranslated one.
|
||||
for (const tip of ["translates v4", "no port forwarding", "mappings expire"]) {
|
||||
assert.ok(xml.includes(tip), `no badge explains '${tip}'`);
|
||||
}
|
||||
// A gateway that does not translate gets no mark, rather than a struck-through one.
|
||||
assert.doesNotMatch(xml, /label="N"[^>]*tooltip="[^"]*no NAT/);
|
||||
});
|
||||
|
||||
test("every badge says in words what its letter means", () => {
|
||||
// A one-letter code with no tooltip is a private language. Each badge is wrapped in an
|
||||
// <object>, which is the only place draw.io reads a tooltip from.
|
||||
const badges = [...xml.matchAll(/<object [^>]*label="([A-Z▶■]{1,2})"[^>]*tooltip="([^"]*)"/g)];
|
||||
assert.ok(badges.length > 0, "no badges rendered at all");
|
||||
for (const [, code, tip] of badges) {
|
||||
assert.ok((tip ?? "").length > 10, `badge ${code} has no explanation`);
|
||||
}
|
||||
});
|
||||
|
||||
test("no two cells share an id — a machine may be named after a gateway", () => {
|
||||
const ids = parseCells(xml).map((c) => c.id);
|
||||
assert.equal(new Set(ids).size, ids.length, "duplicate cell id");
|
||||
});
|
||||
|
||||
test("segments are ordered public first, then by depth behind them", () => {
|
||||
const diagram = diagramFromDeclaration(scenario);
|
||||
const publicDepths = diagram.segments.filter((s) => s.kind === "public").map((s) => s.depth);
|
||||
assert.deepEqual([...new Set(publicDepths)], [0], "a public segment should be at depth 0");
|
||||
const home = diagram.segments.find((s) => s.name === "home");
|
||||
assert.equal(home?.depth, 1, "a segment behind one gateway is at depth 1");
|
||||
});
|
||||
|
||||
test("a declared address appears on the machine that holds it", () => {
|
||||
assert.match(xml, /192\.168\.1\.135/);
|
||||
assert.match(xml, /198\.51\.100\.7/);
|
||||
});
|
||||
|
||||
test("every shape names a stencil that exists", () => {
|
||||
// A style naming a stencil draw.io does not have renders as an empty box — no error, no
|
||||
// warning, just a missing picture. Checked against the names in draw.io's own
|
||||
// stencils/networks.xml, which is where mxgraph.networks.* is defined.
|
||||
const KNOWN = new Set([
|
||||
"mxgraph.networks.server",
|
||||
"mxgraph.networks.router",
|
||||
"mxgraph.networks.cloud",
|
||||
"mxgraph.networks.firewall",
|
||||
"mxgraph.networks.switch",
|
||||
"mxgraph.networks.pc",
|
||||
"mxgraph.networks.laptop",
|
||||
"mxgraph.networks.storage",
|
||||
"mxgraph.networks.modem",
|
||||
"mxgraph.networks.mainframe",
|
||||
]);
|
||||
const used = new Set([...xml.matchAll(/shape=([a-z0-9_.]+)/g)].map((m) => m[1] as string));
|
||||
assert.ok(used.size > 0, "no stencil shapes used at all");
|
||||
for (const shape of used) {
|
||||
assert.ok(KNOWN.has(shape), `'${shape}' is not a stencil draw.io ships`);
|
||||
}
|
||||
});
|
||||
|
||||
test("a resource's shape is fixed by kind, and never varies with its metadata", () => {
|
||||
// The split the whole design rests on: shape says what a thing is, badges say what is
|
||||
// true about it. A gateway that stops translating must still look like a gateway.
|
||||
const routers = [...xml.matchAll(/shape=mxgraph\.networks\.router/g)].length;
|
||||
const diagram = diagramFromDeclaration(scenario);
|
||||
assert.equal(routers, diagram.machines.filter((m) => m.kind === "router").length);
|
||||
assert.equal(
|
||||
[...xml.matchAll(/shape=mxgraph\.networks\.server/g)].length,
|
||||
diagram.machines.filter((m) => m.kind === "machine").length,
|
||||
);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user