The lab raises a mesh, draws it, and now places tier 0 inside it #1

Merged
jschoubben merged 11 commits from feat/scenario-lifecycle into main 2026-08-25 23:02:29 +00:00
3 changed files with 147 additions and 0 deletions
Showing only changes of commit 715f367147 - Show all commits
+67
View File
@@ -0,0 +1,67 @@
/**
* Properties that must hold of ANY raised scenario, whatever it declares.
*
* Distinct from validation, which reads a file and can only catch what the file says. These
* read what actually came up. The first one exists because two routers were raised holding
* one address on one segment: the declaration was accepted, the raise reported success, and
* the address resolved to whichever container answered ARP last — so a published port
* worked or did not, run to run, with nothing reporting a fault.
*
* Pure over already-collected facts, so the logic is testable without a hypervisor and the
* reading of the hypervisor stays in one place.
*/
/** One address, held by one machine, on one segment. */
export interface Held {
machine: string;
segment: string;
address: string;
}
export interface Conflict {
segment: string;
address: string;
machines: string[];
}
/** Strip a prefix length: what is held is an address, the mask is a property of the link. */
function bare(address: string): string {
const slash = address.lastIndexOf("/");
return slash === -1 ? address : address.slice(0, slash);
}
/**
* Two machines holding one address on one segment.
*
* The same address on DIFFERENT segments is not a conflict — `192.168.1.1` on one private
* network and on another are two different machines' idea of "the gateway", which is the
* normal case and must not be reported.
*/
export function duplicateAddresses(held: Held[]): Conflict[] {
const byPlace = new Map<string, Set<string>>();
for (const entry of held) {
const key = `${entry.segment} ${bare(entry.address)}`;
const machines = byPlace.get(key) ?? new Set<string>();
machines.add(entry.machine);
byPlace.set(key, machines);
}
const conflicts: Conflict[] = [];
for (const [key, machines] of byPlace) {
if (machines.size < 2) continue;
const [segment, address] = key.split(" ");
conflicts.push({
segment: segment as string,
address: address as string,
machines: [...machines].sort(),
});
}
return conflicts.sort((a, b) => a.address.localeCompare(b.address));
}
/** Render conflicts as something a failing test can print without further work. */
export function describeConflicts(conflicts: Conflict[]): string {
return conflicts
.map((c) => `${c.address} is held by ${c.machines.join(" and ")} on '${c.segment}'`)
.join("; ");
}
+20
View File
@@ -13,6 +13,7 @@ import { labIsUsable, destroyAll } from "./harness.ts";
import { diagramFromLive } from "../../src/diagram/from-live.ts";
import { diagramFromDeclaration } from "../../src/diagram/from-declaration.ts";
import { toDrawio } from "../../src/diagram/drawio.ts";
import { duplicateAddresses, describeConflicts } from "../../src/lifecycle/invariants.ts";
const capability = await labIsUsable();
const skip = capability.usable ? false : `lab not usable: ${capability.why}`;
@@ -121,6 +122,25 @@ test("ADR 0032 — the workstation has no route into the scenario", { skip, time
assert.equal(stdout.trim(), "inside", "exec is the only way in, and it works");
});
test("no two machines hold one address on one segment", { skip }, async () => {
// True of ANY raised scenario, so it is asserted against whatever is standing rather than
// against something this test declares. Two gateways with the same public address became
// two containers both holding it, and the address resolved to whichever answered ARP last.
//
// Read from the hypervisor, never from the declaration — the declaration is what was
// accepted, and it was accepted.
const drawn = await diagramFromLive(instanceId);
const held = drawn.machines.flatMap((machine) =>
machine.attachments.flatMap((attachment) =>
attachment.addresses.map((address) => ({ machine: machine.name, segment: attachment.segment, address })),
),
);
assert.ok(held.length > 0, "no addresses were read back at all");
const conflicts = duplicateAddresses(held);
assert.deepEqual(conflicts, [], `address conflict: ${describeConflicts(conflicts)}`);
});
// The diagram tests read the instance the file raised, so they run before the one that
// tears it down. Ordering is load-bearing here: appended after the destroy test they read
// an instance that no longer existed, and reported it as the diagram failing.
+60
View File
@@ -0,0 +1,60 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { duplicateAddresses, describeConflicts } from "../src/lifecycle/invariants.ts";
/**
* The fault this defends against, in the shape it actually occurred: two gateways declared
* with the same public address became two router containers, both holding it on one segment.
*/
test("two machines holding one address on one segment is a conflict", () => {
const conflicts = duplicateAddresses([
{ machine: "gw-home", segment: "isp-home", address: "198.51.100.7/24" },
{ machine: "gw-devices", segment: "isp-home", address: "198.51.100.7/24" },
{ machine: "transit", segment: "isp-home", address: "198.51.100.254/24" },
]);
assert.equal(conflicts.length, 1);
assert.equal(conflicts[0]?.address, "198.51.100.7");
assert.deepEqual(conflicts[0]?.machines, ["gw-devices", "gw-home"]);
assert.match(describeConflicts(conflicts), /198\.51\.100\.7 is held by gw-devices and gw-home/);
});
test("the same address on different segments is NOT a conflict", () => {
// Every private network has its own `.1`. Reporting that would make the check useless.
assert.deepEqual(
duplicateAddresses([
{ machine: "gw-a", segment: "home", address: "192.168.1.1/24" },
{ machine: "gw-b", segment: "cafe", address: "192.168.1.1/24" },
]),
[],
);
});
test("one machine holding an address twice is not two machines", () => {
// A machine multi-homed onto the same segment, or an address read back from two places.
assert.deepEqual(
duplicateAddresses([
{ machine: "gw", segment: "isp", address: "198.51.100.7/24" },
{ machine: "gw", segment: "isp", address: "198.51.100.7" },
]),
[],
);
});
test("the prefix length is not part of the address", () => {
// The same address declared /24 in one place and /16 in another is still one address.
const conflicts = duplicateAddresses([
{ machine: "a", segment: "isp", address: "198.51.100.7/24" },
{ machine: "b", segment: "isp", address: "198.51.100.7/16" },
]);
assert.equal(conflicts.length, 1);
});
test("both families are checked", () => {
const conflicts = duplicateAddresses([
{ machine: "a", segment: "isp", address: "2001:db8:b::7/48" },
{ machine: "b", segment: "isp", address: "2001:db8:b::7/48" },
]);
assert.equal(conflicts.length, 1);
assert.equal(conflicts[0]?.address, "2001:db8:b::7");
});