Merge pull request 'route-adapter: provide route by writing into the predecessor's proxy (hq ADR 0104)' (#42) from feat/route-adapter into main

This commit was merged in pull request #42.
This commit is contained in:
2026-09-23 00:12:48 +02:00
8 changed files with 856 additions and 0 deletions
+24
View File
@@ -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.
+161
View File
@@ -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-<name>.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 <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 <node> 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 <node> <module>`** 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 <node>`: 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.
+289
View File
@@ -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<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;
}
+50
View File
@@ -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<unknown> {
const raw = await readFile(path, "utf8").catch(() => undefined);
if (raw === undefined) {
return undefined;
}
return JSON.parse(raw) as unknown;
}
+93
View File
@@ -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"
}
]
}
}
+14
View File
@@ -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"
}
}
+213
View File
@@ -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<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 }[]) {
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);
});
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["adapter.ts", "index.ts"]
}