The lab raises a mesh, draws it, and now places tier 0 inside it #1
@@ -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("; ");
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user