diff --git a/modules/route-adapter/Dockerfile b/modules/route-adapter/Dockerfile new file mode 100644 index 0000000..d65de80 --- /dev/null +++ b/modules/route-adapter/Dockerfile @@ -0,0 +1,24 @@ +# route-adapter's runtime: the tool runtime, carrying this module's compiled code. +# +# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in +# the base images, published like any other artifact — which is what makes this buildable by the +# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that +# happens to have the siblings. +# +# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the +# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`. +ARG BUILD_BASE +ARG RUNTIME_BASE + +FROM ${BUILD_BASE} AS build +WORKDIR /app/modules/route-adapter +COPY . . +RUN node /app/node_modules/typescript/bin/tsc adapter.ts index.ts \ + --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist + +FROM ${RUNTIME_BASE} +COPY --from=build /app/modules/route-adapter/dist /app/modules/route-adapter/dist +# **No MESH_TOOL_MODULES, deliberately.** This module serves no tool and consumes no event: it is a +# step the host runs to completion, named by the container's `args` as `mesh-tools run …`. Setting a +# serve-time entrypoint here would give the image a second way to be started — one that connects to +# the broker and never exits. diff --git a/modules/route-adapter/README.md b/modules/route-adapter/README.md new file mode 100644 index 0000000..ccf82c2 --- /dev/null +++ b/modules/route-adapter/README.md @@ -0,0 +1,161 @@ +# route-adapter — `route`, answered by writing into the predecessor's proxy + +**This is migration scaffolding.** It exists so that a node being adopted can migrate one web +module at a time, and it is deleted when that migration ends. novox/hq +[ADR 0104](https://git.novox.be/novox/hq), which decides it, and +[issue 093](https://git.novox.be/novox/hq), which found the fault. + +## What it is + +Every module reachable by name requires a **route**. The mesh has one provider of it — its own +proxy, [`route-proxy`](../route-proxy/README.md) — and that proxy binds the two public ports on the +machine's own network. On a node that is adopted (novox/hq ADR 0100) the predecessor's proxy holds +those ports and serves every public name there, so the two cannot run at once. That is a circle: no +web module can be taken until the mesh's proxy runs, the mesh's proxy cannot run until the +predecessor's stops, and when it stops every name the predecessor served goes dark. + +This module answers the same provision **without binding anything**. It provides `route`, receives +exactly the contributions file the proxy receives, and turns each contribution into one route file +in the directory the predecessor's file provider reads. The predecessor keeps serving 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. + +| field | value | why | +|---|---|---| +| `provides` | `route` (scope `mesh`) | exactly what `route-proxy` provides, so the controller resolves `route` from this unchanged — a module that publishes through it cannot tell which one answered | +| `serves.route` | `{}` | a route hands back a name, not a credential | +| `receives.route` | `…/routes/mesh.json` | the same contributions file, in the same shape | +| `listens` | *nothing* | **the point.** It opens no port, so it stands beside the predecessor rather than replacing it | +| `requires` | *nothing* | in particular not `acme-ca`: the predecessor holds the certificates and this must not ask for one | + +Assign **either** this or `route-proxy` to a node, never both — two providers of one mesh-scoped +provision is an ambiguity the resolver is right to refuse. + +## What it writes + +One file per contribution, named `mesh-.yml`, in the predecessor's dynamic directory. For a +contribution naming `git.example` on port 2999, on this machine: + +```yaml +# written by the novox mesh route-adapter (novox/hq ADR 0104) +# 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 +``` + +- **The certificate resolver is the predecessor's own**, by its own name. The predecessor already + holds a certificate for every public name it serves, so naming its resolver means a migrated name + is served from the certificate that exists. A resolver of the mesh's would ask a public authority + for one in the same window the module is cut over — the risk ADR 0104 exists to remove. +- **The port is the contributor's**, straight out of the contribution: the mesh assigned the + machine-side number (novox/hq ADR 0038) and carries it there. Nothing here guesses it. +- **The address is where the mesh says that machine is.** Empty means this one, reached from inside + the predecessor's container at `host.docker.internal` — not loopback, which from inside that + container is the container. A contributor on another node carries its overlay address and the + predecessor is sent straight there. + +**It never touches a file it did not write.** Two tests, not one: the name must match `mesh-*.yml`, +*and* the file must start with the marker line above. A file somebody else happened to call +`mesh-something` is not this module's — it is left alone, and the route that wanted that name is +reported as unserved rather than written over. The directory belongs to the predecessor; the mesh +is a guest in it. + +It removes a file it wrote when that contribution goes — including when nothing is contributed at +all, which is what unassigning the contributing module has to mean. + +## How it runs again + +It is a **step**, not a daemon: one `run-once` container that reads two files, reconciles the +directory and exits. It names both files under `restart-on`, so the host runs it again the moment +either changes — the mechanism already in the catalogue for *a fact arrived, do it again* +(novox/hq ADR 0099). + +| `restart-on` | what changed | +|---|---| +| `received-route` | a module contributing a route arrived or left, or its port moved | +| `config` | this node's settings for the adapter changed | + +A watcher of its own would be a second way to notice the same event, and it would keep running +after the module was unassigned. + +## The node setting + +Where the predecessor keeps the directory its file provider reads is a fact about **one machine**, +so it is a setting laid over the module's default rather than a constant in the catalogue — +novox/hq ADR 0100's `ports` is the precedent, and cloudflare-dns's `config.json` is the mechanism: +a `merge: "json"` file the mesh composes from the module's defaults and the node's layer, mounted +into the container. + +| key | default | meaning | +|---|---|---| +| `dynamic` | `/services/traefik/dynamic` | the directory the predecessor's file provider reads, on this machine | +| `entrypoint` | `websecure` | the entry point it serves public HTTPS on | +| `certificate-resolver` | `le` | the predecessor's resolver, by its own name | +| `machine` | `host.docker.internal` | how the predecessor's proxy reaches this machine | + +``` +mesh-controller settings set route-adapter settings.json --node +``` + +**One thing does not follow the setting yet, and it fails loudly rather than quietly.** The +container has to have that directory bind-mounted, and a bind mount's host side is written in the +manifest — the control plane can settle a file's content and a container's published ports, but not +a volume. So the mount is the default path. A node whose predecessor keeps its directory elsewhere +needs the manifest's mount changed with the setting; until then the module refuses on its first +pass and says exactly that, rather than writing route files somewhere nothing reads and reporting +success. The general fix — a per-node path reaching a container's volumes, as `ports` reaches its +published ports — is a decision for novox/hq, not for a module that is scheduled for deletion. + +## Using it on an adopted node + +1. The node is **adopted** and the predecessor's control is stopped (novox/hq ADR 0100). This module + is refused on a converged node: it writes into a directory the predecessor owns, and that is only + safe while nothing else is writing there. +2. `assign route-adapter`, and set `dynamic` for the node if the predecessor keeps its + directory anywhere but the default. +3. The predecessor's proxy must be able to reach the machine — `host.docker.internal` resolving to + the host gateway, as it already does for every service of the predecessor's that runs beside it. +4. Assign and then **`take `** for each web module, one at a time, when its data has + moved. On the take, the module's container starts on the machine port the mesh gave it and the + adapter writes its route file; **remove the predecessor's own route file for that name** in the + same step, or two routers claim one `Host` rule and which one answers is not something to rely + on. +5. At the end, `converge `: the predecessor's proxy retires, `route-proxy` takes the ports and + already knows every route, because by then every name belongs to a module that contributes it. +6. **Then delete this module.** It has a stated end; something has to delete it, and that something + is the operator. + +## How it ships + +The tool runtime carrying this module's compiled code, as +[cloudflare-dns](../cloudflare-dns/Dockerfile) and [mosquitto](../mosquitto/Dockerfile) do — built +from this directory and nothing else. It connects to no broker: reconciling files on the machine it +runs on is an offline operation, and `mesh-tools run` gives it exactly that. + +| env | default | meaning | +|---|---|---| +| `MESH_RECEIVES` | `/var/lib/route-adapter/routes/mesh.json` | the contributions file (`receives.route`) | +| `MESH_ROUTE_ADAPTER_CONFIG` | `/run/config/config.json` | the settled settings file | + +## Tests + +``` +cd modules/route-adapter && npm test +``` + +They hold it to what ADR 0104 says holds it: one file per contribution, a file removed when its +contribution goes, every file it did not write left alone — and the two facts a route file has to +get right, the port the contributor publishes and the address of the machine it is on. diff --git a/modules/route-adapter/adapter.ts b/modules/route-adapter/adapter.ts new file mode 100644 index 0000000..9b0324a --- /dev/null +++ b/modules/route-adapter/adapter.ts @@ -0,0 +1,289 @@ +// 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; +} diff --git a/modules/route-adapter/index.ts b/modules/route-adapter/index.ts new file mode 100644 index 0000000..cbfbf42 --- /dev/null +++ b/modules/route-adapter/index.ts @@ -0,0 +1,50 @@ +// route-adapter's one pass — the module's own code (novox/hq ADR 0039), run as a step (ADR 0052) +// and run *again* whenever what it reads changes. +// +// **A step, not a loop.** Everything this module does is a function of two files the mesh writes: +// the contributions at `receives.route`, and its settings. The manifest's container is `run-once` +// and names both under `restart-on`, so the host runs it once and runs it again the moment either +// changes — which is the mechanism already in the catalogue for "a fact arrived, do it again" +// (novox/hq ADR 0099). A watcher of its own would be a second way to notice the same event, and it +// would keep running after the module was unassigned. +// +// It connects to no broker: this is an offline file operation on the machine it runs on, and +// `mesh-tools run` gives it exactly that. + +import { readFile } from "node:fs/promises"; + +import { reconcile, routesFrom, settingsFrom } from "./adapter.js"; + +const receives = process.env.MESH_RECEIVES ?? "/var/lib/route-adapter/routes/mesh.json"; +const configFile = process.env.MESH_ROUTE_ADAPTER_CONFIG ?? "/run/config/config.json"; + +// The settings the node laid over the module's defaults. Absent is not an error — the mesh always +// writes the file, and a node that overrode nothing leaves it holding the defaults. +const settings = settingsFrom(await readJSON(configFile)); + +// The contributions. Absent is also not an error here: the host writes this file before it starts +// the container, and the empty case — nothing contributed — means every route this module wrote is +// to be taken away, which is a real instruction and not a reason to stop. +const { routes, skipped } = routesFrom(await readJSON(receives), settings.machine); +for (const why of skipped) { + console.warn(`[route-adapter] ${why}`); +} + +const pass = await reconcile(routes, settings); +for (const why of pass.skipped) { + console.warn(`[route-adapter] ${why}`); +} +console.log( + `[route-adapter] ${routes.length} route(s) contributed; ` + + `wrote ${pass.written.length} and removed ${pass.removed.length} in ${settings.dynamic}` + + (pass.written.length > 0 ? `: ${pass.written.join(", ")}` : "") + + (pass.removed.length > 0 ? `; gone: ${pass.removed.join(", ")}` : ""), +); + +async function readJSON(path: string): Promise { + const raw = await readFile(path, "utf8").catch(() => undefined); + if (raw === undefined) { + return undefined; + } + return JSON.parse(raw) as unknown; +} diff --git a/modules/route-adapter/module.json b/modules/route-adapter/module.json new file mode 100644 index 0000000..8003fda --- /dev/null +++ b/modules/route-adapter/module.json @@ -0,0 +1,93 @@ +{ + "module": "route-adapter", + "version": "1", + "slug": "radapt", + "capabilities": [ + "container-runtime" + ], + "provides": [ + { + "name": "route", + "scope": "mesh" + } + ], + "serves": { + "route": {} + }, + "receives": { + "route": "/var/lib/route-adapter/routes/mesh.json" + }, + "accesses": [ + { + "path": "/services/traefik/dynamic", + "mode": "read-write" + } + ], + "resources": [ + { + "id": "state", + "type": "directory", + "path": "/var/lib/route-adapter", + "mode": "0700" + }, + { + "id": "routes-dir", + "type": "directory", + "path": "/var/lib/route-adapter/routes", + "mode": "0700" + }, + { + "id": "config", + "type": "file", + "path": "/var/lib/route-adapter/config.json", + "merge": "json", + "mode": "0644", + "content": "{\n \"dynamic\": \"/services/traefik/dynamic\",\n \"entrypoint\": \"websecure\",\n \"certificate-resolver\": \"le\",\n \"machine\": \"host.docker.internal\"\n}\n" + }, + { + "id": "adapt", + "type": "container", + "name": "mesh-route-adapter", + "artifact": "runtime", + "run-once": true, + "volumes": [ + "/var/lib/route-adapter/routes/mesh.json:/var/lib/route-adapter/routes/mesh.json:ro", + "/var/lib/route-adapter/config.json:/run/config/config.json:ro", + "/services/traefik/dynamic:/services/traefik/dynamic" + ], + "env": { + "MESH_RECEIVES": "/var/lib/route-adapter/routes/mesh.json", + "MESH_ROUTE_ADAPTER_CONFIG": "/run/config/config.json" + }, + "args": [ + "run", + "/app/modules/route-adapter/dist/index.js" + ], + "restart-on": [ + "received-route", + "config" + ] + } + ], + "build": { + "on": [ + { + "arg": "BUILD_BASE", + "module": "mesh-tools", + "artifact": "build" + }, + { + "arg": "RUNTIME_BASE", + "module": "mesh-tools", + "artifact": "runtime" + } + ], + "artifacts": [ + { + "name": "runtime", + "kind": "image", + "from": "Dockerfile" + } + ] + } +} diff --git a/modules/route-adapter/package.json b/modules/route-adapter/package.json new file mode 100644 index 0000000..c481652 --- /dev/null +++ b/modules/route-adapter/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-route-adapter", + "version": "0.1.0", + "description": "route-adapter — migration scaffolding (novox/hq ADR 0104): provides `route` on an adopted node by writing route files for the predecessor's proxy instead of proxying itself.", + "type": "module", + "private": true, + "scripts": { + "test": "node --test --experimental-strip-types 'test/*.test.ts'" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/route-adapter/test/adapter.test.ts b/modules/route-adapter/test/adapter.test.ts new file mode 100644 index 0000000..b8515f6 --- /dev/null +++ b/modules/route-adapter/test/adapter.test.ts @@ -0,0 +1,213 @@ +// 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 = {}): Promise { + 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); +}); diff --git a/modules/route-adapter/tsconfig.json b/modules/route-adapter/tsconfig.json new file mode 100644 index 0000000..58d26ac --- /dev/null +++ b/modules/route-adapter/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["adapter.ts", "index.ts"] +}