Files
mesh-catalog/modules/route-adapter/adapter.ts
jochen b2e39eb2cd route-adapter: write a body limit as the predecessor's buffering middleware
The adapter skips what its one file shape cannot say. A body limit is the exception: the predecessor
has a buffering middleware and served its own registry name with exactly it, so this is written
rather than skipped, named after the router so the two halves cannot drift.

A limit that is not a whole positive number of bytes takes the route with it. Written without the
limit, the predecessor would carry what the module said not to carry and this module would report
success. Silence stays silence — no middleware, the predecessor's default.
2026-09-26 16:01:23 +02:00

358 lines
16 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;
/**
* The largest request body, in bytes, the predecessor may carry to it — the contribution's
* `max-request-body`. Absent is whatever the predecessor does by default.
*
* Unlike a policy, this file shape *can* say it: the predecessor has a buffering middleware, and
* its own registry route used exactly this. A registry takes image layers in single requests of
* gigabytes, so a route that could not say it would be a name nothing could be pushed to.
*/
maxRequestBody?: number;
}
/** 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;
}
// What this adapter's one file shape cannot say, it skips aloud rather than approximating:
// a backend over its own TLS (the file would send plain http into a TLS listener), a
// path-scoped or refusing or redirecting rule (the file routes whole hosts). 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, exactly as they do today.
const scheme = typeof entry.values?.["scheme"] === "string" ? (entry.values["scheme"] as string).trim().toLowerCase() : "";
if (scheme !== "" && scheme !== "http") {
skipped.push(`${from} asked for route ${name} over ${scheme}, which this file shape cannot say`);
continue;
}
if (typeof entry.values?.["path"] === "string" && (entry.values["path"] as string).trim() !== "") {
skipped.push(`${from} asked for route ${name} scoped to a path, which this file shape cannot say`);
continue;
}
if (entry.values?.["deny"] === true || typeof entry.values?.["redirect"] === "string") {
skipped.push(`${from} asked for route ${name} with a policy this file shape cannot say`);
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.
// A limit it cannot honour is a route it does not write — skipped and named, like a port that
// is not one. Written without the limit instead, the predecessor would carry exactly what the
// module said not to carry, and this adapter would report success.
const askedLimit = entry.values?.["max-request-body"];
const limit = asBodyLimit(askedLimit);
if (limit === null) {
skipped.push(
`${from} asked for route ${name} with a max-request-body of ${JSON.stringify(askedLimit)}, ` +
`which is not a whole positive number of bytes`,
);
continue;
}
const at = typeof entry.at === "string" && entry.at.trim() !== "" ? entry.at.trim() : machine;
routes.push({ name, from, target: `http://${at}:${port}`, ...(limit === undefined ? {} : { maxRequestBody: limit }) });
}
return { routes, skipped };
}
/**
* The body limit a contribution asked for: a number, `undefined` for silence, `null` for unusable.
*
* Three answers rather than two, because "said nothing" and "said something wrong" must not become
* the same route.
*/
function asBodyLimit(value: unknown): number | undefined | null {
if (value === undefined) {
return undefined;
}
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
return null;
}
return value;
}
/** 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);
// The body limit is a middleware in the predecessor's vocabulary — its `buffering`, with the one
// field the predecessor's own registry route set — named after the router so the two halves cannot
// drift, and written only when the contribution asked for it.
const limited = route.maxRequestBody !== undefined;
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}`,
...(limited ? [` middlewares: [${id}-body]`] : []),
" tls:",
` certResolver: ${settings.resolver}`,
" domains:",
` - main: ${route.name}`,
...(limited
? [
" middlewares:",
` ${id}-body:`,
" buffering:",
` maxRequestBodyBytes: ${route.maxRequestBody}`,
]
: []),
" 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;
}