Unify trunk on main: initialization → main #3
@@ -16,9 +16,16 @@ machines:
|
|||||||
anchor:
|
anchor:
|
||||||
at: { segment: hosting, address: [192.0.2.10] }
|
at: { segment: hosting, address: [192.0.2.10] }
|
||||||
inbound: allow
|
inbound: allow
|
||||||
|
# The whole substrate, the registry, the builder, an adopted workload and the modules under
|
||||||
|
# test all land here — eleven containers before the forge arrives. At the 1GiB default this
|
||||||
|
# machine thrashes, and it presents as "the mesh hangs": every exec slows from 15s to 105s
|
||||||
|
# and the forge test fails on a status poll that is merely queued behind page-outs.
|
||||||
|
memory: 4GiB
|
||||||
|
cpus: 4
|
||||||
laptop:
|
laptop:
|
||||||
at: { segment: hosting, address: [192.0.2.20] }
|
at: { segment: hosting, address: [192.0.2.20] }
|
||||||
inbound: allow
|
inbound: allow
|
||||||
|
memory: 2GiB
|
||||||
|
|
||||||
images:
|
images:
|
||||||
- postgres:17-alpine
|
- postgres:17-alpine
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ function normaliseMachine(raw: unknown): Machine {
|
|||||||
}
|
}
|
||||||
const inbound = machine["inbound"];
|
const inbound = machine["inbound"];
|
||||||
if (inbound === "allow" || inbound === "deny") result.inbound = inbound;
|
if (inbound === "allow" || inbound === "deny") result.inbound = inbound;
|
||||||
|
if (machine["egress"] === true) result.egress = true;
|
||||||
|
if (machine["memory"] !== undefined) result.memory = String(machine["memory"]);
|
||||||
|
if (machine["cpus"] !== undefined) result.cpus = Number(machine["cpus"]);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,31 @@ export interface Machine {
|
|||||||
* v6-addressed machine. Without this, v6 addressing would imply reachability.
|
* v6-addressed machine. Without this, v6 addressing would imply reachability.
|
||||||
*/
|
*/
|
||||||
inbound?: "allow" | "deny";
|
inbound?: "allow" | "deny";
|
||||||
|
/**
|
||||||
|
* Whether this machine can reach the world outside the scenario.
|
||||||
|
*
|
||||||
|
* **Off unless asked for.** A scenario is a closed address space, and a machine that could
|
||||||
|
* reach anything would make every test's result depend on what else was reachable that day.
|
||||||
|
* It is declared for the same reason an address is: so what a run proves is what the scenario
|
||||||
|
* says, and not what the workstation happened to have.
|
||||||
|
*
|
||||||
|
* What it is for is the one thing a mesh genuinely cannot do without an outside: a first node
|
||||||
|
* fetching the images it starts from, before there is any mesh to serve them
|
||||||
|
* (novox/hq 04-ISSUES/029).
|
||||||
|
*/
|
||||||
|
egress?: boolean;
|
||||||
|
/**
|
||||||
|
* How big the machine is. Absent means the lab's default, which suits a machine running a host
|
||||||
|
* and a handful of containers.
|
||||||
|
*
|
||||||
|
* Declared, because it is a fact about the machine the scenario describes — the node that runs
|
||||||
|
* the whole substrate is bigger than the laptop that joins it, and a test that starves its
|
||||||
|
* anchor at the default answers questions about memory pressure, not about the mesh. The forge
|
||||||
|
* test failed three times as "status hangs" before anyone counted the containers in 1GiB
|
||||||
|
* (novox/hq 04-ISSUES/024 is the same lesson about a different resource).
|
||||||
|
*/
|
||||||
|
memory?: string;
|
||||||
|
cpus?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reachability between segments, as a segmented router enforces it. Asymmetric by design. */
|
/** Reachability between segments, as a segmented router enforces it. Asymmetric by design. */
|
||||||
|
|||||||
@@ -204,6 +204,12 @@ export function validate(scenario: Scenario): void {
|
|||||||
if (machine.published?.length) {
|
if (machine.published?.length) {
|
||||||
problems.push(`machine '${name}' is detached but declares published ports`);
|
problems.push(`machine '${name}' is detached but declares published ports`);
|
||||||
}
|
}
|
||||||
|
// The same fault as publishing from nowhere: raising it would drop the key on the floor,
|
||||||
|
// and a scenario key the runtime silently ignores is the thing this lab exists to catch.
|
||||||
|
if (machine.egress) {
|
||||||
|
problems.push(`machine '${name}' is detached but declares egress — a machine on no ` +
|
||||||
|
`segment reaches nothing, the world included`);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,15 @@ export interface Wire {
|
|||||||
mac: string;
|
mac: string;
|
||||||
addresses: string[];
|
addresses: string[];
|
||||||
mtu: number | undefined;
|
mtu: number | undefined;
|
||||||
|
/**
|
||||||
|
* Ask the host for an address rather than declaring one.
|
||||||
|
*
|
||||||
|
* **Only the uplink**, and it is the one address a scenario has no business choosing: it is on
|
||||||
|
* the machine's side of the host's own network, and what is routable there is the host's fact,
|
||||||
|
* not the declaration's. Every segment a scenario describes is still static, for the reason
|
||||||
|
* below.
|
||||||
|
*/
|
||||||
|
dhcp?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,6 +40,24 @@ export interface Wire {
|
|||||||
* the declaration is supposed to own.
|
* the declaration is supposed to own.
|
||||||
*/
|
*/
|
||||||
function networkUnit(wire: Wire): string {
|
function networkUnit(wire: Wire): string {
|
||||||
|
if (wire.dhcp) {
|
||||||
|
return [
|
||||||
|
"[Match]",
|
||||||
|
`MACAddress=${wire.mac}`,
|
||||||
|
"",
|
||||||
|
"[Network]",
|
||||||
|
"DHCP=ipv4",
|
||||||
|
"IPv6AcceptRA=no",
|
||||||
|
"",
|
||||||
|
"[DHCPv4]",
|
||||||
|
// **Worse than any route the scenario states.** A machine behind a declared gateway must
|
||||||
|
// keep using it: the uplink is a way out of the scenario, not a better way around inside
|
||||||
|
// it. On-link segments win regardless, being connected routes; this only settles which
|
||||||
|
// default route is preferred when a scenario declares one of its own.
|
||||||
|
"RouteMetric=4096",
|
||||||
|
"UseDNS=yes",
|
||||||
|
].join("\n") + "\n";
|
||||||
|
}
|
||||||
const lines = [
|
const lines = [
|
||||||
"[Match]",
|
"[Match]",
|
||||||
`MACAddress=${wire.mac}`,
|
`MACAddress=${wire.mac}`,
|
||||||
@@ -98,6 +125,16 @@ export async function applyAddresses(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (spec.egress) {
|
||||||
|
wires.push({
|
||||||
|
device: `eth${spec.at.length}`,
|
||||||
|
mac: macFor(instanceId, machine, spec.at.length),
|
||||||
|
addresses: [],
|
||||||
|
mtu: undefined,
|
||||||
|
dhcp: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for (const [index, wire] of wires.entries()) {
|
for (const [index, wire] of wires.entries()) {
|
||||||
const unit = networkUnit(wire);
|
const unit = networkUnit(wire);
|
||||||
await incus(
|
await incus(
|
||||||
@@ -109,7 +146,7 @@ export async function applyAddresses(
|
|||||||
|
|
||||||
await incus(["exec", name, "--", "systemctl", "enable", "--now", "systemd-networkd"], 60_000);
|
await incus(["exec", name, "--", "systemctl", "enable", "--now", "systemd-networkd"], 60_000);
|
||||||
await incus(["exec", name, "--", "systemctl", "restart", "systemd-networkd"], 60_000);
|
await incus(["exec", name, "--", "systemctl", "restart", "systemd-networkd"], 60_000);
|
||||||
log(` addressed ${machine} (${wires.map((w) => w.addresses.join(",")).join(" | ")})`);
|
log(` addressed ${machine} (${wires.map((w) => w.dhcp ? "uplink:dhcp" : w.addresses.join(",")).join(" | ")})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+46
-3
@@ -131,12 +131,42 @@ async function createNetwork(
|
|||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one network the lab supplies rather than the declaration.
|
||||||
|
*
|
||||||
|
* Every segment a scenario describes is an isolated bridge with no addresses, no DHCP and no NAT,
|
||||||
|
* because the declaration owns addressing. This is the opposite of that on purpose: it is not part
|
||||||
|
* of the scenario, it carries no scenario traffic, and what is routable on it is the host's fact.
|
||||||
|
*
|
||||||
|
* It exists so a machine can fetch what it starts from. A first node pulls three images before
|
||||||
|
* there is any mesh, and the module that gives a mesh its own store pulls one more
|
||||||
|
* (novox/hq 04-ISSUES/029) — none of which anything inside a scenario can serve.
|
||||||
|
*
|
||||||
|
* Tagged like everything else, so tearing the scenario down takes it too.
|
||||||
|
*/
|
||||||
|
async function createUplink(instanceId: string): Promise<string> {
|
||||||
|
const name = networkName(instanceId, "uplink");
|
||||||
|
if (await succeeds(["network", "show", name], 15_000)) return name;
|
||||||
|
await incus([
|
||||||
|
"network", "create", name,
|
||||||
|
"ipv4.address=auto",
|
||||||
|
"ipv4.nat=true",
|
||||||
|
"ipv6.address=none",
|
||||||
|
`user.mesh-lab.instance=${instanceId}`,
|
||||||
|
"user.mesh-lab.segment=uplink",
|
||||||
|
]);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
async function createMachine(
|
async function createMachine(
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
machine: string,
|
machine: string,
|
||||||
attachments: { segment: string }[],
|
attachments: { segment: string }[],
|
||||||
image: string,
|
image: string,
|
||||||
pool: string,
|
pool: string,
|
||||||
|
egress = false,
|
||||||
|
memory = "1GiB",
|
||||||
|
cpus = 2,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const name = machineName(instanceId, machine);
|
const name = machineName(instanceId, machine);
|
||||||
if (await succeeds(["config", "show", name], 15_000)) return name;
|
if (await succeeds(["config", "show", name], 15_000)) return name;
|
||||||
@@ -148,8 +178,8 @@ async function createMachine(
|
|||||||
// Arch images refuse to boot under secureboot with the shipped keys. Discovered by
|
// Arch images refuse to boot under secureboot with the shipped keys. Discovered by
|
||||||
// the first launch failing with exactly that message.
|
// the first launch failing with exactly that message.
|
||||||
"-c", "security.secureboot=false",
|
"-c", "security.secureboot=false",
|
||||||
"-c", "limits.memory=1GiB",
|
"-c", `limits.memory=${memory}`,
|
||||||
"-c", "limits.cpu=2",
|
"-c", `limits.cpu=${cpus}`,
|
||||||
"-c", `user.mesh-lab.instance=${instanceId}`,
|
"-c", `user.mesh-lab.instance=${instanceId}`,
|
||||||
"-c", `user.mesh-lab.machine=${machine}`,
|
"-c", `user.mesh-lab.machine=${machine}`,
|
||||||
];
|
];
|
||||||
@@ -168,6 +198,17 @@ async function createMachine(
|
|||||||
`hwaddr=${macFor(instanceId, machine, index)}`,
|
`hwaddr=${macFor(instanceId, machine, index)}`,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
// After every declared attachment, so eth0..ethN keep meaning what the scenario said and the
|
||||||
|
// uplink is whatever comes next. A machine that never asked for one has no such interface at
|
||||||
|
// all, which is the difference between a closed scenario and an open one.
|
||||||
|
if (egress) {
|
||||||
|
await incus([
|
||||||
|
"config", "device", "add", name, `eth${attachments.length}`, "nic",
|
||||||
|
"nictype=bridged",
|
||||||
|
`parent=${await createUplink(instanceId)}`,
|
||||||
|
`hwaddr=${macFor(instanceId, machine, attachments.length)}`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,7 +283,9 @@ export async function raise(
|
|||||||
const byMachine = new Map<string, string>();
|
const byMachine = new Map<string, string>();
|
||||||
for (const [machine, spec] of Object.entries(scenario.machines)) {
|
for (const [machine, spec] of Object.entries(scenario.machines)) {
|
||||||
const attachments = spec.at === "detached" ? [] : spec.at;
|
const attachments = spec.at === "detached" ? [] : spec.at;
|
||||||
const name = await createMachine(instanceId, machine, attachments, image, pool);
|
const name = await createMachine(
|
||||||
|
instanceId, machine, attachments, image, pool,
|
||||||
|
spec.at !== "detached" && spec.egress === true, spec.memory, spec.cpus);
|
||||||
created.push(name);
|
created.push(name);
|
||||||
byMachine.set(machine, name);
|
byMachine.set(machine, name);
|
||||||
log(` machine ${machine}${spec.at === "detached" ? " (detached)" : ""}`);
|
log(` machine ${machine}${spec.at === "detached" ? " (detached)" : ""}`);
|
||||||
|
|||||||
@@ -128,8 +128,16 @@ async function settled(node: string, withinMs = 240_000): Promise<void> {
|
|||||||
// second is this machine's fault. The control plane is a container on the node being polled:
|
// second is this machine's fault. The control plane is a container on the node being polled:
|
||||||
// while it applies a declaration, an exec into it can lose its fifo to containerd, and a poll
|
// while it applies a declaration, an exec into it can lose its fifo to containerd, and a poll
|
||||||
// loop that treats that as a verdict reports the mesh broken because the question missed.
|
// loop that treats that as a verdict reports the mesh broken because the question missed.
|
||||||
const { out: said, ok } = await on("anchor",
|
// And a poll that *threw* — an exec timeout, a lost fifo — is also "could not ask", not a
|
||||||
`docker exec mesh-control /mesh-control status --json`);
|
// verdict. The distinction failed once as an IncusError surfacing at minute four of a wait
|
||||||
|
// whose machine was merely slow.
|
||||||
|
let said = "", ok = false;
|
||||||
|
try {
|
||||||
|
({ out: said, ok } = await on("anchor",
|
||||||
|
`docker exec mesh-control /mesh-control status --json`));
|
||||||
|
} catch (err) {
|
||||||
|
said = (err as Error).message;
|
||||||
|
}
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
last = said;
|
last = said;
|
||||||
await new Promise((r) => setTimeout(r, 5000));
|
await new Promise((r) => setTimeout(r, 5000));
|
||||||
|
|||||||
@@ -84,3 +84,13 @@ segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
|
|||||||
machines: { a: { at: { segment: net, address: [192.0.2.1] }, inbound: allow } }`);
|
machines: { a: { at: { segment: net, address: [192.0.2.1] }, inbound: allow } }`);
|
||||||
assert.doesNotThrow(() => assertSupported(scenario));
|
assert.doesNotThrow(() => assertSupported(scenario));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("egress is parsed, and off unless asked for", () => {
|
||||||
|
const scenario = parseScenario(`scenario: x
|
||||||
|
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
|
||||||
|
machines:
|
||||||
|
a: { at: { segment: net, address: [192.0.2.1] }, egress: true }
|
||||||
|
b: { at: { segment: net, address: [192.0.2.2] } }`);
|
||||||
|
assert.equal(scenario.machines["a"]?.egress, true);
|
||||||
|
assert.equal(scenario.machines["b"]?.egress, undefined);
|
||||||
|
});
|
||||||
|
|||||||
@@ -243,3 +243,10 @@ machines:
|
|||||||
/one gateway.*disagree.*forwardable/s,
|
/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/);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user