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:
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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>
|
||||
`;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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` : ""}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user