Three changes, found by one failing test. The forge failed three runs in a row as "status hangs", and it was diagnosed twice as contention — real defects, fixed, and not the cause. The heartbeats told the truth in the end: every exec on anchor crawled from 15s to 105s, because eleven containers plus a database pull were running in a 1GiB machine. Starvation presents as whatever you were doing when the page-outs start, which is why it wore two other bugs' clothes first. So machine size is now the scenario's to declare — memory and cpus per machine, default unchanged. The anchor that carries the whole substrate is bigger than the laptop that joins it, and the comment on the scenario says why in terms of what lands there. `egress: true` gives a machine one extra interface on a lab-supplied NAT network, addressed by DHCP because the one address a scenario has no business choosing is on the host's side of the fence. Declared per machine and off by default: a closed scenario stays the rule (novox/hq ADR 0016), and the exception exists because a first node fetches its images before any mesh can serve them — which is now the tested path (04-ISSUES/029), and a lab that can never reach upstream cannot prove the bootstrap it exists to prove. The uplink route is metric-4096, so it never shadows a route the scenario declared. A detached machine declaring egress is refused, not ignored. And settled() treats a poll that threw as a poll that missed. An exec timeout at minute four of a wait is "could not ask", not a verdict on the machine.
253 lines
8.2 KiB
TypeScript
253 lines
8.2 KiB
TypeScript
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 {
|
|
assert.throws(() => parseScenario(yaml), (err: Error) => {
|
|
assert.match(err.message, pattern);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
test("the shipped scenarios are valid", () => {
|
|
for (const file of ["scenarios/bootstrap-single.yml", "scenarios/the-ordinary-shape.yml"]) {
|
|
assert.doesNotThrow(() => loadScenario(file));
|
|
}
|
|
});
|
|
|
|
test("a public segment on a private range is refused — the mesh would silently never form", () => {
|
|
refuses(
|
|
`scenario: x
|
|
segments: { net: { kind: public, cidr: [192.168.1.0/24] } }
|
|
machines: { a: { at: { segment: net, address: [192.168.1.1] } } }`,
|
|
/not documentation space/,
|
|
);
|
|
});
|
|
|
|
test("a public segment on a real routable range is refused", () => {
|
|
refuses(
|
|
`scenario: x
|
|
segments: { net: { kind: public, cidr: [8.8.8.0/24] } }
|
|
machines: { a: { at: { segment: net, address: [8.8.8.8] } } }`,
|
|
/not documentation space/,
|
|
);
|
|
});
|
|
|
|
test("a private segment may use any range, including someone else's RFC 1918", () => {
|
|
assert.doesNotThrow(() =>
|
|
parseScenario(`scenario: x
|
|
segments:
|
|
pub: { kind: public, cidr: [192.0.2.0/24] }
|
|
cafe: { kind: private, cidr: [10.50.0.0/16], gateway: { to: pub, address: [192.0.2.5], nat: [v4], forwardable: false } }
|
|
machines: { a: { at: { segment: cafe, address: [10.50.0.9] } } }`),
|
|
);
|
|
});
|
|
|
|
test("publishing through an unforwardable gateway is refused — that is the constraint", () => {
|
|
refuses(
|
|
`scenario: x
|
|
segments:
|
|
pub: { kind: public, cidr: [192.0.2.0/24] }
|
|
cafe: { kind: private, cidr: [10.50.0.0/16], gateway: { to: pub, address: [192.0.2.5], nat: [v4], forwardable: false } }
|
|
machines:
|
|
a:
|
|
at: { segment: cafe, address: [10.50.0.9] }
|
|
published: [{ port: 443, on: cafe }]`,
|
|
/not forwardable/,
|
|
);
|
|
});
|
|
|
|
test("a gateway address must be on the PARENT segment, not the one behind it", () => {
|
|
refuses(
|
|
`scenario: x
|
|
segments:
|
|
pub: { kind: public, cidr: [192.0.2.0/24] }
|
|
home: { kind: private, cidr: [192.168.1.0/24], gateway: { to: pub, address: [192.168.1.1], nat: [v4] } }
|
|
machines: { a: { at: { segment: home, address: [192.168.1.9] } } }`,
|
|
/is not within 'pub'/,
|
|
);
|
|
});
|
|
|
|
test("a machine address outside its segment is refused", () => {
|
|
refuses(
|
|
`scenario: x
|
|
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
|
|
machines: { a: { at: { segment: net, address: [203.0.113.9] } } }`,
|
|
/is not within segment 'net'/,
|
|
);
|
|
});
|
|
|
|
test("an unknown segment reference is refused", () => {
|
|
refuses(
|
|
`scenario: x
|
|
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
|
|
machines: { a: { at: { segment: nope, address: [192.0.2.1] } } }`,
|
|
/unknown segment 'nope'/,
|
|
);
|
|
});
|
|
|
|
test("a gateway loop is refused rather than raised forever", () => {
|
|
refuses(
|
|
`scenario: x
|
|
segments:
|
|
a: { kind: private, cidr: [10.0.0.0/24], gateway: { to: b, address: [10.0.1.1], nat: [] } }
|
|
b: { kind: private, cidr: [10.0.1.0/24], gateway: { to: a, address: [10.0.0.1], nat: [] } }
|
|
machines: { m: { at: { segment: a, address: [10.0.0.9] } } }`,
|
|
/loops through/,
|
|
);
|
|
});
|
|
|
|
test("a detached machine cannot publish", () => {
|
|
refuses(
|
|
`scenario: x
|
|
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
|
|
machines: { a: { at: detached, published: [{ port: 443, on: net }] } }`,
|
|
/detached but declares published/,
|
|
);
|
|
});
|
|
|
|
test("publishing on a segment the machine is not attached to is refused", () => {
|
|
refuses(
|
|
`scenario: x
|
|
segments:
|
|
pub: { kind: public, cidr: [192.0.2.0/24] }
|
|
home: { kind: private, cidr: [192.168.1.0/24], gateway: { to: pub, address: [192.0.2.5], nat: [v4] } }
|
|
machines:
|
|
a:
|
|
at: { segment: pub, address: [192.0.2.10] }
|
|
published: [{ port: 443, on: home }]`,
|
|
/not attached to it/,
|
|
);
|
|
});
|
|
|
|
test("two addresses of one family on one attachment is refused", () => {
|
|
refuses(
|
|
`scenario: x
|
|
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
|
|
machines: { a: { at: { segment: net, address: [192.0.2.1, 192.0.2.2] } } }`,
|
|
/two v4 addresses/,
|
|
);
|
|
});
|
|
|
|
test("place naming a machine that does not exist is refused", () => {
|
|
refuses(
|
|
`scenario: x
|
|
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
|
|
machines: { a: { at: { segment: net, address: [192.0.2.1] } } }
|
|
place: { ghost: [host] }`,
|
|
/'ghost' is not a machine/,
|
|
);
|
|
});
|
|
|
|
test("every problem is reported, not just the first", () => {
|
|
try {
|
|
parseScenario(`scenario: x
|
|
segments: { net: { kind: public, cidr: [192.168.0.0/24] } }
|
|
machines: { a: { at: { segment: nope, address: [1.2.3.4] } } }
|
|
place: { ghost: [host] }`);
|
|
assert.fail("should have thrown");
|
|
} catch (err) {
|
|
const problems = (err as { problems: string[] }).problems;
|
|
assert.ok(problems.length >= 3, `expected several problems, got ${problems.length}`);
|
|
}
|
|
});
|
|
|
|
test("a detached machine is valid", () => {
|
|
assert.doesNotThrow(() =>
|
|
parseScenario(`scenario: x
|
|
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
|
|
machines:
|
|
a: { at: { segment: net, address: [192.0.2.1] } }
|
|
roamer: { at: detached }`),
|
|
);
|
|
});
|
|
|
|
test("a multi-homed machine is valid", () => {
|
|
assert.doesNotThrow(() =>
|
|
parseScenario(`scenario: x
|
|
segments:
|
|
pub: { kind: public, cidr: [192.0.2.0/24] }
|
|
home: { kind: private, cidr: [192.168.1.0/24], gateway: { to: pub, address: [192.0.2.5], nat: [v4] } }
|
|
machines:
|
|
border:
|
|
at:
|
|
- { segment: pub, address: [192.0.2.60] }
|
|
- { segment: home, address: [192.168.1.2] }`),
|
|
);
|
|
});
|
|
|
|
test("an isolated private segment with no gateway is valid — a site with no internet", () => {
|
|
assert.doesNotThrow(() =>
|
|
parseScenario(`scenario: x
|
|
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,
|
|
);
|
|
});
|
|
|
|
test("a detached machine cannot declare egress", () => {
|
|
refuses(`scenario: x
|
|
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
|
|
machines: { a: { at: detached, egress: true } }`,
|
|
/detached but declares egress/);
|
|
});
|