diff --git a/src/lifecycle/base.ts b/src/lifecycle/base.ts index fd7833f..b4f169d 100644 --- a/src/lifecycle/base.ts +++ b/src/lifecycle/base.ts @@ -78,6 +78,13 @@ export async function buildBaseImage( log(" installing nftables, so a machine can enforce what the mesh computed"); await incus(["exec", BUILDER, "--", "pacman", "-S", "--noconfirm", "nftables"], 600_000); + // And dnsmasq, because a service is named under the machine it runs on — postgres.novox.internal + // — and only a resolver can answer a name the mesh was never told about. Installed and NOT + // started: whether a machine resolves for the mesh is the mesh's decision, and a lab that + // turned it on itself would be testing its own setup. + log(" installing dnsmasq, so a machine can answer names under another machine"); + await incus(["exec", BUILDER, "--", "pacman", "-S", "--noconfirm", "dnsmasq"], 600_000); + // Trust the documentation ranges as plain-HTTP registries. // // A scenario's registry is scenery inside the scenario, serving over HTTP, and a runtime @@ -127,6 +134,17 @@ export async function buildBaseImage( } log(` ${nft.trim()}`); + // The same again for dnsmasq. A machine that cannot answer names applies the mesh's resolver + // data, reports success, and resolves nothing — the shape of fault this lab exists to catch. + const dns = await incusOk(["exec", BUILDER, "--", "dnsmasq", "--version"], 60_000); + if (!dns?.trim()) { + throw new BaseImageError( + `dnsmasq was installed in ${BUILDER} and does not answer. Publishing this would give ` + + `every scenario a machine that cannot resolve a name under another machine.`, + ); + } + log(` ${dns.trim().split("\n")[0]}`); + // Read back from the runtime, not from the package manager. An installed package is not a // capability (novox/hq 04-ISSUES/007), and this is the one place to catch that — after // publishing, every scenario pays for it instead. diff --git a/test/integration/mesh.test.ts b/test/integration/mesh.test.ts index 166ab6b..db46fd8 100644 --- a/test/integration/mesh.test.ts +++ b/test/integration/mesh.test.ts @@ -31,6 +31,8 @@ const capability = await labIsUsable(); const binary = hostBinaryPath(); const bundle = process.env["MESH_LAB_BUNDLE"] ?? ""; const builder = process.env["MESH_LAB_BUILDER"] ?? ""; +/** mesh-control's `examples/modules`, so the manifests proven here are the ones that ship. */ +const moduleExamples = process.env["MESH_LAB_MODULES"] ?? ""; const skip = !capability.usable ? `lab not usable: ${capability.why}` @@ -1362,3 +1364,88 @@ test("every name under a machine resolves to that machine", { await mesh("push"); await new Promise((r) => setTimeout(r, 15_000)); }); + +test("a service is reached by a name under the machine it runs on", { + skip: skip || (!moduleExamples ? "set MESH_LAB_MODULES to mesh-control's examples/modules" : false), + timeout: 900_000, +}, async () => { + // postgres.novox.internal, plex.ace.internal — the first label is the service and the rest is + // the node, so anything under a node's name must resolve to that node. What routes it once it + // arrives is a proxy's concern and stays separate. + // + // The mesh writes the data; a module runs the daemon. Both manifests are read from the + // repository rather than written here, so what is proven is what ships. + for (const name of ["dnsmasq", "resolv-conf"]) { + const manifest = readFileSync(`${moduleExamples}/${name}.json`, "utf8"); + await must("anchor", `cat > /tmp/${name}.json <<'MANIFEST'\n${manifest}\nMANIFEST`); + await must("anchor", `docker cp /tmp/${name}.json mesh-control:/${name}.json`); + await mesh(`module add /${name}.json`); + } + + // Both machines, because a node resolves from its own copy — the same rule as everything else + // it holds. A mesh where one machine answers for all of them stops resolving when that machine + // does, which is the arrangement this design refuses everywhere else. + for (const machine of ["anchor", "laptop"]) { + await mesh(`assign ${machine} dnsmasq`); + await mesh(`assign ${machine} resolv-conf`); + } + await mesh("push"); + await new Promise((r) => setTimeout(r, 25_000)); + + for (const machine of ["anchor", "laptop"]) { + assert.match(await must(machine, `systemctl is-active dnsmasq.service`), /^active/, + `the resolver is not running on ${machine}:\n` + + `${(await on(machine, `journalctl -u dnsmasq -n 20 --no-pager`)).out}`); + } + + // Through the machine's own resolver, by the path an application actually takes: nsswitch, then + // files, then DNS. `dig` would ask a server directly and prove less — the resolv.conf module is + // half of what is being tested, and only this path goes through it. + const resolves = async (machine: string, name: string) => { + const said = await on(machine, `getent hosts ${name} | head -1 | cut -d' ' -f1`, 30_000); + return said.out.trim(); + }; + const addressOf = async (machine: string, node: string) => + (await must(machine, `getent hosts ${node}.internal | head -1 | cut -d' ' -f1`)).trim(); + + const anchorAt = await addressOf("anchor", "anchor"); + const laptopAt = await addressOf("anchor", "laptop"); + + // A name the mesh was never told about, under a machine it was — from both machines, because a + // node must answer for every machine and not only for itself. + for (const machine of ["anchor", "laptop"]) { + let got = ""; + for (let i = 0; i < 15 && !got; i++) { + got = await resolves(machine, "postgres.anchor.internal"); + if (!got) await new Promise((r) => setTimeout(r, 3000)); + } + assert.equal(got, anchorAt, + `${machine} does not resolve a service named under anchor: ${got}`); + } + + // Any name at all, which is the whole point: the mesh was never told these exist. + for (const [name, expected] of [ + ["postgres-2.anchor.internal", anchorAt], + ["keycloak.anchor.internal", anchorAt], + ["plex.laptop.internal", laptopAt], + ["radarr.laptop.internal", laptopAt], + ] as const) { + assert.equal(await resolves("laptop", name), expected, + `${name} did not resolve to the machine it is named under`); + } + + // The machine's own name still resolves, and to the same place. Two accounts of where a machine + // is, disagreeing, would be worse than either alone. + assert.equal(await resolves("laptop", "anchor.internal"), anchorAt); + + // And what is not the mesh's is not answered by it. The resolver takes over the mesh's names + // and nothing else, which is what lets a machine keep whatever DNS it already had. + assert.equal(await resolves("anchor", "something.example.com"), "", + "the resolver answered for a name that is not the mesh's"); + + for (const machine of ["anchor", "laptop"]) { + await mesh(`unassign ${machine} resolv-conf`); + await mesh(`unassign ${machine} dnsmasq`); + } + await mesh("push"); +});