'incus list' failed because this shell had no permission to reach the daemon, incusOk returned null, and the caller wrote ?? "[]". So 'mesh-lab list' printed 'no scenario instances standing' -- confidently, about a question it had never managed to ask. The comment on incusOk warns about exactly this, in those words: absence and success made indistinguishable. Three of its own callers then did it. Two listings and the live diagram, which would have drawn an empty scenario rather than fail -- a picture that is confidently wrong, which is worse than none. Anything enumerating what exists now goes through enumerate() and throws. incusOk stays right where failure genuinely means no, like instanceExists, and there is a test holding that line so this does not get over-corrected until nothing can be asked at all. Worth noting 'mesh-lab check' already diagnoses this precise cause, down to 'a session that predates it cannot see it'. The diagnosis existed; the listing just never asked for it.
125 lines
5.8 KiB
TypeScript
125 lines
5.8 KiB
TypeScript
/**
|
|
* 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 { incus, 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);
|
|
|
|
// Not `incusOk(...) ?? "[]"`. A picture is read from what runs (novox/hq ADR 0018), and a read
|
|
// that failed and became an empty list would draw an empty scenario rather than fail — a
|
|
// diagram that is confidently wrong, which is worse than no diagram.
|
|
const json = (await incus(["list", "--format", "json"], 30_000)).stdout.trim() || "[]";
|
|
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 });
|
|
}
|
|
// Outside first, then the segments behind it — the same order the declared picture uses,
|
|
// and what lets the layout place a gateway above the lanes it SERVES rather than below
|
|
// the one it faces. Sorting alphabetically threw that away.
|
|
const outside = config["user.mesh-lab.outside"];
|
|
attachments.sort((a, b) =>
|
|
a.segment === outside ? -1 : b.segment === outside ? 1 : 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 };
|
|
}
|