The predecessor serves the registry under a public name, behind htpasswd basic auth, with a twenty-gigabyte body limit for layer pushes. The mesh's registry has no name, no lock and no limit — by design inside the mesh, where the private network is the boundary and every node pulls without an account (hq ADR 0082). Taking the name over must not change that. A route on `distribution` itself would: contributing a route is requiring one, and the store is raised at genesis on a node with no proxy. So the public door is `distribution-gate`, a second registry process on the same volume, behind the registry's own htpasswd (the predecessor's realm, the predecessor's file, carried in with `secret accept`), with the route and its limit. It requires the store's storage as a node-scoped provision, so it can only land beside the store. The store's own door is untouched — no auth, no htpasswd — which is what keeps the builder's pushes and every node's pulls working. Both processes read the predecessor's configuration where it changed behaviour: delete enabled, which tag retention depends on; no per-process descriptor cache, which two processes over one store cannot share; the CORS headers for the retired interface dropped. route-adapter writes the limit as the predecessor's own buffering middleware, named after the router, only when asked for — and skips a route whose limit it cannot read rather than carrying what the module said not to. hq ADR 0082/0104, the registry hand-over.
277 lines
14 KiB
TypeScript
277 lines
14 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; limit?: unknown }[]
|
|
) {
|
|
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, ...(g.limit === undefined ? {} : { "max-request-body": g.limit }) },
|
|
})),
|
|
};
|
|
}
|
|
|
|
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);
|
|
});
|
|
|
|
// **The registry's hand-over** (novox/hq ADR 0082, ADR 0104). A registry takes image layers in
|
|
// single requests of gigabytes, and the predecessor served its public name with a twenty-gigabyte
|
|
// `buffering` middleware. The contribution carries that limit as `max-request-body`; the adapter
|
|
// writes it as the middleware the predecessor already understands, named after the router, and
|
|
// writes nothing of the kind for a route that did not ask.
|
|
test("a body limit is written as the predecessor's buffering middleware", async () => {
|
|
const settings = await predecessor();
|
|
const changed = await pass(settings, contributed(
|
|
{ from: "distribution-gate", name: "registry-api.example", port: 5001, limit: 21474836480 },
|
|
{ from: "gitea", name: "git.example", port: 2999 },
|
|
));
|
|
assert.deepEqual(changed.written, ["mesh-git.example.yml", "mesh-registry-api.example.yml"]);
|
|
|
|
assert.equal(await readFile(join(settings.dynamic, "mesh-registry-api.example.yml"), "utf8"), [
|
|
marker,
|
|
"# distribution-gate contributed this route. It is removed when that contribution goes.",
|
|
"http:",
|
|
" routers:",
|
|
" mesh-registry-api-example:",
|
|
" entryPoints: [websecure]",
|
|
" rule: Host(`registry-api.example`)",
|
|
" service: mesh-registry-api-example",
|
|
" middlewares: [mesh-registry-api-example-body]",
|
|
" tls:",
|
|
" certResolver: le",
|
|
" domains:",
|
|
" - main: registry-api.example",
|
|
" middlewares:",
|
|
" mesh-registry-api-example-body:",
|
|
" buffering:",
|
|
" maxRequestBodyBytes: 21474836480",
|
|
" services:",
|
|
" mesh-registry-api-example:",
|
|
" loadBalancer:",
|
|
" servers:",
|
|
" - url: http://host.docker.internal:5001",
|
|
"",
|
|
].join("\n"));
|
|
|
|
// The route that asked for nothing carries no middleware — the predecessor's default stands.
|
|
const plain = await readFile(join(settings.dynamic, "mesh-git.example.yml"), "utf8");
|
|
assert.doesNotMatch(plain, /middlewares|buffering/);
|
|
});
|
|
|
|
// A limit it cannot honour is a route it does not write. Written without it, the predecessor would
|
|
// carry exactly what the module said not to carry, and this module would report success.
|
|
test("a body limit that is not a number of bytes is skipped and named", () => {
|
|
const machine = defaults.machine;
|
|
for (const limit of ["20g", 0, -1, 1.5, true, null]) {
|
|
const { routes, skipped } = routesFrom(
|
|
contributed({ from: "gate", name: "registry-api.example", port: 5001, limit }), machine);
|
|
assert.deepEqual(routes, [], `a limit of ${JSON.stringify(limit)} was served`);
|
|
assert.equal(skipped.length, 1);
|
|
assert.match(skipped[0]!, /max-request-body/);
|
|
}
|
|
// And a limit the controller would accept is carried, as a number.
|
|
const { routes } = routesFrom(
|
|
contributed({ from: "gate", name: "registry-api.example", port: 5001, limit: 1024 }), machine);
|
|
assert.equal(routes[0]?.maxRequestBody, 1024);
|
|
});
|