The beds name images the way a machine would find them
Twenty-eight integration tests each carried their own copy of the same two helpers, which pointed a manifest and the substrate bundle at whatever the lab's registry had assigned. They now share two in the harness, and the difference is the point: ours is rewritten to the ID the machine holds it under, and everything else is left exactly as written so the machine pulls it. **The substrate bundle is where the fiction was most load-bearing.** mesh-host's `examples/substrate-first-node.lock` pins all three of its images at `192.0.2.250:5000/…`, which is the address the lab's registry served from — it was written for a target, and the target was the lab. Two of those are ordinary third-party images and become the digests mesh-catalog's own postgres and lavinmq modules pin, so the substrate's store and broker are literally the images the mesh runs. mesh-control exists in no registry at all and becomes the ID the machine was handed. **The bundle itself should be fixed in mesh-host and this substitution deleted with it.** Beds that wrote a manifest by hand named an image by repository and let the rewrite supply a digest. There is nothing to supply one now, so `onTheMachine` refuses an unpinned reference and hands back the digest the catalogue pins — a bed runs the image the mesh ships, and a bed that drifts from the catalogue is testing a different postgres. Three beds took a third-party image out of the raised list, which no longer contains one: certificates (pebble), objectstore (minio and its client) and provisioner (postgres) now name theirs and pull it. builds and mesh publish into the MESH's own artifact store — the `registry` module's image, on the node, on 5000 — rather than into scenery the lab raised. That is a different claim, and only one of them exists in production. New unit tests cover what a full raise would otherwise be the only way to check: the routes an egress machine gets (that its gateway is still the path to the rest of the scenario, that a range with no path is unreachable rather than leaked to the uplink, that each family gets its own next hop), which machine is handed which of our images, and the `images:` rule that refuses a third-party entry. The "shipped scenarios are valid" test now loads every scenario rather than two of them. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { parseScenario } from "../src/declaration/parse.ts";
|
||||
import { scenarioRoutesFor } from "../src/lifecycle/address.ts";
|
||||
|
||||
/**
|
||||
* The uplink and the declared gateway must not fight.
|
||||
*
|
||||
* **This is the one decision the registry's removal turned on, and it is invisible in a raise.**
|
||||
* Every machine that needs an image now has an `egress` uplink, and the uplink's DHCP offers a
|
||||
* default route. So did the scenario: a machine behind a household gateway defaulted through it, a
|
||||
* machine on a public segment defaulted through transit. Both of those are containers that reach
|
||||
* the scenario and nothing else — no route to the real internet, by design, because they exist to
|
||||
* reproduce a household router rather than to be one.
|
||||
*
|
||||
* A default route through either is therefore a black hole for anything outside, and it beats the
|
||||
* uplink's route on metric. The machine would sit failing every pull with a routing table that
|
||||
* looks perfectly reasonable.
|
||||
*
|
||||
* The answer is that an egress machine states the scenario's ranges explicitly and lets the uplink
|
||||
* be the default. These tests are how that is checked without spending an hour raising four nodes.
|
||||
*/
|
||||
|
||||
const HOUSEHOLD = `
|
||||
scenario: household
|
||||
segments:
|
||||
hosting:
|
||||
kind: public
|
||||
cidr: [192.0.2.0/24]
|
||||
home:
|
||||
kind: private
|
||||
cidr: [192.168.1.0/24]
|
||||
gateway:
|
||||
to: hosting
|
||||
address: [192.0.2.50]
|
||||
nat: [v4]
|
||||
forwardable: true
|
||||
machines:
|
||||
novox:
|
||||
at: { segment: hosting, address: [192.0.2.20] }
|
||||
egress: true
|
||||
ace:
|
||||
at: { segment: home, address: [192.168.1.10] }
|
||||
egress: true
|
||||
sealed:
|
||||
at: { segment: home, address: [192.168.1.99] }
|
||||
`;
|
||||
|
||||
test("a machine behind a gateway still reaches the scenario through that gateway", () => {
|
||||
// The whole point of the topology: home→public is a masqueraded outbound path, and the overlay
|
||||
// handshake has to survive it. An egress machine that stopped using its gateway would be
|
||||
// testing a flat network with extra steps.
|
||||
const routes = scenarioRoutesFor(parseScenario(HOUSEHOLD), "ace");
|
||||
assert.deepEqual(routes, [{ cidr: "192.0.2.0/24", via: "192.168.1.1" }]);
|
||||
});
|
||||
|
||||
test("a machine's own segment gets no route — it is already on-link", () => {
|
||||
const routes = scenarioRoutesFor(parseScenario(HOUSEHOLD), "ace");
|
||||
assert.ok(!routes.some((r) => r.cidr === "192.168.1.0/24"), JSON.stringify(routes));
|
||||
});
|
||||
|
||||
/**
|
||||
* **The dangerous one.** `home` is 192.168.1.0/24 — a documentation range in spirit, an ordinary
|
||||
* private one in fact, and very possibly the network the workstation itself is on.
|
||||
*
|
||||
* With one public segment there is no transit router, so novox has no path to `home` at all. Left
|
||||
* to fall through, that traffic would leave by the uplink and land on whatever the workstation can
|
||||
* reach. Unreachable is both the faithful reproduction of what it had before — a default route into
|
||||
* scenery that dropped it — and the only safe answer.
|
||||
*/
|
||||
test("a range with no path inside the scenario is unreachable, not leaked to the uplink", () => {
|
||||
const routes = scenarioRoutesFor(parseScenario(HOUSEHOLD), "novox");
|
||||
assert.deepEqual(routes, [{ cidr: "192.168.1.0/24", via: null }]);
|
||||
});
|
||||
|
||||
test("a machine without egress is left to its default route, and states nothing", () => {
|
||||
// Not because it needs no routes — it has one, a default through its gateway, applied the old
|
||||
// way. This function is only asked about machines whose default belongs to the uplink.
|
||||
const scenario = parseScenario(HOUSEHOLD);
|
||||
assert.equal(scenario.machines["sealed"]?.egress, undefined);
|
||||
});
|
||||
|
||||
const TWO_PUBLIC = `
|
||||
scenario: two-public
|
||||
segments:
|
||||
hosting:
|
||||
kind: public
|
||||
cidr: [192.0.2.0/24]
|
||||
elsewhere:
|
||||
kind: public
|
||||
cidr: [198.51.100.0/24]
|
||||
machines:
|
||||
anchor:
|
||||
at: { segment: hosting, address: [192.0.2.10] }
|
||||
egress: true
|
||||
`;
|
||||
|
||||
test("with a second public segment the transit router is the way across, as it always was", () => {
|
||||
// Transit is raised only when there is more than one public segment, so this is exactly the
|
||||
// case where pointing at it means something.
|
||||
const routes = scenarioRoutesFor(parseScenario(TWO_PUBLIC), "anchor");
|
||||
assert.deepEqual(routes, [{ cidr: "198.51.100.0/24", via: "192.0.2.254" }]);
|
||||
});
|
||||
|
||||
const V6 = `
|
||||
scenario: both-families
|
||||
segments:
|
||||
hosting:
|
||||
kind: public
|
||||
cidr: [192.0.2.0/24, "2001:db8:a::/48"]
|
||||
elsewhere:
|
||||
kind: public
|
||||
cidr: [198.51.100.0/24, "2001:db8:b::/48"]
|
||||
machines:
|
||||
anchor:
|
||||
at: { segment: hosting, address: [192.0.2.10, "2001:db8:a::10"] }
|
||||
egress: true
|
||||
`;
|
||||
|
||||
test("each family is routed through its own next hop", () => {
|
||||
// A v6 range routed via a v4 next hop is not a route, and the reverse is not either.
|
||||
const routes = scenarioRoutesFor(parseScenario(V6), "anchor");
|
||||
assert.deepEqual(routes, [
|
||||
{ cidr: "198.51.100.0/24", via: "192.0.2.254" },
|
||||
{ cidr: "2001:db8:b::/48", via: "2001:db8:a::fffe" },
|
||||
]);
|
||||
});
|
||||
Reference in New Issue
Block a user