Files
mesh-lab/src/declaration/parse.ts
T
jschoubben 751948f0f9 The lab had a registry that production does not, so it tested a fiction
The lab raised a `registry` VM, pushed ~73 images into it from the workstation, and
rewrote every manifest reference — third-party ones included — to point at it. No
production mesh has such a thing. So every bed proved that a machine could fetch an
image from a registry that exists nowhere else, and the bootstrap problems that only
appear when a machine has to fetch for itself went unfound.

What replaces it is the two things that are true in the world:

**Public images come from the public internet.** mesh-lab already created a NAT'd
uplink for exactly this and attached it to any machine declaring `egress`; no scenario
ever declared it. They do now, and third-party references are left exactly as the
catalogue writes them.

**The mesh's own images have no registry and never will.** mesh-control, mesh-builder,
mesh-route-proxy and the per-module runtimes are built from source and exist in no
registry. A machine gets them the way an operator's machine does — they are built here
and loaded onto it — and is then named by the digest of its own image configuration,
which mesh-host now accepts as "an image this machine already holds".

`images:` therefore means only *ours*, and a third-party entry is refused rather than
quietly loaded: otherwise the fiction returns one convenient line at a time. It is
per-machine as well, because "everything, everywhere" was never a description of
anything real — handing whole-mesh-full's union to its two 30GiB workstations would
fill the disk with runtimes nothing on them will start.

**The uplink and the declared gateway would have fought, silently.** A gateway container
and the transit router reach the scenario and nothing else; a default route through
either is a black hole for anything outside, and it beats the uplink's DHCP route on
metric. So a machine with egress states the scenario's ranges explicitly — through the
same gateway or transit it would have defaulted to, so the overlay-across-NAT path is
unchanged — and leaves the default to the uplink. A range with no path inside the
scenario becomes `unreachable` rather than falling through: 192.168.1.0/24 is an
ordinary private range in fact, and letting it escape would put scenario traffic on
whatever network the workstation is sitting on. `scenarioRoutesFor` is pure and tested,
because a decision only a full raise could check is one nobody checks.

The registry-reachability check the raise gained earlier is kept, pointed at the real
thing: every machine with egress must resolve a name and reach the internet before the
raise says it finished. Same failure it was written for — a raise that returns, an apply
that dies on its first pull, an instance left a bare shell — now guarding the path that
actually carries.

The base image's trust of the documentation ranges as plain-HTTP registries STAYS. It
was never only for the lab's registry: the mesh has one of its own, the `registry`
module, serving artifacts to the whole mesh over plain HTTP from whatever node runs it.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-10 23:16:05 +02:00

129 lines
4.5 KiB
TypeScript

/** 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;
if (machine["egress"] === true) result.egress = true;
if (machine["memory"] !== undefined) result.memory = String(machine["memory"]);
if (machine["cpus"] !== undefined) result.cpus = Number(machine["cpus"]);
if (machine["disk"] !== undefined) result.disk = String(machine["disk"]);
// Absent and empty are different: absent means "all of the scenario's images", an explicit empty
// list means "none". A machine that runs nothing of ours should be able to say so.
if (machine["images"] !== undefined) result.images = toList(machine["images"]);
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 images = raw["images"];
if (Array.isArray(images)) {
scenario.images = images.map((i) => String(i));
}
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"));
}