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:
+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` : ""}`);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user