/** 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; return { segment: String(at["segment"] ?? ""), address: toList(at["address"]) }; } function normaliseMachine(raw: unknown): Machine { const machine = (raw ?? {}) as Record; 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; 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; 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 | 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; const segments: Record = {}; for (const [name, value] of Object.entries( (raw["segments"] ?? {}) as Record, )) { segments[name] = normaliseSegment(value); } const machines: Record = {}; for (const [name, value] of Object.entries( (raw["machines"] ?? {}) as Record, )) { 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; 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 = {}; for (const [key, value] of Object.entries(place as Record)) { 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")); }