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:
2026-08-24 22:53:00 +02:00
parent ca2bbab836
commit 2243618f01
14 changed files with 993 additions and 37 deletions
+115
View File
@@ -0,0 +1,115 @@
/**
* The picture of what is actually raised, read from the hypervisor's own metadata.
*
* Deliberately reads the same tags `destroy` uses rather than re-deriving anything from the
* declaration: a diagram built from the declaration would draw what was asked for and call
* it what exists, which is the whole failure this pairing is meant to expose. Everything
* shown here was either recorded on the resource when it was raised, or is being reported
* by the running machine now — nothing is inferred from a file on disk.
*/
import { incusOk, taggedNetworks } from "../incus/client.ts";
import { depthOf, type Diagram, type DiagramMachine, type DiagramSegment } from "./model.ts";
interface RawInstance {
name?: string;
type?: string;
status?: string;
config?: Record<string, string>;
devices?: Record<string, Record<string, string>>;
state?: {
network?: Record<
string,
{ hwaddr?: string; addresses?: { family?: string; address?: string; netmask?: string; scope?: string }[] }
>;
};
}
export async function diagramFromLive(instanceId: string): Promise<Diagram> {
const networks = (await taggedNetworks()).filter((n) => n.instanceId === instanceId);
const json = (await incusOk(["list", "--format", "json"], 30_000)) ?? "[]";
const parsed = JSON.parse(json) as RawInstance[];
const mine = parsed.filter((i) => i.config?.["user.mesh-lab.instance"] === instanceId);
if (mine.length === 0 && networks.length === 0) throw new Error(`no scenario instance '${instanceId}'`);
const segmentOfLink = new Map(networks.map((n) => [n.name, n.segment]));
// A gateway records the segments behind it, so the tree is recoverable from the routers
// alone. Without this every segment would draw at the same depth and a picture of a
// layered scenario would look flat — which is exactly the property under test.
const parent = new Map<string, string>();
for (const item of mine) {
const inside = item.config?.["user.mesh-lab.router"];
const outside = item.config?.["user.mesh-lab.outside"];
if (!inside || !outside) continue;
for (const segment of inside.split(",").filter(Boolean)) parent.set(segment, outside);
}
const parentOf = (name: string) => parent.get(name);
const segments: DiagramSegment[] = networks.map((n) => ({
name: n.segment,
// Untagged links come from an instance raised before segment shape was recorded. Drawn
// as private rather than guessed at, and the missing tag is said out loud on the lane.
kind: n.kind ?? "private",
cidr: n.cidr,
mtu: n.mtu,
behind: parentOf(n.segment),
depth: depthOf(n.segment, parentOf),
}));
const machines: DiagramMachine[] = mine.map((item) => {
const config = item.config ?? {};
const isTransit = config["user.mesh-lab.transit"] !== undefined;
const isRouter = config["user.mesh-lab.router"] !== undefined;
// Addresses are joined to devices by MAC, not by name. A container's interface is
// called what the device is called; a virtual machine names its own — `enp5s0` for the
// device configured as `eth0` — so matching on the name attached every address to a
// container and none to a VM, which read as machines that had failed to come up.
const heldByMac = new Map<string, string[]>();
const heldByName = new Map<string, string[]>();
for (const [name, iface] of Object.entries(item.state?.network ?? {})) {
const held = (iface.addresses ?? [])
.filter((a) => a.scope === "global" && a.address)
.map((a) => (a.netmask ? `${a.address}/${a.netmask}` : (a.address as string)));
if (held.length === 0) continue;
heldByName.set(name, held);
if (iface.hwaddr) heldByMac.set(iface.hwaddr.toLowerCase(), held);
}
const attachments: DiagramMachine["attachments"] = [];
for (const [device, spec] of Object.entries(item.devices ?? {})) {
if (spec["type"] !== "nic") continue;
const segment = segmentOfLink.get(spec["parent"] ?? "");
if (!segment) continue;
const mac = spec["hwaddr"]?.toLowerCase();
const addresses = (mac ? heldByMac.get(mac) : undefined) ?? heldByName.get(device) ?? [];
attachments.push({ segment, addresses });
}
attachments.sort((a, b) => a.segment.localeCompare(b.segment));
const notes: string[] = [];
notes.push(item.type === "container" ? "container" : "virtual machine");
if (config["user.mesh-lab.inbound"] === "deny") notes.push("refuses inbound");
if (isRouter) {
const nat = (config["user.mesh-lab.nat"] ?? "").split(",").filter(Boolean);
notes.push(nat.length > 0 ? `NAT ${nat.join("+")}` : "routed, no NAT");
if (config["user.mesh-lab.forwardable"] !== undefined) {
notes.push(config["user.mesh-lab.forwardable"] === "true" ? "forwardable" : "NOT forwardable");
}
const ttl = config["user.mesh-lab.mapping-ttl"];
if (ttl) notes.push(`mappings expire ${ttl}s`);
}
return {
name: config["user.mesh-lab.machine"] ?? item.name ?? "?",
kind: isTransit ? "transit" : isRouter ? "router" : "machine",
notes,
attachments,
...(item.status ? { status: item.status } : {}),
};
});
return { title: instanceId, source: "live", segments, machines };
}