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 123 additions and 7 deletions
Showing only changes of commit a6b7d67e19 - Show all commits
+36
View File
@@ -149,6 +149,42 @@ export function validate(scenario: Scenario): void {
}
}
// Two gateways sharing an address are the same box, and one box cannot behave two ways.
// Left unchecked this raised two routers holding one address on one segment, where the
// address resolved to whichever answered ARP last — so a published port worked or did
// not, run to run, with nothing reporting a fault.
const gateways = Object.entries(scenario.segments)
.filter(([, segment]) => segment.gateway)
.map(([name, segment]) => ({ name, gateway: segment.gateway! }));
for (let i = 0; i < gateways.length; i++) {
for (let j = i + 1; j < gateways.length; j++) {
const a = gateways[i]!;
const b = gateways[j]!;
if (a.gateway.to !== b.gateway.to) continue;
const shared = a.gateway.address.filter((address) => b.gateway.address.includes(address));
if (shared.length === 0) continue;
const differences: string[] = [];
if ([...a.gateway.nat].sort().join(",") !== [...b.gateway.nat].sort().join(",")) {
differences.push(`nat (${a.gateway.nat.join("+") || "none"} vs ${b.gateway.nat.join("+") || "none"})`);
}
if (a.gateway.forwardable !== b.gateway.forwardable) {
differences.push(`forwardable (${a.gateway.forwardable} vs ${b.gateway.forwardable})`);
}
if (a.gateway.mappingTtl !== b.gateway.mappingTtl) {
differences.push(`mapping_ttl (${a.gateway.mappingTtl ?? "none"} vs ${b.gateway.mappingTtl ?? "none"})`);
}
if (differences.length > 0) {
problems.push(
`segments '${a.name}' and '${b.name}' declare gateways on '${a.gateway.to}' sharing ` +
`address '${shared[0]}', so they are one gateway — but they disagree on ` +
`${differences.join(" and ")}. One box cannot behave two ways.`,
);
}
}
}
// A gateway chain must terminate. A cycle would raise forever rather than fail.
for (const name of segmentNames) {
const seen = new Set<string>([name]);
+29 -7
View File
@@ -132,32 +132,54 @@ export interface RouterPlan {
* router, not several — that is what a VLAN-capable router is, and two routers sharing an
* external address would not work anyway.
*/
/**
* Gateways that share an address are ONE gateway.
*
* Grouping on the exact address list instead split a household in two: `home` declaring a
* v4 and a v6 address and `devices` declaring only the v4 produced two router containers,
* both holding the same v4 address on the same segment. The lab raised it, and the shared
* address resolved to whichever container answered ARP last — so a published port worked or
* did not, run to run, with nothing reporting a fault.
*
* One public address is one box. Checked against the real thing this models: a bridged
* modem, a single gateway holding the public address, everything behind it on one network.
* Two routers on one address is not a topology, it is a collision.
*/
export function planRouters(scenario: Scenario, instanceId: string): RouterPlan[] {
const byGateway = new Map<string, RouterPlan>();
const plans: RouterPlan[] = [];
for (const [segmentName, segment] of Object.entries(scenario.segments)) {
const gateway = segment.gateway;
if (!gateway) continue;
const key = `${gateway.to}|${[...gateway.address].sort().join(",")}`;
const existing = byGateway.get(key);
const existing = plans.find(
(plan) =>
plan.outside === gateway.to &&
plan.outsideAddresses.some((address) => gateway.address.includes(address)),
);
if (existing) {
existing.inside.push(segmentName);
// The union, so a gateway declared with a v6 address on only one of the segments it
// serves still carries it. The declarations must otherwise agree — validate refuses
// the case where they do not, so there is nothing to reconcile here.
for (const address of gateway.address) {
if (!existing.outsideAddresses.includes(address)) existing.outsideAddresses.push(address);
}
continue;
}
byGateway.set(key, {
name: `mlab-${instanceId}-gw${byGateway.size}`,
plans.push({
name: `mlab-${instanceId}-gw${plans.length}`,
inside: [segmentName],
outside: gateway.to,
outsideAddresses: gateway.address,
outsideAddresses: [...gateway.address],
nat: gateway.nat,
forwardable: gateway.forwardable,
mappingTtl: gateway.mappingTtl,
});
}
return [...byGateway.values()];
return plans;
}
/** "120s" / "2m" / "90" → seconds. */
+58
View File
@@ -2,6 +2,7 @@ import { test } from "node:test";
import assert from "node:assert/strict";
import { parseScenario } from "../src/declaration/parse.ts";
import { loadScenario } from "../src/declaration/parse.ts";
import { planRouters } from "../src/lifecycle/router.ts";
/** Every rejection below is a fault that would otherwise be silent at runtime. */
function refuses(yaml: string, pattern: RegExp): void {
@@ -185,3 +186,60 @@ segments: { island: { kind: private, cidr: [10.9.0.0/24] } }
machines: { a: { at: { segment: island, address: [10.9.0.1] } } }`),
);
});
test("two gateways sharing an address are one gateway, not two", () => {
// Modelled on the real thing: a bridged modem, one gateway holding the public address,
// everything behind it. Two routers on one address is not a topology, it is a collision —
// and the lab raised it happily, with the address resolving to whichever container
// answered ARP last.
const scenario = parseScenario(`
scenario: shared-gateway
segments:
isp:
kind: public
cidr: [198.51.100.0/24, "2001:db8:b::/48"]
home:
kind: private
cidr: [192.168.1.0/24]
gateway: { to: isp, address: [198.51.100.7, "2001:db8:b::7"], nat: [v4], forwardable: true, mapping_ttl: 120s }
devices:
kind: private
cidr: [192.168.30.0/24]
gateway: { to: isp, address: [198.51.100.7], nat: [v4], forwardable: true, mapping_ttl: 120s }
machines:
thermostat:
at: { segment: devices, address: [192.168.30.20] }
`);
const plans = planRouters(scenario, "test");
assert.equal(plans.length, 1, `expected one gateway, got ${plans.map((p) => p.inside.join("+")).join(" / ")}`);
assert.deepEqual([...plans[0]!.inside].sort(), ["devices", "home"]);
// The union: a v6 address declared on only one of the segments it serves is still carried.
assert.deepEqual([...plans[0]!.outsideAddresses].sort(), ["198.51.100.7", "2001:db8:b::7"]);
});
test("one box cannot behave two ways", () => {
// If two gateways share an address they are the same box, so a disagreement about what
// that box does is a contradiction — refused rather than silently resolved one way.
assert.throws(
() =>
parseScenario(`
scenario: contradictory-gateway
segments:
isp:
kind: public
cidr: [198.51.100.0/24]
home:
kind: private
cidr: [192.168.1.0/24]
gateway: { to: isp, address: [198.51.100.7], nat: [v4], forwardable: true }
devices:
kind: private
cidr: [192.168.30.0/24]
gateway: { to: isp, address: [198.51.100.7], nat: [v4], forwardable: false }
machines:
thermostat:
at: { segment: devices, address: [192.168.30.20] }
`),
/one gateway.*disagree.*forwardable/s,
);
});