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.
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Address parsing, enough to answer two questions the validator asks: which family is
|
||||
* this, and is it inside that range.
|
||||
*
|
||||
* Written rather than depended on because it is small, and because the one rule it
|
||||
* exists to enforce — public segments use documentation ranges — is the difference
|
||||
* between a lab that reproduces the internet and one that silently never forms a mesh.
|
||||
*/
|
||||
|
||||
export type Family = "v4" | "v6";
|
||||
|
||||
export interface Cidr {
|
||||
family: Family;
|
||||
/** Network address, as an integer. */
|
||||
base: bigint;
|
||||
prefix: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const V4_BITS = 32n;
|
||||
const V6_BITS = 128n;
|
||||
|
||||
export function familyOf(address: string): Family {
|
||||
return address.includes(":") ? "v6" : "v4";
|
||||
}
|
||||
|
||||
function parseV4(text: string): bigint {
|
||||
const parts = text.split(".");
|
||||
if (parts.length !== 4) throw new Error(`not an IPv4 address: ${text}`);
|
||||
let value = 0n;
|
||||
for (const part of parts) {
|
||||
if (!/^\d{1,3}$/.test(part)) throw new Error(`not an IPv4 address: ${text}`);
|
||||
const octet = Number(part);
|
||||
if (octet > 255) throw new Error(`octet out of range in ${text}`);
|
||||
value = (value << 8n) | BigInt(octet);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseV6(text: string): bigint {
|
||||
// Reject the forms this does not implement rather than mis-parsing them. An embedded
|
||||
// IPv4 suffix is legal and rare; getting it wrong silently would be worse than refusing.
|
||||
if (text.includes(".")) throw new Error(`IPv4-in-IPv6 form is not supported: ${text}`);
|
||||
const halves = text.split("::");
|
||||
if (halves.length > 2) throw new Error(`not an IPv6 address: ${text}`);
|
||||
|
||||
const head = halves[0] ? halves[0].split(":").filter(Boolean) : [];
|
||||
const tail = halves.length === 2 && halves[1] ? halves[1].split(":").filter(Boolean) : [];
|
||||
const explicit = head.length + tail.length;
|
||||
if (explicit > 8) throw new Error(`too many groups in ${text}`);
|
||||
if (halves.length === 1 && explicit !== 8) throw new Error(`not an IPv6 address: ${text}`);
|
||||
|
||||
const groups = [...head, ...Array<string>(8 - explicit).fill("0"), ...tail];
|
||||
let value = 0n;
|
||||
for (const group of groups) {
|
||||
if (!/^[0-9a-fA-F]{1,4}$/.test(group)) throw new Error(`not an IPv6 address: ${text}`);
|
||||
value = (value << 16n) | BigInt(parseInt(group, 16));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseAddress(text: string): { family: Family; value: bigint } {
|
||||
const family = familyOf(text);
|
||||
return { family, value: family === "v4" ? parseV4(text) : parseV6(text) };
|
||||
}
|
||||
|
||||
export function parseCidr(text: string): Cidr {
|
||||
const slash = text.lastIndexOf("/");
|
||||
if (slash === -1) throw new Error(`not a CIDR range (no prefix length): ${text}`);
|
||||
const addressText = text.slice(0, slash);
|
||||
const prefix = Number(text.slice(slash + 1));
|
||||
const { family, value } = parseAddress(addressText);
|
||||
const bits = family === "v4" ? V4_BITS : V6_BITS;
|
||||
if (!Number.isInteger(prefix) || prefix < 0 || BigInt(prefix) > bits) {
|
||||
throw new Error(`prefix length out of range for ${family}: ${text}`);
|
||||
}
|
||||
const hostBits = bits - BigInt(prefix);
|
||||
const base = (value >> hostBits) << hostBits;
|
||||
return { family, base, prefix, text };
|
||||
}
|
||||
|
||||
export function contains(range: Cidr, address: string): boolean {
|
||||
const { family, value } = parseAddress(address);
|
||||
if (family !== range.family) return false;
|
||||
const bits = family === "v4" ? V4_BITS : V6_BITS;
|
||||
const hostBits = bits - BigInt(range.prefix);
|
||||
return ((value >> hostBits) << hostBits) === range.base;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/** YAML in, a validated Scenario out. Normalises the shorthands the design's examples use. */
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import type { Attachment, Machine, Scenario, Segment } from "./types.ts";
|
||||
import { validate } from "./validate.ts";
|
||||
|
||||
/** `address: "1.2.3.4"` and `address: [...]` both mean a list. */
|
||||
function toList(value: unknown): string[] {
|
||||
if (value === undefined || value === null) return [];
|
||||
return Array.isArray(value) ? value.map(String) : [String(value)];
|
||||
}
|
||||
|
||||
function normaliseAttachment(raw: unknown): Attachment {
|
||||
const at = (raw ?? {}) as Record<string, unknown>;
|
||||
return { segment: String(at["segment"] ?? ""), address: toList(at["address"]) };
|
||||
}
|
||||
|
||||
function normaliseMachine(raw: unknown): Machine {
|
||||
const machine = (raw ?? {}) as Record<string, unknown>;
|
||||
const at = machine["at"];
|
||||
|
||||
const attachment: Machine["at"] =
|
||||
at === "detached"
|
||||
? "detached"
|
||||
: Array.isArray(at)
|
||||
? at.map(normaliseAttachment)
|
||||
: [normaliseAttachment(at)];
|
||||
|
||||
const result: Machine = { at: attachment };
|
||||
const published = machine["published"];
|
||||
if (Array.isArray(published)) {
|
||||
result.published = published.map((entry) => {
|
||||
const p = (entry ?? {}) as Record<string, unknown>;
|
||||
return { port: Number(p["port"]), on: String(p["on"] ?? "") };
|
||||
});
|
||||
}
|
||||
const inbound = machine["inbound"];
|
||||
if (inbound === "allow" || inbound === "deny") result.inbound = inbound;
|
||||
return result;
|
||||
}
|
||||
|
||||
function normaliseSegment(raw: unknown): Segment {
|
||||
const segment = (raw ?? {}) as Record<string, unknown>;
|
||||
const result: Segment = {
|
||||
kind: segment["kind"] === "public" ? "public" : "private",
|
||||
cidr: toList(segment["cidr"]),
|
||||
};
|
||||
if (segment["mtu"] !== undefined) result.mtu = Number(segment["mtu"]);
|
||||
|
||||
const gateway = segment["gateway"] as Record<string, unknown> | undefined;
|
||||
if (gateway) {
|
||||
const nat = toList(gateway["nat"]).filter((f): f is "v4" | "v6" => f === "v4" || f === "v6");
|
||||
result.gateway = {
|
||||
to: String(gateway["to"] ?? ""),
|
||||
address: toList(gateway["address"]),
|
||||
nat,
|
||||
// Absent means forwardable: a gateway you control is the ordinary case, and the
|
||||
// interesting one — carrier-grade NAT — should have to be stated.
|
||||
forwardable: gateway["forwardable"] !== false,
|
||||
};
|
||||
const ttl = gateway["mapping_ttl"] ?? gateway["mappingTtl"];
|
||||
if (ttl !== undefined) result.gateway.mappingTtl = String(ttl);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseScenario(text: string): Scenario {
|
||||
const raw = (parseYaml(text) ?? {}) as Record<string, unknown>;
|
||||
|
||||
const segments: Record<string, Segment> = {};
|
||||
for (const [name, value] of Object.entries(
|
||||
(raw["segments"] ?? {}) as Record<string, unknown>,
|
||||
)) {
|
||||
segments[name] = normaliseSegment(value);
|
||||
}
|
||||
|
||||
const machines: Record<string, Machine> = {};
|
||||
for (const [name, value] of Object.entries(
|
||||
(raw["machines"] ?? {}) as Record<string, unknown>,
|
||||
)) {
|
||||
machines[name] = normaliseMachine(value);
|
||||
}
|
||||
|
||||
const scenario: Scenario = {
|
||||
scenario: String(raw["scenario"] ?? ""),
|
||||
segments,
|
||||
machines,
|
||||
};
|
||||
|
||||
const policy = raw["policy"];
|
||||
if (Array.isArray(policy)) {
|
||||
scenario.policy = policy.map((entry) => {
|
||||
const p = (entry ?? {}) as Record<string, unknown>;
|
||||
return { from: String(p["from"] ?? ""), to: String(p["to"] ?? ""), allow: p["allow"] !== false };
|
||||
});
|
||||
}
|
||||
|
||||
const place = raw["place"];
|
||||
if (place && typeof place === "object") {
|
||||
const normalised: Record<string, string[]> = {};
|
||||
for (const [key, value] of Object.entries(place as Record<string, unknown>)) {
|
||||
normalised[key] = toList(value);
|
||||
}
|
||||
scenario.place = normalised;
|
||||
}
|
||||
|
||||
if (raw["snapshot"] !== undefined) scenario.snapshot = String(raw["snapshot"]);
|
||||
|
||||
validate(scenario);
|
||||
return scenario;
|
||||
}
|
||||
|
||||
export function loadScenario(path: string): Scenario {
|
||||
return parseScenario(readFileSync(path, "utf-8"));
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Every rule the design states about a scenario, checked before anything is raised.
|
||||
*
|
||||
* The reason validation is this strict is that the failures it prevents are silent. A
|
||||
* private range on a segment meant to be public does not produce an error — the mesh
|
||||
* simply never forms, because its own code decides public-versus-private by matching the
|
||||
* address. Publishing through a gateway that cannot forward does not produce an error
|
||||
* either; it produces a machine that looks reachable and is not.
|
||||
*
|
||||
* So: refuse the declaration, loudly, before spending a minute raising something that
|
||||
* would have taught us the wrong thing.
|
||||
*
|
||||
* See novox/hq 03-DESIGN/01-to-be/02-scenario-declaration.md
|
||||
*/
|
||||
|
||||
import type { Scenario, Segment } from "./types.ts";
|
||||
import { contains, familyOf, parseAddress, parseCidr, type Cidr } from "./net.ts";
|
||||
|
||||
/** RFC 5737 and RFC 3849. The only addresses guaranteed never to route on the real internet. */
|
||||
const DOCUMENTATION_RANGES = [
|
||||
"192.0.2.0/24",
|
||||
"198.51.100.0/24",
|
||||
"203.0.113.0/24",
|
||||
"2001:db8::/32",
|
||||
].map(parseCidr);
|
||||
|
||||
export class DeclarationError extends Error {
|
||||
readonly problems: string[];
|
||||
|
||||
constructor(problems: string[]) {
|
||||
super(`scenario declaration is not valid:\n - ${problems.join("\n - ")}`);
|
||||
this.name = "DeclarationError";
|
||||
this.problems = problems;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a range sits inside documentation space. Compared as ranges rather than as a
|
||||
* sample address so that a range wider than the reserved block — `203.0.0.0/8`, say — is
|
||||
* correctly refused instead of passing because its first address happens to fall inside.
|
||||
*/
|
||||
function isDocumentationRange(range: Cidr): boolean {
|
||||
return DOCUMENTATION_RANGES.some(
|
||||
(allowed) =>
|
||||
allowed.family === range.family &&
|
||||
range.prefix >= allowed.prefix &&
|
||||
containsRange(allowed, range),
|
||||
);
|
||||
}
|
||||
|
||||
/** Is `inner` entirely within `outer`? Both already parsed, so no address parsing can throw. */
|
||||
function containsRange(outer: Cidr, inner: Cidr): boolean {
|
||||
const bits = outer.family === "v4" ? 32n : 128n;
|
||||
const hostBits = bits - BigInt(outer.prefix);
|
||||
return ((inner.base >> hostBits) << hostBits) === outer.base;
|
||||
}
|
||||
|
||||
/** Ranges of a segment, parsed once, with malformed entries reported rather than thrown. */
|
||||
function rangesOf(name: string, segment: Segment, problems: string[]): Cidr[] {
|
||||
const ranges: Cidr[] = [];
|
||||
for (const text of segment.cidr) {
|
||||
try {
|
||||
ranges.push(parseCidr(text));
|
||||
} catch (err) {
|
||||
problems.push(`segment '${name}': ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function inAnyRange(ranges: Cidr[], address: string): boolean {
|
||||
return ranges.some((range) => contains(range, address));
|
||||
}
|
||||
|
||||
export function validate(scenario: Scenario): void {
|
||||
const problems: string[] = [];
|
||||
const segmentNames = Object.keys(scenario.segments);
|
||||
|
||||
if (!scenario.scenario) problems.push("scenario has no name");
|
||||
if (segmentNames.length === 0) problems.push("scenario declares no segments");
|
||||
if (Object.keys(scenario.machines).length === 0) {
|
||||
problems.push("scenario declares no machines");
|
||||
}
|
||||
|
||||
const ranges = new Map<string, Cidr[]>();
|
||||
for (const [name, segment] of Object.entries(scenario.segments)) {
|
||||
ranges.set(name, rangesOf(name, segment, problems));
|
||||
}
|
||||
|
||||
for (const [name, segment] of Object.entries(scenario.segments)) {
|
||||
// A public segment stands in for the internet. Anything that is not documentation
|
||||
// space could route somewhere real, and — far more likely — a private range here
|
||||
// makes the mesh's own public-versus-private test fail silently.
|
||||
if (segment.kind === "public") {
|
||||
// Only ranges that parsed — a malformed one is already reported, and re-parsing it
|
||||
// here would throw out of validation with a single cryptic message instead of the
|
||||
// full list.
|
||||
for (const range of ranges.get(name) ?? []) {
|
||||
if (!isDocumentationRange(range)) {
|
||||
problems.push(
|
||||
`segment '${name}' is public but '${range.text}' is not documentation space ` +
|
||||
`(RFC 5737 / RFC 3849). A non-documentation range here either routes somewhere ` +
|
||||
`real, or — if private — makes the mesh silently never form.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (segment.mtu !== undefined && (segment.mtu < 576 || segment.mtu > 9000)) {
|
||||
problems.push(`segment '${name}': mtu ${segment.mtu} is outside any plausible range`);
|
||||
}
|
||||
|
||||
const gateway = segment.gateway;
|
||||
if (!gateway) continue;
|
||||
|
||||
if (!segmentNames.includes(gateway.to)) {
|
||||
problems.push(`segment '${name}': gateway points at unknown segment '${gateway.to}'`);
|
||||
continue;
|
||||
}
|
||||
if (gateway.to === name) {
|
||||
problems.push(`segment '${name}': gateway points at itself`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// The gateway's address is what the world sees this network as, so it belongs to the
|
||||
// PARENT segment, not this one. Getting this backwards is easy and produces a topology
|
||||
// that raises fine and reproduces nothing.
|
||||
const parentRanges = ranges.get(gateway.to) ?? [];
|
||||
for (const address of gateway.address) {
|
||||
try {
|
||||
parseAddress(address);
|
||||
} catch (err) {
|
||||
problems.push(`segment '${name}' gateway: ${(err as Error).message}`);
|
||||
continue;
|
||||
}
|
||||
if (parentRanges.length > 0 && !inAnyRange(parentRanges, address)) {
|
||||
problems.push(
|
||||
`segment '${name}' gateway: address '${address}' is not within '${gateway.to}' ` +
|
||||
`(${scenario.segments[gateway.to]?.cidr.join(", ")}). A gateway's address is the ` +
|
||||
`one the parent network sees, not one on the segment behind it.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const family of gateway.nat) {
|
||||
if (family !== "v4" && family !== "v6") {
|
||||
problems.push(`segment '${name}' gateway: '${family}' is not an address family`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A gateway chain must terminate. A cycle would raise forever rather than fail.
|
||||
for (const name of segmentNames) {
|
||||
const seen = new Set<string>([name]);
|
||||
let current = scenario.segments[name]?.gateway?.to;
|
||||
while (current) {
|
||||
if (seen.has(current)) {
|
||||
problems.push(`segment '${name}': gateway chain loops through '${current}'`);
|
||||
break;
|
||||
}
|
||||
seen.add(current);
|
||||
current = scenario.segments[current]?.gateway?.to;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, machine] of Object.entries(scenario.machines)) {
|
||||
if (machine.at === "detached") {
|
||||
if (machine.published?.length) {
|
||||
problems.push(`machine '${name}' is detached but declares published ports`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Array.isArray(machine.at) || machine.at.length === 0) {
|
||||
problems.push(`machine '${name}': 'at' must be an attachment, a list of them, or "detached"`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const attachedTo = new Set<string>();
|
||||
for (const attachment of machine.at) {
|
||||
if (!segmentNames.includes(attachment.segment)) {
|
||||
problems.push(`machine '${name}': unknown segment '${attachment.segment}'`);
|
||||
continue;
|
||||
}
|
||||
if (attachedTo.has(attachment.segment)) {
|
||||
problems.push(`machine '${name}': attached to '${attachment.segment}' more than once`);
|
||||
}
|
||||
attachedTo.add(attachment.segment);
|
||||
|
||||
const segmentRanges = ranges.get(attachment.segment) ?? [];
|
||||
const seenFamilies = new Set<string>();
|
||||
for (const address of attachment.address) {
|
||||
try {
|
||||
parseAddress(address);
|
||||
} catch (err) {
|
||||
problems.push(`machine '${name}': ${(err as Error).message}`);
|
||||
continue;
|
||||
}
|
||||
const family = familyOf(address);
|
||||
if (seenFamilies.has(family)) {
|
||||
problems.push(`machine '${name}': two ${family} addresses on '${attachment.segment}'`);
|
||||
}
|
||||
seenFamilies.add(family);
|
||||
|
||||
if (segmentRanges.length > 0 && !inAnyRange(segmentRanges, address)) {
|
||||
problems.push(
|
||||
`machine '${name}': address '${address}' is not within segment ` +
|
||||
`'${attachment.segment}' (${scenario.segments[attachment.segment]?.cidr.join(", ")})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const publication of machine.published ?? []) {
|
||||
if (!Number.isInteger(publication.port) || publication.port < 1 || publication.port > 65535) {
|
||||
problems.push(`machine '${name}': port ${publication.port} is not a port`);
|
||||
}
|
||||
const via = scenario.segments[publication.on];
|
||||
if (!via) {
|
||||
problems.push(`machine '${name}': publishes on unknown segment '${publication.on}'`);
|
||||
continue;
|
||||
}
|
||||
if (!attachedTo.has(publication.on)) {
|
||||
problems.push(
|
||||
`machine '${name}': publishes on '${publication.on}' but is not attached to it`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!via.gateway) {
|
||||
problems.push(
|
||||
`machine '${name}': publishes on '${publication.on}', which has no gateway to ` +
|
||||
`forward through`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// The constraint being reproduced, not an implementation limit: a machine behind a
|
||||
// gateway it does not control cannot be published, and pretending otherwise would
|
||||
// make the lab certify something production cannot do.
|
||||
if (!via.gateway.forwardable) {
|
||||
problems.push(
|
||||
`machine '${name}': cannot publish through '${publication.on}' — its gateway is ` +
|
||||
`not forwardable. That is the constraint being reproduced, not a limitation.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const rule of scenario.policy ?? []) {
|
||||
for (const side of [rule.from, rule.to]) {
|
||||
if (!segmentNames.includes(side)) {
|
||||
problems.push(`policy: unknown segment '${side}'`);
|
||||
}
|
||||
}
|
||||
if (rule.from === rule.to) {
|
||||
problems.push(`policy: '${rule.from}' to itself is not a rule`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const machine of Object.keys(scenario.place ?? {})) {
|
||||
if (machine === "all") continue;
|
||||
if (!(machine in scenario.machines)) {
|
||||
problems.push(`place: '${machine}' is not a machine in this scenario`);
|
||||
}
|
||||
}
|
||||
|
||||
if (problems.length > 0) throw new DeclarationError(problems);
|
||||
}
|
||||
Reference in New Issue
Block a user