A node being adopted cannot take a web module: every module reachable by name requires route, the mesh's only provider of it binds the two public ports, and the predecessor's proxy holds them and serves every public name there. Stopping the predecessor to break the circle darkens every name at once, with every certificate to re-obtain in the same window. So this answers the same provision without binding anything. It provides route and receives the same contributions file, and writes each contribution as one route file where the predecessor's file provider reads, naming the predecessor's own certificate resolver so no certificate is asked for. It removes a file it wrote when its contribution goes and never touches a file it did not write — the name and a marker inside both have to say it is the mesh's. A step, not a daemon: run-once, re-run by restart-on over the received file and the settings. The predecessor's dynamic directory is a node setting, because it is a fact about one machine. Migration scaffolding with a stated end: assigned only on an adopted node, deleted when the predecessor's proxy retires.
214 lines
11 KiB
TypeScript
214 lines
11 KiB
TypeScript
// 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<string, string> = {}): Promise<Settings> {
|
|
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, []);
|
|
});
|
|
|
|
// 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);
|
|
});
|