The lab raises a mesh, draws it, and now places tier 0 inside it #1

Merged
jschoubben merged 11 commits from feat/scenario-lifecycle into main 2026-08-25 23:02:29 +00:00
14 changed files with 993 additions and 37 deletions
Showing only changes of commit 2243618f01 - Show all commits
+55 -10
View File
@@ -37,6 +37,8 @@ mesh-lab exec <instance> <machine> -- <cmd...>
mesh-lab snapshot <instance> <label>
mesh-lab restore <instance> <label>
mesh-lab destroy <instance>
mesh-lab diagram scenarios/x.yml draw what the scenario asks for
mesh-lab diagram --live <instance> draw what is actually standing
```
`check` refuses rather than warns. A machine without copy-on-write storage runs scenarios
@@ -121,8 +123,9 @@ raise a mesh that silently lacks what it declared — that is the fault this lab
(`novox/hq` 04-ISSUES/003: a firewall key declared in five manifests and read by no code, so a
manifest appears to restrict a port and restricts nothing).
`the-ordinary-shape.yml` therefore validates and does not raise. That is the intended state:
it is the topology being built toward, and the tool says exactly what is missing.
No scenario in `scenarios/` declares `place:` yet, so all of them raise. What they raise is
an underlay holding empty machines — correct, and not yet useful for anything, because the
node host that would be placed on them does not exist.
## Measured on a workstation
@@ -170,6 +173,48 @@ These numbers depend entirely on a copy-on-write pool. On `dir` the same snapsho
and a full copy of the disk, and a second one did not finish in two minutes — which is why
`check` refuses rather than warns.
## Drawing one
```
mesh-lab diagram scenarios/the-ordinary-shape.yml what the declaration asks for
mesh-lab diagram --live <instance> what the hypervisor actually holds
```
Both produce draw.io files, laid out the same way — public networks at the top, each private
one below the network it sits behind. Drawing both sources through one layout is the point: a
difference between what was asked for and what exists becomes a difference you can *see*.
Two kinds of symbol, and the split matters:
- the **shape** says what a resource is, and is fixed per kind — a server is always the server
shape, a gateway always the router shape, whatever else is true about it;
- the **badges** say what is true about that particular one, and come entirely from metadata:
`N` translated, `F` forwarding (green yes, red no), `T` mappings expire, `D` refuses inbound,
`C` container, `VM` virtual machine, `▶` running. Each carries the full sentence as a
tooltip, because a one-letter code with no explanation is a private language.
Badges exist because the interesting properties of a network are exactly the ones with no
visual consequence. An address that is translated looks identical to one that is not, until
traffic proves otherwise.
The live drawing reads **only** the hypervisor — the same tags `destroy` uses — and never
re-opens the scenario file. A picture built from the declaration and labelled *as raised*
would report the request as though it were the result, which is the whole failure the pairing
exists to expose. So `raise` records what it applied: a segment's kind and ranges on the link,
a gateway's translation, forwardability and mapping expiry on the gateway, and `inbound: deny`
on the machine.
**Every behavioural tag is written after the thing works, never before.** A tag written when
the resource is created would restate the request; a failed raise leaves its wreckage standing
on purpose, so a picture of that wreckage would badge translation the gateway was never
configured to do. The gateway is tagged after its ruleset is applied, and the machine after
the read-back proves its firewall loaded.
That pairing has already earned itself. Drawn side by side, the live picture showed every
virtual machine holding no addresses at all: a container's interface carries the device's
name, a virtual machine names its own, and joining them by name silently dropped one whole
class of machine. The two pictures disagreed, so the bug was visible in seconds.
## Where the reasoning lives
Design and decisions are in [`novox/hq`](https://git.novox.be/novox/hq), not here:
@@ -186,14 +231,11 @@ This repository carries implementation. It does not carry decisions.
No build step — Node strips the types.
```
npm test the declaration layer, offline
npm run typecheck
```
```
npm test the declaration layer, offline, 40 tests
npm run test:integration real scenarios against a real hypervisor, 10 tests
npm run check typecheck + both — this is the gate
npm test the declaration layer and the diagram, offline, 49 tests
npm run test:integration real scenarios against a real hypervisor, 14 tests
npm run typecheck source and tests both — a test that does not compile is a test
that silently never ran
npm run check typecheck + both suites — this is the gate
```
**A test names the decision it defends** (`novox/hq` ADR 0034). A decision with no test is one
@@ -208,6 +250,9 @@ that will quietly stop being true, and nobody learns that from a document:
| snapshots are whole-scenario | the lifecycle design |
| a public range that is not documentation space is refused | the declaration design |
| a scenario declaring what cannot be materialised is refused | the declaration design |
| the live diagram distinguishes scenery from a node | ADR 0033 |
| the live diagram draws what exists, never what was asked for | the diagram design |
| a picture nobody can open is not a picture | the diagram design |
**Mocking the hypervisor is forbidden.** A fake would assert that the fake behaves as expected,
which is the shape of test this project exists to stop shipping. Integration tests skip with a
+1 -1
View File
@@ -7,7 +7,7 @@
"mesh-lab": "./src/cli.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
"test": "node --test --experimental-strip-types 'test/*.test.ts'",
"test:integration": "node --test --experimental-strip-types 'test/integration/*.test.ts'",
"check": "npm run typecheck && npm test && npm run test:integration"
+21
View File
@@ -10,6 +10,10 @@ import { DeclarationError } from "./declaration/validate.ts";
import { isReachable, supportedDrivers, pools } from "./incus/client.ts";
import { raise, RaiseError } from "./lifecycle/raise.ts";
import { assertSupported, UnsupportedError } from "./lifecycle/supported.ts";
import { diagramFromDeclaration } from "./diagram/from-declaration.ts";
import { diagramFromLive } from "./diagram/from-live.ts";
import { toDrawio } from "./diagram/drawio.ts";
import { writeFileSync } from "node:fs";
import { destroy, exec, list, restore, snapshot, snapshots } from "./lifecycle/operate.ts";
const USAGE = `mesh-lab — raise a disposable mesh on one machine
@@ -23,6 +27,8 @@ const USAGE = `mesh-lab — raise a disposable mesh on one machine
restore <instance> <label> return the whole scenario to it
snapshots <instance>
destroy <instance>
diagram <scenario.yml> [out.drawio] draw what a scenario asks for
diagram --live <instance> [out.drawio] draw what is actually raised
Set MESH_LAB_INCUS if the daemon needs a different invocation, e.g. "sudo -n incus".
`;
@@ -168,6 +174,21 @@ async function main(): Promise<void> {
return;
}
case "diagram": {
const live = rest[0] === "--live";
const target = (live ? rest[1] : rest[0]) ?? fail("diagram needs a scenario file or --live <instance>");
const diagram = live
? await diagramFromLive(target)
: diagramFromDeclaration(loadScenario(target));
const out = (live ? rest[2] : rest[1]) ?? `${diagram.title}.drawio`;
writeFileSync(out, toDrawio(diagram));
console.log(
`${out} — ${diagram.segments.length} segments, ${diagram.machines.length} machines ` +
`(${diagram.source})`,
);
return;
}
case "destroy": {
const instanceId = rest[0] ?? fail("destroy <instance>");
const { machines, networks } = await destroy(instanceId);
+367
View File
@@ -0,0 +1,367 @@
/**
* Render a diagram as draw.io XML.
*
* draw.io rather than a rendered image because the output is **editable**: an automatic
* layout of a network is usually 90% right and needs a human nudge, and a picture nobody
* can adjust gets regenerated rather than corrected.
*
* Laid out as lanes rather than by a generic graph algorithm. A network topology has a
* natural vertical order — public at the top, each private network below the one it sits
* behind — and a force-directed layout throws that away, producing a picture that is
* correct and unreadable.
*
* Two kinds of symbol, on purpose:
*
* - the **shape** says what a resource is, and is fixed per kind — a server is always the
* server shape, a gateway always the router shape;
* - the **badges** say what is true about that particular one, and come entirely from
* metadata: translation, forwardability, mapping expiry, whether it refuses inbound.
*
* Which matters because the interesting properties of a network are exactly the ones with
* no visual consequence. An address that is translated looks identical to one that is not.
*
* Shapes come from draw.io's bundled network library, so the file needs no external images
* and renders anywhere draw.io opens. Badges use core mxGraph primitives rather than icon
* shapes: a stencil name that turns out not to exist renders as an empty box, and a badge
* that silently disappears is worse than a plain one that does not.
*/
import type { Diagram, DiagramMachine } from "./model.ts";
const LANE_MIN_HEIGHT = 170;
const LANE_GAP = 80;
const LANE_X = 40;
const LANE_WIDTH = 980;
const NODE_WIDTH = 150;
const NODE_HEIGHT = 60;
const SLOT_WIDTH = NODE_WIDTH + 60;
const SLOTS_PER_ROW = Math.max(1, Math.floor((LANE_WIDTH - 60) / SLOT_WIDTH));
const BADGE = 18;
const STYLE = {
publicLane:
"rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;dashed=1;" +
"verticalAlign=top;align=left;spacingLeft=10;spacingTop=4;fontSize=11;fontStyle=1",
privateLane:
"rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#999999;dashed=1;" +
"verticalAlign=top;align=left;spacingLeft=10;spacingTop=4;fontSize=11;fontStyle=1",
machine:
"sketch=0;html=1;verticalLabelPosition=bottom;verticalAlign=top;align=center;" +
"shape=mxgraph.networks.server;fillColor=#ffffff;strokeColor=#333333;fontSize=10",
router:
"sketch=0;html=1;verticalLabelPosition=bottom;verticalAlign=top;align=center;" +
"shape=mxgraph.networks.router;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10",
transit:
"sketch=0;html=1;verticalLabelPosition=bottom;verticalAlign=top;align=center;" +
"shape=mxgraph.networks.cloud;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10",
link: "edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=none;strokeColor=#666666",
note: "text;html=1;align=left;verticalAlign=top;fontSize=9;fontColor=#666666",
badge:
"ellipse;whiteSpace=wrap;html=1;fontSize=9;fontStyle=1;fontColor=#ffffff;" +
"verticalAlign=middle;align=center;spacing=0",
};
/** A metadata fact, rendered as a mark on the resource it is a fact about. */
interface Badge {
code: string;
fill: string;
stroke: string;
/** The full sentence, shown on hover — the code alone would be a private language. */
tip: string;
}
const COLOUR = {
amber: { fill: "#d79b00", stroke: "#b07000" },
red: { fill: "#b85450", stroke: "#8c3a37" },
green: { fill: "#82b366", stroke: "#5b8047" },
blue: { fill: "#6c8ebf", stroke: "#4b6a94" },
grey: { fill: "#9e9e9e", stroke: "#757575" },
};
/**
* Turn a machine's recorded facts into marks.
*
* Absence is deliberately not a badge. A gateway that does not translate gets no NAT mark
* rather than a struck-through one, because a diagram that badges every negative is a
* diagram nobody reads.
*/
function badgesFor(machine: DiagramMachine): { badges: Badge[]; remaining: string[] } {
const badges: Badge[] = [];
const remaining: string[] = [];
if (machine.status) {
const running = machine.status.toLowerCase() === "running";
badges.push({
code: running ? "▶" : "■",
...(running ? COLOUR.green : COLOUR.grey),
tip: `status: ${machine.status}`,
});
}
for (const note of machine.notes) {
const nat = /^NAT (.+)$/.exec(note);
const ttl = /^mappings expire (\d+)s$/.exec(note);
if (nat) {
badges.push({ code: "N", ...COLOUR.amber, tip: `translates ${nat[1]} — addresses behind it are not seen outside` });
} else if (note === "NOT forwardable") {
badges.push({ code: "F", ...COLOUR.red, tip: "no port forwarding — nothing behind this gateway is reachable from outside" });
} else if (note === "forwardable") {
badges.push({ code: "F", ...COLOUR.green, tip: "port forwarding available" });
} else if (ttl) {
badges.push({ code: "T", ...COLOUR.blue, tip: `mappings expire after ${ttl[1]}s of no traffic` });
} else if (note === "refuses inbound") {
badges.push({ code: "D", ...COLOUR.red, tip: "refuses inbound connections it did not start" });
} else if (note === "container") {
badges.push({ code: "C", ...COLOUR.grey, tip: "container — scenery, nothing under test runs here" });
} else if (note === "virtual machine") {
badges.push({ code: "VM", ...COLOUR.blue, tip: "virtual machine — its own kernel" });
} else {
remaining.push(note);
}
}
return { badges, remaining };
}
function escapeXml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function shapeFor(machine: DiagramMachine): string {
return machine.kind === "router"
? STYLE.router
: machine.kind === "transit"
? STYLE.transit
: STYLE.machine;
}
interface Cell {
id: string;
value: string;
style: string;
x: number;
y: number;
w: number;
h: number;
/** Rendered as a wrapping <object>, which is how draw.io carries a tooltip. */
tip?: string;
}
export function toDrawio(diagram: Diagram): string {
const cells: Cell[] = [];
const edges: { id: string; source: string; target: string }[] = [];
// Lanes, ordered by depth: public first, then each level of private network below it.
const lanes = [...diagram.segments].sort(
(a, b) => a.depth - b.depth || a.name.localeCompare(b.name),
);
// A lane is sized to what it holds. Fixed heights meant a lane with enough machines to
// wrap onto a second row drew that row outside the box it was supposed to be inside.
const occupants = new Map<string, number>();
for (const machine of diagram.machines) {
const first = machine.attachments[0]?.segment;
if (first) occupants.set(first, (occupants.get(first) ?? 0) + 1);
}
const laneHeight = (name: string): number => {
const rows = Math.max(1, Math.ceil((occupants.get(name) ?? 0) / SLOTS_PER_ROW));
return Math.max(LANE_MIN_HEIGHT, 45 + rows * (NODE_HEIGHT + 45));
};
const laneY = new Map<string, number>();
const laneH = new Map<string, number>();
let cursor = 80;
lanes.forEach((segment) => {
const y = cursor;
const h = laneHeight(segment.name);
cursor = y + h + LANE_GAP;
laneY.set(segment.name, y);
laneH.set(segment.name, h);
const facts = [
segment.cidr.join(" "),
segment.mtu ? `MTU ${segment.mtu}` : "",
segment.behind ? `behind ${segment.behind}` : segment.kind === "public" ? "public" : "isolated",
].filter(Boolean);
cells.push({
id: `lane-${segment.name}`,
value: `<b>${segment.name}</b><br/><font style="font-size:10px">${facts.join(" · ")}</font>`,
style: segment.kind === "public" ? STYLE.publicLane : STYLE.privateLane,
x: LANE_X,
y,
w: LANE_WIDTH,
h,
});
});
// Cell ids are numbered, not named. A declared machine is free to be called `gw-home`,
// which is also what the gateway serving `home` is called — two cells sharing an id makes
// a file draw.io opens with one of them missing, silently.
const idOf = new Map<DiagramMachine, string>();
diagram.machines.forEach((machine, index) => idOf.set(machine, `m${index}`));
// A machine sits inside its first lane. A router straddles, so it is placed between the
// lanes it joins — which is what makes the picture readable at a glance.
const perLane = new Map<string, number>();
let detachedSlot = 0;
for (const machine of diagram.machines) {
const id = idOf.get(machine)!;
const { badges, remaining } = badgesFor(machine);
let x: number;
let y = 80;
if (machine.attachments.length === 0) {
// Detached: parked to the side, because it genuinely is nowhere.
x = LANE_X + LANE_WIDTH + 60;
y = 80 + detachedSlot * (NODE_HEIGHT + 50);
detachedSlot++;
} else {
const first = machine.attachments[0]!;
const spans =
machine.attachments.filter((a) => laneY.has(a.segment)).length > 1;
const slot = perLane.get(first.segment) ?? 0;
perLane.set(first.segment, slot + 1);
// Wrap onto a second row rather than running off the end of the lane. An earlier
// version placed slot 5 outside the box it was supposed to be inside.
const column = slot % SLOTS_PER_ROW;
const row = Math.floor(slot / SLOTS_PER_ROW);
x = LANE_X + 40 + column * SLOT_WIDTH;
if (spans) {
// A gateway straddles, so it sits in the gap below the highest lane it joins —
// which is what makes at-a-glance reading of "this is the way in" work.
const top = machine.attachments
.map((a) => a.segment)
.filter((name) => laneY.has(name))
.reduce((best, name) => ((laneY.get(name) ?? 0) < (laneY.get(best) ?? 0) ? name : best));
y = (laneY.get(top) ?? 80) + (laneH.get(top) ?? LANE_MIN_HEIGHT) + LANE_GAP / 2 - NODE_HEIGHT / 2;
} else {
y = (laneY.get(first.segment) ?? 80) + 45 + row * (NODE_HEIGHT + 45);
}
}
const addresses = machine.attachments
.flatMap((a) => a.addresses)
.slice(0, 3)
.join("<br/>");
const label =
`<b>${machine.name}</b>` +
(addresses ? `<br/><font style="font-size:9px">${addresses}</font>` : "");
cells.push({
id,
value: label,
style: shapeFor(machine),
x,
y,
w: NODE_WIDTH,
h: NODE_HEIGHT,
tip: machine.attachments
.map((a) => `${a.segment}${a.addresses.length ? `: ${a.addresses.join(", ")}` : ""}`)
.join(" · "),
});
// Badges sit along the top edge, right to left, so the first fact stated is nearest the
// resource's own corner and the row grows away from the label underneath it.
badges.forEach((badge, index) => {
cells.push({
id: `${id}-b${index}`,
value: badge.code,
style: `${STYLE.badge};fillColor=${badge.fill};strokeColor=${badge.stroke}`,
x: x + NODE_WIDTH - BADGE - index * (BADGE + 3),
y: y - BADGE / 2,
w: BADGE,
h: BADGE,
tip: badge.tip,
});
});
if (remaining.length > 0) {
cells.push({
id: `${id}-n`,
value: remaining.join("<br/>"),
style: STYLE.note,
x,
y: y + NODE_HEIGHT + 4,
w: NODE_WIDTH + 60,
h: 13 * remaining.length,
});
}
for (const attachment of machine.attachments) {
if (!laneY.has(attachment.segment)) continue;
edges.push({ id: `${id}-e-${attachment.segment}`, source: id, target: `lane-${attachment.segment}` });
}
}
const header =
`<b>${diagram.title}</b> — ${diagram.source === "live" ? "as raised" : "as declared"}`;
cells.unshift({ id: "title", value: header, style: STYLE.note + ";fontSize=14", x: LANE_X, y: 30, w: 600, h: 24 });
const legend = [
"<b>badges</b> — read from metadata, not from the file that asked for it",
"N translates · F forwarding (green yes, red no) · T mappings expire",
"D refuses inbound · C container · VM virtual machine · ▶ running",
].join("<br/>");
cells.push({
id: "legend",
value: legend,
style: STYLE.note,
x: LANE_X,
y: cursor + 10,
w: LANE_WIDTH,
h: 48,
});
// A cell with a tooltip has to be wrapped in <object> — draw.io reads `tooltip` from the
// wrapper, never from mxCell itself, and putting it on the mxCell loses it without error.
const vertex = (c: Cell): string => {
const geometry =
` <mxGeometry x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}" as="geometry" />\n`;
if (c.tip) {
return (
` <object id="${c.id}" label="${escapeXml(c.value)}" tooltip="${escapeXml(c.tip)}">\n` +
` <mxCell style="${c.style}" vertex="1" parent="1">\n` +
` ${geometry}` +
` </mxCell>\n` +
` </object>`
);
}
// The label is HTML and this is an XML attribute, so the whole thing is escaped here —
// exactly once. Escaping the pieces and concatenating raw tags is what made the first
// file unparseable.
return (
` <mxCell id="${c.id}" value="${escapeXml(c.value)}" style="${c.style}" vertex="1" parent="1">\n` +
geometry +
` </mxCell>`
);
};
const body = [
...cells.map(vertex),
...edges.map(
(e) =>
` <mxCell id="${e.id}" style="${STYLE.link}" edge="1" parent="1" source="${e.source}" target="${e.target}">\n` +
` <mxGeometry relative="1" as="geometry" />\n` +
` </mxCell>`,
),
].join("\n");
return `<mxfile host="mesh-lab">
<diagram name="${escapeXml(diagram.title)}">
<mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" page="1" pageWidth="1169" pageHeight="826">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
${body}
</root>
</mxGraphModel>
</diagram>
</mxfile>
`;
}
+68
View File
@@ -0,0 +1,68 @@
/** The picture of what a scenario asks for. Needs nothing running. */
import type { Scenario } from "../declaration/types.ts";
import { planRouters, routerMachineName, ttlSeconds } from "../lifecycle/router.ts";
import { depthOf, type Diagram, type DiagramMachine, type DiagramSegment } from "./model.ts";
export function diagramFromDeclaration(scenario: Scenario): Diagram {
const parentOf = (name: string) => scenario.segments[name]?.gateway?.to;
const segments: DiagramSegment[] = Object.entries(scenario.segments).map(([name, segment]) => ({
name,
kind: segment.kind,
cidr: segment.cidr,
mtu: segment.mtu,
behind: segment.gateway?.to,
depth: depthOf(name, parentOf),
}));
const machines: DiagramMachine[] = Object.entries(scenario.machines).map(([name, spec]) => {
const notes: string[] = [];
if (spec.inbound === "deny") notes.push("refuses inbound");
for (const publication of spec.published ?? []) {
notes.push(`published :${publication.port} via ${publication.on}`);
}
return {
name,
kind: "machine" as const,
notes,
attachments:
spec.at === "detached"
? []
: spec.at.map((a) => ({ segment: a.segment, addresses: a.address })),
};
});
// Routers are implicit in a declaration — a scenario says a segment sits behind one and
// never names it. The diagram has to show them anyway, or the picture omits the machines
// that carry every interesting property.
for (const plan of planRouters(scenario, "diagram")) {
const notes: string[] = [];
notes.push(plan.nat.length ? `NAT ${plan.nat.join("+")}` : "routed, no NAT");
notes.push(plan.forwardable ? "forwardable" : "NOT forwardable");
const ttl = ttlSeconds(plan.mappingTtl);
if (ttl !== undefined) notes.push(`mappings expire ${ttl}s`);
machines.push({
name: routerMachineName(plan),
kind: "router",
notes,
attachments: [
{ segment: plan.outside, addresses: plan.outsideAddresses },
...plan.inside.map((segment) => ({ segment, addresses: [] })),
],
});
}
const publicSegments = segments.filter((s) => s.kind === "public");
if (publicSegments.length > 1) {
machines.push({
name: "transit",
kind: "transit",
notes: ["routed, never bridged"],
attachments: publicSegments.map((s) => ({ segment: s.name, addresses: [] })),
});
}
return { title: scenario.scenario, source: "declared", segments, machines };
}
+115
View File
@@ -0,0 +1,115 @@
/**
* The picture of what is actually raised, read from the hypervisor's own metadata.
*
* Deliberately reads the same tags `destroy` uses rather than re-deriving anything from the
* declaration: a diagram built from the declaration would draw what was asked for and call
* it what exists, which is the whole failure this pairing is meant to expose. Everything
* shown here was either recorded on the resource when it was raised, or is being reported
* by the running machine now — nothing is inferred from a file on disk.
*/
import { incusOk, taggedNetworks } from "../incus/client.ts";
import { depthOf, type Diagram, type DiagramMachine, type DiagramSegment } from "./model.ts";
interface RawInstance {
name?: string;
type?: string;
status?: string;
config?: Record<string, string>;
devices?: Record<string, Record<string, string>>;
state?: {
network?: Record<
string,
{ hwaddr?: string; addresses?: { family?: string; address?: string; netmask?: string; scope?: string }[] }
>;
};
}
export async function diagramFromLive(instanceId: string): Promise<Diagram> {
const networks = (await taggedNetworks()).filter((n) => n.instanceId === instanceId);
const json = (await incusOk(["list", "--format", "json"], 30_000)) ?? "[]";
const parsed = JSON.parse(json) as RawInstance[];
const mine = parsed.filter((i) => i.config?.["user.mesh-lab.instance"] === instanceId);
if (mine.length === 0 && networks.length === 0) throw new Error(`no scenario instance '${instanceId}'`);
const segmentOfLink = new Map(networks.map((n) => [n.name, n.segment]));
// A gateway records the segments behind it, so the tree is recoverable from the routers
// alone. Without this every segment would draw at the same depth and a picture of a
// layered scenario would look flat — which is exactly the property under test.
const parent = new Map<string, string>();
for (const item of mine) {
const inside = item.config?.["user.mesh-lab.router"];
const outside = item.config?.["user.mesh-lab.outside"];
if (!inside || !outside) continue;
for (const segment of inside.split(",").filter(Boolean)) parent.set(segment, outside);
}
const parentOf = (name: string) => parent.get(name);
const segments: DiagramSegment[] = networks.map((n) => ({
name: n.segment,
// Untagged links come from an instance raised before segment shape was recorded. Drawn
// as private rather than guessed at, and the missing tag is said out loud on the lane.
kind: n.kind ?? "private",
cidr: n.cidr,
mtu: n.mtu,
behind: parentOf(n.segment),
depth: depthOf(n.segment, parentOf),
}));
const machines: DiagramMachine[] = mine.map((item) => {
const config = item.config ?? {};
const isTransit = config["user.mesh-lab.transit"] !== undefined;
const isRouter = config["user.mesh-lab.router"] !== undefined;
// Addresses are joined to devices by MAC, not by name. A container's interface is
// called what the device is called; a virtual machine names its own — `enp5s0` for the
// device configured as `eth0` — so matching on the name attached every address to a
// container and none to a VM, which read as machines that had failed to come up.
const heldByMac = new Map<string, string[]>();
const heldByName = new Map<string, string[]>();
for (const [name, iface] of Object.entries(item.state?.network ?? {})) {
const held = (iface.addresses ?? [])
.filter((a) => a.scope === "global" && a.address)
.map((a) => (a.netmask ? `${a.address}/${a.netmask}` : (a.address as string)));
if (held.length === 0) continue;
heldByName.set(name, held);
if (iface.hwaddr) heldByMac.set(iface.hwaddr.toLowerCase(), held);
}
const attachments: DiagramMachine["attachments"] = [];
for (const [device, spec] of Object.entries(item.devices ?? {})) {
if (spec["type"] !== "nic") continue;
const segment = segmentOfLink.get(spec["parent"] ?? "");
if (!segment) continue;
const mac = spec["hwaddr"]?.toLowerCase();
const addresses = (mac ? heldByMac.get(mac) : undefined) ?? heldByName.get(device) ?? [];
attachments.push({ segment, addresses });
}
attachments.sort((a, b) => a.segment.localeCompare(b.segment));
const notes: string[] = [];
notes.push(item.type === "container" ? "container" : "virtual machine");
if (config["user.mesh-lab.inbound"] === "deny") notes.push("refuses inbound");
if (isRouter) {
const nat = (config["user.mesh-lab.nat"] ?? "").split(",").filter(Boolean);
notes.push(nat.length > 0 ? `NAT ${nat.join("+")}` : "routed, no NAT");
if (config["user.mesh-lab.forwardable"] !== undefined) {
notes.push(config["user.mesh-lab.forwardable"] === "true" ? "forwardable" : "NOT forwardable");
}
const ttl = config["user.mesh-lab.mapping-ttl"];
if (ttl) notes.push(`mappings expire ${ttl}s`);
}
return {
name: config["user.mesh-lab.machine"] ?? item.name ?? "?",
kind: isTransit ? "transit" : isRouter ? "router" : "machine",
notes,
attachments,
...(item.status ? { status: item.status } : {}),
};
});
return { title: instanceId, source: "live", segments, machines };
}
+54
View File
@@ -0,0 +1,54 @@
/**
* What a diagram draws, independent of where it came from.
*
* Two sources produce one of these: a declaration, and a raised instance. Drawing both
* through the same model and the same layout is the point — a difference between the
* picture of what was asked for and the picture of what exists is a difference you can
* see, which is the check this project keeps finding absent everywhere else.
*/
export interface DiagramSegment {
name: string;
kind: "public" | "private";
cidr: string[];
mtu: number | undefined;
/** Parent segment, for a private network behind a gateway. */
behind: string | undefined;
/** Depth from a public segment. Public is 0. */
depth: number;
}
export interface DiagramMachine {
name: string;
/** Segments it sits on, with the addresses it holds there. */
attachments: { segment: string; addresses: string[] }[];
kind: "machine" | "router" | "transit";
/** Badges rendered on the node: NAT, forwardable, TTL, refuses inbound. */
notes: string[];
/** Present only for a live diagram — what the hypervisor reports. */
status?: string;
}
export interface Diagram {
title: string;
/** Where this came from, so a reader never has to guess which picture they hold. */
source: "declared" | "live";
segments: DiagramSegment[];
machines: DiagramMachine[];
}
/** Depth of a segment: how many gateways lie between it and a public network. */
export function depthOf(
name: string,
parentOf: (segment: string) => string | undefined,
): number {
let depth = 0;
let current = parentOf(name);
const seen = new Set([name]);
while (current && !seen.has(current)) {
seen.add(current);
depth++;
current = parentOf(current);
}
return depth;
}
+16 -1
View File
@@ -192,6 +192,10 @@ export interface TaggedNetwork {
name: string;
instanceId: string;
segment: string;
/** Absent on a link raised before segment shape was recorded. */
kind?: "public" | "private";
cidr: string[];
mtu?: number;
}
export async function taggedNetworks(): Promise<TaggedNetwork[]> {
@@ -210,7 +214,18 @@ export async function taggedNetworks(): Promise<TaggedNetwork[]> {
const instanceId = item.config?.["user.mesh-lab.instance"];
const segment = item.config?.["user.mesh-lab.segment"];
if (!instanceId || !segment || !item.name) continue;
tagged.push({ name: item.name, instanceId, segment });
const kind = item.config?.["user.mesh-lab.kind"];
const cidr = item.config?.["user.mesh-lab.cidr"];
tagged.push({
name: item.name,
instanceId,
segment,
...(kind === "public" || kind === "private" ? { kind } : {}),
cidr: cidr ? cidr.split(",").filter(Boolean) : [],
...(Number.isFinite(Number(item.config?.["user.mesh-lab.mtu"]))
? { mtu: Number(item.config?.["user.mesh-lab.mtu"]) }
: {}),
});
}
return tagged;
}
+5 -1
View File
@@ -12,7 +12,7 @@
*/
import type { Scenario } from "../declaration/types.ts";
import { incus } from "../incus/client.ts";
import { incus, succeeds } from "../incus/client.ts";
const RULESET = `flush ruleset
table inet mlab {
@@ -54,6 +54,10 @@ export async function applyHostFirewalls(
`would accept traffic the scenario says it refuses`,
);
}
// Recorded only after the read-back proved it loaded. A tag written before the check
// would be a claim rather than a record, and anything reading the instance back would
// report a defended machine that is in fact wide open.
await succeeds(["config", "set", name, "user.mesh-lab.inbound", "deny"], 20_000);
log(` ${machine} refuses unsolicited inbound`);
}
}
+15 -4
View File
@@ -14,7 +14,7 @@
* See novox/hq 03-DESIGN/01-to-be/03-scenario-lifecycle.md
*/
import type { Scenario } from "../declaration/types.ts";
import type { Scenario, Segment } from "../declaration/types.ts";
import { incus, incusOk, succeeds, pools, supportedDrivers } from "../incus/client.ts";
import { machineName, macFor, networkName, newInstanceId } from "./names.ts";
import { waitUntilAllUsable } from "./ready.ts";
@@ -94,7 +94,11 @@ async function choosePool(log: (m: string) => void): Promise<string> {
* a hypervisor's DHCP assign addresses would be the lab supplying facts the declaration
* is supposed to own.
*/
async function createNetwork(instanceId: string, segment: string): Promise<string> {
async function createNetwork(
instanceId: string,
segment: string,
spec: Segment,
): Promise<string> {
const name = networkName(instanceId, segment);
if (await succeeds(["network", "show", name], 15_000)) return name;
await incus([
@@ -105,6 +109,13 @@ async function createNetwork(instanceId: string, segment: string): Promise<strin
"ipv6.nat=false",
`user.mesh-lab.instance=${instanceId}`,
`user.mesh-lab.segment=${segment}`,
// Whether a segment is public and which ranges it carries are facts a link cannot be
// asked for afterwards — incus knows only that it is an isolated bridge. Recorded here
// so anything reading a raised instance back reads what was applied, rather than
// re-opening the declaration and reporting the request as though it were the result.
`user.mesh-lab.kind=${spec.kind}`,
`user.mesh-lab.cidr=${spec.cidr.join(",")}`,
...(spec.mtu === undefined ? [] : [`user.mesh-lab.mtu=${spec.mtu}`]),
]);
return name;
}
@@ -170,8 +181,8 @@ export async function raise(
step = "creating segments";
const networks: string[] = [];
for (const segment of Object.keys(scenario.segments)) {
networks.push(await createNetwork(instanceId, segment));
for (const [segment, spec] of Object.entries(scenario.segments)) {
networks.push(await createNetwork(instanceId, segment, spec));
log(` segment ${segment}`);
}
+19
View File
@@ -298,6 +298,9 @@ export async function raiseRouters(
// then held its networks open, so `destroy` reported removing zero segments.
"-c", `user.mesh-lab.machine=${routerMachineName(plan)}`,
"-c", `user.mesh-lab.router=${plan.inside.join(",")}`,
// `outside` is structural — it is which link eth0 is on, true the moment the device
// is added. The gateway's *behaviour* is not recorded here; see configureRouter.
"-c", `user.mesh-lab.outside=${plan.outside}`,
], 300_000);
await succeeds(["config", "device", "remove", plan.name, "eth0"], 15_000);
@@ -408,6 +411,22 @@ async function configureRouter(
}
await applyRules(scenario, plan);
// Recorded last, and only here. Everything above either read itself back or threw, so a
// gateway carrying these tags is one that demonstrably does these things. Written at
// `init` they would have been a restatement of the request — and a raise that failed
// half way leaves its wreckage standing on purpose, so a picture of that wreckage would
// have badged translation the router was never configured to do.
const recorded = [
`user.mesh-lab.nat=${plan.nat.join(",")}`,
`user.mesh-lab.forwardable=${plan.forwardable}`,
...(ttl === undefined ? [] : [`user.mesh-lab.mapping-ttl=${ttl}`]),
];
for (const entry of recorded) {
const at = entry.indexOf("=");
await succeeds(["config", "set", plan.name, entry.slice(0, at), entry.slice(at + 1)], 20_000);
}
log(` ${plan.name}: nat=${plan.nat.join(",") || "none"} forwardable=${plan.forwardable}${ttl ? ` ttl=${ttl}s` : ""}`);
}
+149
View File
@@ -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,
);
});
+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 });
});
+9
View File
@@ -0,0 +1,9 @@
{
// The tests were outside the typecheck gate, so a test that did not compile was simply a
// test that never ran — silently, which is the failure mode this project keeps finding.
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}