Files
mesh-lab/src/incus/client.ts
T
jschoubben 2243618f01 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.
2026-08-24 22:53:00 +02:00

232 lines
7.8 KiB
TypeScript

/**
* A thin wrapper over the incus CLI. Thin on purpose: the lab's value is in the scenario
* model, not in re-describing a hypervisor.
*
* Everything here goes through incus's own channel and never over IP. A scenario is a
* closed address space — two scenarios raised from one declaration hold the same
* addresses and must never meet — so the workstation has no route into either, and
* reaching a machine by address would make concurrency impossible in the worst way:
* not with an error, but with one scenario's traffic arriving in another.
*
* See novox/hq 02-DECISIONS/0032-a-scenario-is-an-isolated-address-space.md
*/
import { spawn } from "node:child_process";
/**
* How to invoke incus. Overridable because the socket is group-owned and a session that
* predates the group grant cannot reach it — which is a real thing that happens on the
* machine that just installed it.
*/
const INCUS = (process.env["MESH_LAB_INCUS"] ?? "incus").split(" ").filter(Boolean);
export interface IncusResult {
stdout: string;
stderr: string;
}
export class IncusError extends Error {
readonly args: string[];
readonly stderr: string;
readonly exitCode: number | null;
constructor(args: string[], stderr: string, exitCode: number | null) {
const detail = stderr.trim() || (exitCode === null ? "timed out" : `exit ${exitCode}`);
super(`incus ${args.join(" ")} failed: ${detail}`);
this.name = "IncusError";
this.args = args;
this.stderr = stderr;
this.exitCode = exitCode;
}
}
/**
* Runs incus with **stdin closed**, which is not incidental.
*
* Several incus subcommands accept a YAML definition on stdin and, given a descriptor
* that is not a terminal, wait for one. Under a shell that never happens because stdin is
* a TTY; under a spawned process it hangs until the timeout kills it — and the timeout
* kill produces an empty stderr, so the failure arrives with no explanation at all.
*
* Found exactly that way: the first raise reported "failed: (no output)" on a command
* that worked perfectly when typed.
*/
export async function incus(args: string[], timeoutMs = 60_000): Promise<IncusResult> {
const [command, ...prefix] = INCUS;
if (!command) throw new Error("MESH_LAB_INCUS is empty");
return new Promise((resolve, reject) => {
const child = spawn(command, [...prefix, ...args], {
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGKILL");
}, timeoutMs);
child.stdout.on("data", (chunk) => (stdout += chunk));
child.stderr.on("data", (chunk) => (stderr += chunk));
child.on("error", (err) => {
clearTimeout(timer);
reject(new IncusError(args, err.message, null));
});
child.on("close", (code) => {
clearTimeout(timer);
if (timedOut) {
reject(new IncusError(args, `timed out after ${timeoutMs}ms`, null));
} else if (code === 0) {
resolve({ stdout, stderr });
} else {
reject(new IncusError(args, stderr, code));
}
});
});
}
/**
* Like `incus`, but a failure is an answer rather than an exception. Returns the command's
* output, or `null` if it failed.
*
* **Never truthiness-test this.** Plenty of incus commands succeed with no output at all —
* `exec … true`, `network delete`, `start` — so an empty string means *worked and said
* nothing*, and `if (await incusOk(...))` reads that as failure. Use `succeeds()` when the
* question is whether it worked, and `!== null` when the output matters.
*
* This is the mesh's own recurring fault in miniature: absence and success made
* indistinguishable. It cost a raise that reported a machine unreachable while `incus exec`
* on that machine worked perfectly.
*/
export async function incusOk(args: string[], timeoutMs = 60_000): Promise<string | null> {
try {
return (await incus(args, timeoutMs)).stdout;
} catch {
return null;
}
}
/** Did the command work? For commands whose output is not the point. */
export async function succeeds(args: string[], timeoutMs = 60_000): Promise<boolean> {
return (await incusOk(args, timeoutMs)) !== null;
}
export async function isReachable(): Promise<boolean> {
return succeeds(["info"], 15_000);
}
/** Storage drivers the daemon offers. It only advertises those whose tooling it found. */
export async function supportedDrivers(): Promise<string[]> {
const info = (await incusOk(["info"], 15_000)) ?? "";
const block = info.split("storage_supported_drivers:")[1] ?? "";
return [...block.matchAll(/-\s+name:\s*(\w+)/g)].map((m) => m[1] ?? "");
}
export interface Pool {
name: string;
driver: string;
}
export async function pools(): Promise<Pool[]> {
const csv = (await incusOk(["storage", "list", "--format", "csv"])) ?? "";
return csv
.split("\n")
.filter(Boolean)
.map((line) => {
const [name = "", driver = ""] = line.split(",");
return { name, driver };
});
}
export async function networkExists(name: string): Promise<boolean> {
return succeeds(["network", "show", name], 15_000);
}
export async function instanceExists(name: string): Promise<boolean> {
return succeeds(["config", "show", name], 15_000);
}
export interface TaggedInstance {
name: string;
status: string;
instanceId: string;
machine: string;
}
/**
* Instances this lab owns, identified by the config keys set when they were created —
* never by parsing their names.
*
* Names were parsed at first, splitting on the last dash to separate machine from
* instance. That works until a machine is called something like `home-server`, at which
* point the instance id absorbs half the machine name and `destroy` silently finds
* nothing. Metadata is what the instance actually knows about itself.
*/
export async function taggedInstances(): Promise<TaggedInstance[]> {
const json = (await incusOk(["list", "--format", "json"], 30_000)) ?? "[]";
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch {
return [];
}
if (!Array.isArray(parsed)) return [];
const tagged: TaggedInstance[] = [];
for (const entry of parsed) {
const item = entry as { name?: string; status?: string; config?: Record<string, string> };
const instanceId = item.config?.["user.mesh-lab.instance"];
const machine = item.config?.["user.mesh-lab.machine"];
if (!instanceId || !machine || !item.name) continue;
tagged.push({ name: item.name, status: item.status ?? "", instanceId, machine });
}
return tagged;
}
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[]> {
const json = (await incusOk(["network", "list", "--format", "json"], 30_000)) ?? "[]";
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch {
return [];
}
if (!Array.isArray(parsed)) return [];
const tagged: TaggedNetwork[] = [];
for (const entry of parsed) {
const item = entry as { name?: string; config?: Record<string, string> };
const instanceId = item.config?.["user.mesh-lab.instance"];
const segment = item.config?.["user.mesh-lab.segment"];
if (!instanceId || !segment || !item.name) continue;
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;
}