// 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; } /** 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; 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 { 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(); 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; }