Prove a service is reached by a name under the machine it runs on
Through the path an application actually takes — nsswitch, files, then DNS — because the resolv.conf module is half of what is being tested and only that path goes through it. Asking a server directly would prove less. Both machines resolve, from their own copy: 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. The manifests are read from mesh-control's examples rather than written here, so what is proven is what ships. And dnsmasq joins the base image, read back through --version like the others: a machine that cannot answer names applies the resolver data, reports success, and resolves nothing.
This commit is contained in:
@@ -78,6 +78,13 @@ export async function buildBaseImage(
|
|||||||
log(" installing nftables, so a machine can enforce what the mesh computed");
|
log(" installing nftables, so a machine can enforce what the mesh computed");
|
||||||
await incus(["exec", BUILDER, "--", "pacman", "-S", "--noconfirm", "nftables"], 600_000);
|
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.
|
// Trust the documentation ranges as plain-HTTP registries.
|
||||||
//
|
//
|
||||||
// A scenario's registry is scenery inside the scenario, serving over HTTP, and a runtime
|
// 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()}`);
|
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
|
// 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
|
// capability (novox/hq 04-ISSUES/007), and this is the one place to catch that — after
|
||||||
// publishing, every scenario pays for it instead.
|
// publishing, every scenario pays for it instead.
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ const capability = await labIsUsable();
|
|||||||
const binary = hostBinaryPath();
|
const binary = hostBinaryPath();
|
||||||
const bundle = process.env["MESH_LAB_BUNDLE"] ?? "";
|
const bundle = process.env["MESH_LAB_BUNDLE"] ?? "";
|
||||||
const builder = process.env["MESH_LAB_BUILDER"] ?? "";
|
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
|
const skip = !capability.usable
|
||||||
? `lab not usable: ${capability.why}`
|
? `lab not usable: ${capability.why}`
|
||||||
@@ -1362,3 +1364,88 @@ test("every name under a machine resolves to that machine", {
|
|||||||
await mesh("push");
|
await mesh("push");
|
||||||
await new Promise((r) => setTimeout(r, 15_000));
|
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");
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user