Files
mesh-catalog/modules/route-adapter/adapter.ts
T
jschoubben bd5b349a0d route-adapter: provide route by writing into the predecessor's proxy (hq ADR 0104)
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.
2026-09-23 00:11:21 +02:00

290 lines
12 KiB
TypeScript

// The route adapter: it provides `route` by writing route files for the predecessor's proxy.
//
// **Migration scaffolding, and it says so** (novox/hq ADR 0104). The mesh has one provider of
// `route`, its own proxy, and that proxy binds the two public ports on the machine's own network.
// On a node being adopted (ADR 0100) the predecessor's proxy holds those ports and serves every
// public name there, so the two cannot run at once — and until one of them does, no module that
// must be reachable by name can be taken at all.
//
// So this module answers the same provision by writing into the predecessor's own configuration
// instead of proxying itself. It receives exactly what the proxy receives — the contributions file
// at `receives.route` — and turns each contribution into one route file where the predecessor's
// file provider reads. The predecessor keeps every name it already serves and keeps renewing its
// certificates; a name whose module has migrated is served by the same proxy, pointing at the
// mesh's container instead of the predecessor's.
//
// Two rules hold the whole thing safe, and both are tested:
//
// - **one file per contribution**, named so the mesh can recognise its own, and
// - **it never touches a file it did not write** — not a file of the predecessor's, and not even
// a `mesh-…` file that carries no marker of this module's.
//
// It is removed when the predecessor's proxy retires, at which point the mesh's own proxy takes
// the ports and already knows every route.
import { readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
/**
* One entry of the contributions file the mesh writes at `receives.route`.
*
* The same shape the mesh's own proxy reads (`mesh-control/examples/route-proxy`), because the
* contract is the file and not the program — which is the whole reason one provider can stand in
* for another without a module that publishes through it noticing.
*/
export interface Contribution {
/** The module that asked for a route. */
from?: string;
/** The node it was assigned to. */
node?: string;
/**
* Where the mesh says that machine is on the private network. Empty means this one — a workload
* beside the predecessor's proxy is the ordinary case during a migration.
*/
at?: string;
values?: Record<string, unknown>;
}
/** What the predecessor needs to be told, and the facts about this one machine. */
export interface Settings {
/** The directory the predecessor's file provider reads, on this machine. */
dynamic: string;
/** The entry point it serves public HTTPS on. */
entrypoint: string;
/**
* The predecessor's certificate resolver, by its own name.
*
* **Its own, deliberately.** The predecessor holds the certificates for every public name and
* renews them; naming its resolver means a migrated name is served from the certificate that
* already exists. A resolver of the mesh's would ask a public authority for a certificate in the
* same window the module is cut over, which is the risk ADR 0104 exists to remove.
*/
resolver: string;
/**
* How the predecessor's proxy reaches this machine. It runs in a container, so the machine is
* not `127.0.0.1` from where it stands.
*/
machine: string;
}
/** One route this module is to keep in force. */
export interface Route {
/** The public name, lowercased. */
name: string;
/** The module that contributed it, for the note inside the file. */
from: string;
/** Where the predecessor's proxy is to send it. */
target: string;
}
/** What one pass changed. */
export interface Pass {
written: string[];
removed: string[];
skipped: string[];
}
/** The line that marks a file as this module's. Recognition is by name *and* by this. */
export const marker = "# written by the novox mesh route-adapter (novox/hq ADR 0104)";
/** What this module's files are called, and so what it will consider removing. */
const ours = /^mesh-[A-Za-z0-9._*-]+\.yml$/;
/** What a public name may be made of. Anything else is refused rather than turned into a path. */
const aName = /^[a-z0-9*][a-z0-9.*-]*$/;
/** The defaults, which are also what the manifest's settings file says. */
export const defaults: Settings = {
dynamic: "/services/traefik/dynamic",
entrypoint: "websecure",
resolver: "le",
machine: "host.docker.internal",
};
/**
* The settings, as the node set them.
*
* **A per-node fact, not a constant** (novox/hq ADR 0100, whose `ports` setting is the precedent).
* Where the predecessor keeps the directory its file provider reads is true of one machine and of
* nothing else, so it is a setting laid over the module's default rather than a number in the
* catalogue. It reaches the module the way cloudflare-dns's does: a `merge: "json"` file the mesh
* composes and the container reads.
*/
export function settingsFrom(raw: unknown): Settings {
const given = (raw ?? {}) as Record<string, unknown>;
const text = (key: string, fallback: string): string => {
const value = given[key];
return typeof value === "string" && value.trim() !== "" ? value.trim() : fallback;
};
return {
dynamic: text("dynamic", defaults.dynamic),
entrypoint: text("entrypoint", defaults.entrypoint),
resolver: text("certificate-resolver", defaults.resolver),
machine: text("machine", defaults.machine),
};
}
/**
* The routes a contributions document asks for.
*
* A contribution the adapter cannot act on is skipped and said aloud rather than guessed at: the
* mesh's proxy does the same, and a route invented from half a contribution is a name that answers
* for something nobody asked for.
*/
export function routesFrom(document: unknown, machine: string): { routes: Route[]; skipped: string[] } {
const given = ((document ?? {}) as { given?: unknown }).given;
const entries: Contribution[] = Array.isArray(given) ? (given as Contribution[]) : [];
const routes: Route[] = [];
const skipped: string[] = [];
for (const entry of entries) {
const from = typeof entry.from === "string" && entry.from !== "" ? entry.from : "an unnamed module";
const asked = entry.values?.["name"];
const name = typeof asked === "string" ? asked.trim().toLowerCase() : "";
if (name === "") {
skipped.push(`${from} asked for a route and named nothing`);
continue;
}
if (!aName.test(name)) {
// Refused rather than sanitised: this name becomes a file name in a directory belonging to
// something else, and quietly rewriting it is how a module ends up serving a name it never
// asked for — or writing outside the directory altogether.
skipped.push(`${from} asked for ${JSON.stringify(name)}, which is not a name this can write`);
continue;
}
const port = asPort(entry.values?.["port"]);
if (port === undefined) {
skipped.push(`${from} asked for route ${name} and gave no usable port`);
continue;
}
// Where the mesh says that machine is. Empty means this one, and this one is reached from
// inside the predecessor's container by the machine's own name, not by loopback.
const at = typeof entry.at === "string" && entry.at.trim() !== "" ? entry.at.trim() : machine;
routes.push({ name, from, target: `http://${at}:${port}` });
}
return { routes, skipped };
}
/** The file one route is written to. The prefix is how the mesh recognises its own. */
export function fileNameFor(name: string): string {
return `mesh-${name}.yml`;
}
/** The router and service name inside that file. One name, so the two halves cannot drift. */
export function routerNameFor(name: string): string {
return `mesh-${name.replace(/[^a-z0-9-]/g, "-")}`;
}
/**
* One route, in the shape the predecessor already reads.
*
* Written out rather than produced by a YAML library: the document has one shape, every value in
* it is the mesh's own, and a dependency whose output could drift is a dependency that could stop
* the predecessor parsing the directory it serves everything else from.
*/
export function routeFile(route: Route, settings: Settings): string {
const id = routerNameFor(route.name);
return [
marker,
`# ${route.from} contributed this route. It is removed when that contribution goes.`,
"http:",
" routers:",
` ${id}:`,
` entryPoints: [${settings.entrypoint}]`,
` rule: Host(\`${route.name}\`)`,
` service: ${id}`,
" tls:",
` certResolver: ${settings.resolver}`,
" domains:",
` - main: ${route.name}`,
" services:",
` ${id}:`,
" loadBalancer:",
" servers:",
` - url: ${route.target}`,
"",
].join("\n");
}
/**
* Whether a file in the predecessor's directory is this module's to remove.
*
* **Two tests, not one.** The name says which files this module may consider at all; the marker
* inside says it actually wrote this one. A file somebody else happened to call `mesh-something`
* is not this module's, and deleting it because the name matched would be exactly the fault the
* rule exists to prevent — the mesh is a guest in this directory.
*/
export function isOurs(fileName: string, body: string): boolean {
return ours.test(fileName) && body.startsWith(marker);
}
/**
* Bring the predecessor's directory in line with what the mesh contributed: one file per route,
* this module's files that no route wants any more removed, and everything else untouched.
*
* A file whose content is already right is not rewritten, so an unchanged pass does not wake the
* predecessor's watcher for nothing.
*/
export async function reconcile(routes: Route[], settings: Settings): Promise<Pass> {
const directory = settings.dynamic;
const present = await stat(directory).catch(() => undefined);
if (!present?.isDirectory()) {
// Named, and named in the operator's terms. On an adopted node this 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.
throw new Error(
`the predecessor's dynamic directory ${directory} is not there. It is this node's ` +
`\`dynamic\` setting, and the module's container mounts it at the same path — so either ` +
`the setting names somewhere else, or the mount does not follow it`,
);
}
const wanted = new Map<string, string>();
for (const route of routes) {
wanted.set(fileNameFor(route.name), routeFile(route, settings));
}
const pass: Pass = { written: [], removed: [], skipped: [] };
for (const [fileName, body] of [...wanted].sort()) {
const at = join(directory, fileName);
const already = await readFile(at, "utf8").catch(() => undefined);
if (already === body) {
continue;
}
if (already !== undefined && !isOurs(fileName, already)) {
// A file of this module's name that this module did not write. Left exactly as it is: the
// directory is the predecessor's, and the one thing this module may never do is overwrite
// something it cannot show it owns.
pass.skipped.push(`${fileName} is not this module's to write, so the route it wants is not served`);
continue;
}
// Renamed into place rather than written in place: the predecessor watches this directory and
// would otherwise read a file half written.
const staged = at + ".mesh-staged";
await writeFile(staged, body, { mode: 0o644 });
await rename(staged, at);
pass.written.push(fileName);
}
for (const fileName of (await readdir(directory)).sort()) {
if (wanted.has(fileName) || !ours.test(fileName)) {
continue;
}
const body = await readFile(join(directory, fileName), "utf8").catch(() => undefined);
if (body === undefined || !isOurs(fileName, body)) {
continue;
}
await rm(join(directory, fileName));
pass.removed.push(fileName);
}
return pass;
}
/** What JSON makes of a port, which is a float even where it was written 8080. */
function asPort(value: unknown): number | undefined {
const port = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
if (!Number.isInteger(port) || port < 1 || port > 65535) {
return undefined;
}
return port;
}