Files
mesh-lab/src/declaration/types.ts
T
jschoubben a27d861d3b Scenario lifecycle: raise, exec, snapshot, restore, destroy
A declaration goes in and a disposable mesh comes out. Verified on a
workstation, not asserted: two machines raised and addressed in 14.6s,
snapshot 0.28s, restore-to-usable 11.6s, both families pinging with no
loss, and the workstation with no route into any of it.

The declaration layer implements the model in full — three positions a
machine can be in, keyed on forwardability; gateways carrying the address
the world sees them as; both address families; multi-homing; MTU;
inter-segment policy. It is validated hard because the failures it prevents
are silent: a private range on a public segment produces no error, the mesh
simply never forms. Public segments are refused unless they use RFC 5737 or
RFC 3849 space, and a range wider than the reserved block is refused too.
33 tests, all offline.

The runtime implements less than the model, and refuses the difference.
A scenario declaring gateways, published ports, policy, inbound deny or
place is rejected at raise with every gap named. Raising it would produce a
mesh that silently lacks what it declared, which is the fault this lab
exists to catch — 04-ISSUES/003, where a firewall key is declared in five
manifests and read by no code.

Three bugs found by review and by running it, all of one family:

The readiness check truthiness-tested incusOk's return. `exec … true`
succeeds with EMPTY output, so every machine reported unreachable while
incus exec on it worked perfectly. succeeds() now exists so the mistake is
not available, and network delete had the same bug — it counted zero
segments removed while removing them.

list() split instance from machine on the last dash, so a machine called
home-server absorbed half the instance id and destroy found nothing.
Resources are now found by the metadata they carry, never by name.

restore reported success in 0.79s while the machine's agent was still
starting, so the next command failed. Both raise and restore now wait for
usable and say how long that took — reporting the earlier number is
transport reported as effect, which is the fault the lab is being built to
find.

Two incus behaviours worth recording. Its CLI reads a YAML definition from
stdin when stdin is not a terminal, so a spawned command hangs until the
timeout kills it and arrives with empty stderr — a failure with no
explanation, on a command that works when typed. And it assigns a MAC at
runtime without recording it in device config, so MACs are derived and set
explicitly, which the guest needs anyway: it names interfaces by bus
position, and matching by name configures the wrong one on a multi-homed
machine.

No build step; Node strips the types. The lifecycle has no unit tests
because a fake hypervisor would assert that the fake behaves as expected,
which is the shape of test this project exists to stop shipping.
2026-08-24 01:12:49 +02:00

115 lines
4.1 KiB
TypeScript

/**
* A scenario declares an UNDERLAY and what to place on it — the facts a machine would
* have before any of our software touched it. It declares nothing the mesh is
* responsible for: no overlay addresses, no hub, no peering, no names, no certificates.
* Those are outcomes to observe, and a scenario that supplied them would be certifying
* its own work.
*
* See novox/hq: 02-DECISIONS/0031-the-lab-provides-the-underlay.md
* 03-DESIGN/01-to-be/02-scenario-declaration.md
*/
/** An IP family. Reachability is a property of (machine, family), never of a machine. */
export type Family = "v4" | "v6";
/**
* How a segment reaches its parent.
*
* `address` is the address the outside world sees the network as — for a household
* connection, what the ISP hands out. It is load-bearing rather than decorative: it is
* what a peer records as an endpoint when a machine here dials out, and what a public
* name for a published machine here resolves to.
*/
export interface Gateway {
/** Parent segment name. */
to: string;
/** Addresses the gateway holds on the parent segment, one per family. */
address: string[];
/**
* Which families are translated. `["v4"]` is the modern default — v4 translated, v6
* routed. `[]` is a routed range where machines keep their own addresses.
*/
nat: Family[];
/**
* Whether an inbound mapping can be created. Independent of `nat`, and the field that
* separates a home gateway from carrier-grade NAT — which is your own connection and
* still unforwardable.
*/
forwardable: boolean;
/**
* How long an unused inbound mapping survives, e.g. "120s". Absent means mappings never
* expire, which no real gateway does — so absence is a simplification, not a default.
*/
mappingTtl?: string;
}
/** A broadcast domain. Several public segments are unrelated and routed, never bridged. */
export interface Segment {
/**
* `public` stands in for a public network — and there is normally more than one,
* unrelated to each other. `private` is everything else; a private segment with no
* gateway is an island that reaches nothing.
*/
kind: "public" | "private";
/** Address ranges, one per family. */
cidr: string[];
/** Largest packet the segment carries. Default 1500. Lower reproduces tunnelled paths. */
mtu?: number;
gateway?: Gateway;
}
/** Where a machine sits: a segment and the addresses it holds there. */
export interface Attachment {
segment: string;
address: string[];
}
/** A destination-NAT rule on a named gateway, stated as an outcome rather than a port list. */
export interface Publication {
port: number;
/** The segment whose gateway forwards. Named, because a machine may sit behind several. */
on: string;
}
export interface Machine {
/**
* One attachment, or several for a machine on multiple segments at once. Multi-homing
* is not exotic: it is what any node with both a LAN and a WAN interface is.
* `"detached"` is a machine on no segment — it exists and reaches nothing.
*/
at: Attachment[] | "detached";
published?: Publication[];
/**
* A host firewall. Distinct from NAT and behaves differently: a machine can be perfectly
* routable and still refuse everything unsolicited, which is the normal state of a
* v6-addressed machine. Without this, v6 addressing would imply reachability.
*/
inbound?: "allow" | "deny";
}
/** Reachability between segments, as a segmented router enforces it. Asymmetric by design. */
export interface Policy {
from: string;
to: string;
allow: boolean;
}
/** What goes inside the machines. The ONLY part that differs between scenario classes. */
export interface Placement {
/** Applied to every machine. */
all?: string[];
/** Per-machine, overriding `all` for that machine. */
[machine: string]: string[] | undefined;
}
export interface Scenario {
/** The kind. Instances are many; this names the shape, not one of them. */
scenario: string;
segments: Record<string, Segment>;
machines: Record<string, Machine>;
policy?: Policy[];
place?: Placement;
/** Name the state once placement finishes, so a run can return to it. */
snapshot?: string;
}