// What ADR 0104 says holds the adapter: one route file per contribution, each named as its own, a // file removed when its contribution goes, and every file it did not write left alone. Plus the two // facts the file has to get right to be a route at all — the port the contributor publishes, and // where the mesh says that contributor's machine is. import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdtemp, readdir, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { defaults, marker, reconcile, routesFrom, settingsFrom } from "../adapter.ts"; import type { Settings } from "../adapter.ts"; /** A dynamic directory standing in for the predecessor's, with whatever is already in it. */ async function predecessor(already: Record = {}): Promise { const dynamic = await mkdtemp(join(tmpdir(), "route-adapter-")); for (const [name, body] of Object.entries(already)) { await writeFile(join(dynamic, name), body); } return { ...defaults, dynamic }; } /** The contributions file the mesh writes, in the shape the mesh's own proxy also reads. */ function contributed(...given: { from: string; node?: string; at?: string; name: string; port: number }[]) { return { contributions: 1, requirement: "route", given: given.map((g) => ({ from: g.from, node: g.node ?? "control-node", at: g.at ?? "", values: { name: g.name, port: g.port }, })), }; } async function pass(settings: Settings, document: unknown) { const { routes, skipped } = routesFrom(document, settings.machine); assert.deepEqual(skipped, [], "a contribution was skipped that the test meant to be served"); return reconcile(routes, settings); } // **One file per contribution**, named so the mesh can recognise its own — and shaped like the // route files the predecessor already serves, down to its own certificate resolver, so a migrated // name is served from the certificate that exists rather than one asked for at the cutover. test("it writes one route file per contribution, in the predecessor's own shape", async () => { const settings = await predecessor(); const changed = await pass(settings, contributed( { from: "gitea", name: "git.example", port: 2999 }, { from: "umami", name: "stats.example", port: 3001 }, )); assert.deepEqual(changed.written, ["mesh-git.example.yml", "mesh-stats.example.yml"]); assert.deepEqual((await readdir(settings.dynamic)).sort(), ["mesh-git.example.yml", "mesh-stats.example.yml"]); assert.equal(await readFile(join(settings.dynamic, "mesh-git.example.yml"), "utf8"), [ marker, "# gitea contributed this route. It is removed when that contribution goes.", "http:", " routers:", " mesh-git-example:", " entryPoints: [websecure]", " rule: Host(`git.example`)", " service: mesh-git-example", " tls:", " certResolver: le", " domains:", " - main: git.example", " services:", " mesh-git-example:", " loadBalancer:", " servers:", " - url: http://host.docker.internal:2999", "", ].join("\n")); }); // **A contribution that goes takes its file with it.** The contributions file is the whole truth // about who has a route, so a module unassigned — or migrated on to the mesh's own proxy — must // stop being served by the predecessor too. A route left behind is the stale-route fault, one // level down. test("it removes the file it wrote when that contribution goes", async () => { const settings = await predecessor(); await pass(settings, contributed( { from: "gitea", name: "git.example", port: 2999 }, { from: "umami", name: "stats.example", port: 3001 }, )); const changed = await pass(settings, contributed({ from: "gitea", name: "git.example", port: 2999 })); assert.deepEqual(changed.removed, ["mesh-stats.example.yml"]); assert.deepEqual(changed.written, [], "an unchanged route was rewritten, waking the predecessor for nothing"); assert.deepEqual(await readdir(settings.dynamic), ["mesh-git.example.yml"]); // And with nothing contributed at all, everything this module put there goes — which is what // unassigning it must mean, not "the file could not be read, so keep serving". const emptied = await pass(settings, contributed()); assert.deepEqual(emptied.removed, ["mesh-git.example.yml"]); assert.deepEqual(await readdir(settings.dynamic), []); }); // **The one rule that makes writing into somebody else's directory safe at all.** The mesh is a // guest here: the predecessor's own route files, and anything else in the directory, are none of // its business — including a file whose name happens to look like one of the mesh's but carries no // marker of it. test("it never touches a file it did not write", async () => { const predecessorsOwn = "http:\n routers:\n gitea-gitea:\n rule: Host(`git.example`)\n"; const lookalike = "# somebody else's, with a name like the mesh's\nhttp: {}\n"; const settings = await predecessor({ "gitea-gitea.yml": predecessorsOwn, "mesh-not-ours.yml": lookalike, "mesh-stats.example.yml": lookalike, }); const changed = await pass(settings, contributed( { from: "gitea", name: "git.example", port: 2999 }, { from: "umami", name: "stats.example", port: 3001 }, )); // Its own file it writes; the two it did not write it leaves — one it was never asked about, and // one it WAS asked to write, which it refuses and says so rather than overwriting. assert.deepEqual(changed.written, ["mesh-git.example.yml"]); assert.deepEqual(changed.removed, []); assert.equal(changed.skipped.length, 1); assert.match(changed.skipped[0]!, /mesh-stats\.example\.yml is not this module's to write/); assert.equal(await readFile(join(settings.dynamic, "gitea-gitea.yml"), "utf8"), predecessorsOwn); assert.equal(await readFile(join(settings.dynamic, "mesh-not-ours.yml"), "utf8"), lookalike); assert.equal(await readFile(join(settings.dynamic, "mesh-stats.example.yml"), "utf8"), lookalike); // And a later pass that wants neither of them removes neither: removal is for files this module // can show it wrote, and nothing else. const later = await pass(settings, contributed()); assert.deepEqual(later.removed, ["mesh-git.example.yml"]); assert.deepEqual((await readdir(settings.dynamic)).sort(), ["gitea-gitea.yml", "mesh-not-ours.yml", "mesh-stats.example.yml"]); }); // **The port is the contributor's, not a guess.** The mesh tells a consumer the machine-side port // it assigned (novox/hq ADR 0038), and it carries that in the contribution; the adapter's whole job // on this axis is to put that number in the predecessor's service, unchanged. test("the target port follows what the contributor publishes", async () => { const settings = await predecessor(); await pass(settings, contributed({ from: "gitea", name: "git.example", port: 2999 })); assert.match(await readFile(join(settings.dynamic, "mesh-git.example.yml"), "utf8"), /url: http:\/\/host\.docker\.internal:2999$/m); // Moved to another machine port, and the route follows it on the next pass. const changed = await pass(settings, contributed({ from: "gitea", name: "git.example", port: 21000 })); assert.deepEqual(changed.written, ["mesh-git.example.yml"]); assert.match(await readFile(join(settings.dynamic, "mesh-git.example.yml"), "utf8"), /url: http:\/\/host\.docker\.internal:21000$/m); }); // **Where the mesh says that machine is.** Empty means this one — reached from inside the // predecessor's container by the machine's own name, not by loopback, which is `127.0.0.1` to the // container and nothing useful. A contributor elsewhere in the mesh carries its overlay address, // and the predecessor is sent straight there. test("a contributor on another node is reached at the address the mesh gave it", async () => { const settings = await predecessor(); await pass(settings, contributed( { from: "gitea", name: "git.example", port: 2999 }, { from: "umami", node: "home-server", at: "198.51.100.7", name: "stats.example", port: 3001 }, )); assert.match(await readFile(join(settings.dynamic, "mesh-git.example.yml"), "utf8"), /url: http:\/\/host\.docker\.internal:2999$/m); assert.match(await readFile(join(settings.dynamic, "mesh-stats.example.yml"), "utf8"), /url: http:\/\/198\.51\.100\.7:3001$/m); }); // The node setting is the whole point of this module being assignable to more than one adopted // machine: where the predecessor keeps its directory, which entry point and which resolver it uses // are facts about one machine, laid over the module's defaults (novox/hq ADR 0100). test("a node's settings are laid over the module's defaults, and nothing else changes", async () => { assert.deepEqual(settingsFrom(undefined), defaults); assert.deepEqual(settingsFrom({}), defaults); assert.deepEqual(settingsFrom({ dynamic: "/srv/proxy/conf.d", "certificate-resolver": "letsencrypt" }), { ...defaults, dynamic: "/srv/proxy/conf.d", resolver: "letsencrypt", }); const settings = { ...(await predecessor()), entrypoint: "https", resolver: "letsencrypt", machine: "10.0.2.2" }; await pass(settings, contributed({ from: "gitea", name: "git.example", port: 2999 })); const written = await readFile(join(settings.dynamic, "mesh-git.example.yml"), "utf8"); assert.match(written, /entryPoints: \[https\]/); assert.match(written, /certResolver: letsencrypt/); assert.match(written, /url: http:\/\/10\.0\.2\.2:2999$/m); }); // A contribution it cannot act on is said aloud and skipped, never guessed at — and a name that is // not a name never becomes a path in somebody else's directory. test("a contribution it cannot act on is skipped and named", async () => { const machine = defaults.machine; assert.deepEqual(routesFrom({ given: [{ from: "a", values: {} }] }, machine).skipped, ["a asked for a route and named nothing"]); assert.deepEqual(routesFrom({ given: [{ from: "b", values: { name: "x.example" } }] }, machine).skipped, ["b asked for route x.example and gave no usable port"]); assert.equal(routesFrom({ given: [{ from: "c", values: { name: "../../etc/x", port: 80 } }] }, machine) .routes.length, 0); assert.deepEqual(routesFrom(undefined, machine).routes, []); }); // What the file shape cannot say is skipped aloud, never approximated: plain http into a TLS // listener, a whole-host file for a path-scoped rule, a proxying file for a refusal or redirect. // The mesh's own proxy serves all of these the day it takes over; until then the predecessor's // hand-authored files keep covering them. test("a contribution the file shape cannot say is skipped and says which part", async () => { const machine = defaults.machine; const { routes, skipped } = routesFrom({ given: [ { from: "mailu", values: { name: "mail.example", port: 7443, scheme: "https", insecure: true } }, { from: "mailu", values: { name: "mail.example", port: 7080, path: "/.well-known/acme-challenge" } }, { from: "gitea", values: { name: "git.example", path: "/api/internal", deny: true, priority: 100000 } }, { from: "site", values: { name: "www.example", redirect: "https://example" } }, { from: "mailu", values: { name: "autoconfig.example", port: 4243 } }, ] }, machine); assert.deepEqual(routes.map((r) => r.name), ["autoconfig.example"]); assert.equal(skipped.length, 4); assert.match(skipped[0]!, /over https/); assert.match(skipped[1]!, /scoped to a path/); assert.match(skipped[2]!, /scoped to a path/); assert.match(skipped[3]!, /policy/); }); // The directory is the predecessor's and the mesh only mounts it. Absent, there is nothing to write // into — and writing anyway would put route files somewhere nothing reads, reporting success. test("it refuses when the predecessor's directory is not there, and says why", async () => { const settings = { ...defaults, dynamic: join(await mkdtemp(join(tmpdir(), "route-adapter-")), "absent") }; await assert.rejects(reconcile([], settings), /is not there.*`dynamic` setting.*mounts it/s); });