The lab raises a mesh, draws it, and now places tier 0 inside it #1
@@ -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