The predecessor serves the registry under a public name, behind htpasswd basic auth, with a twenty-gigabyte body limit for layer pushes. The mesh's registry has no name, no lock and no limit — by design inside the mesh, where the private network is the boundary and every node pulls without an account (hq ADR 0082). Taking the name over must not change that. A route on `distribution` itself would: contributing a route is requiring one, and the store is raised at genesis on a node with no proxy. So the public door is `distribution-gate`, a second registry process on the same volume, behind the registry's own htpasswd (the predecessor's realm, the predecessor's file, carried in with `secret accept`), with the route and its limit. It requires the store's storage as a node-scoped provision, so it can only land beside the store. The store's own door is untouched — no auth, no htpasswd — which is what keeps the builder's pushes and every node's pulls working. Both processes read the predecessor's configuration where it changed behaviour: delete enabled, which tag retention depends on; no per-process descriptor cache, which two processes over one store cannot share; the CORS headers for the retired interface dropped. route-adapter writes the limit as the predecessor's own buffering middleware, named after the router, only when asked for — and skips a route whose limit it cannot read rather than carrying what the module said not to. hq ADR 0082/0104, the registry hand-over.
341 lines
14 KiB
TypeScript
341 lines
14 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.
|
|
*
|
|
* **The registry's hand-over is why it exists** (novox/hq ADR 0082). A registry takes image
|
|
* layers in single requests of gigabytes, and the predecessor served the registry's public name
|
|
* with exactly this as a buffering middleware; a route that could not say it would have a public
|
|
* name it could not 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;
|
|
}
|
|
const port = asPort(entry.values?.["port"]);
|
|
if (port === undefined) {
|
|
skipped.push(`${from} asked for route ${name} and gave no usable port`);
|
|
continue;
|
|
}
|
|
// 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 module would report success.
|
|
const asked_limit = entry.values?.["max-request-body"];
|
|
const limit = asBodyLimit(asked_limit);
|
|
if (limit === null) {
|
|
skipped.push(
|
|
`${from} asked for route ${name} with a max-request-body of ${JSON.stringify(asked_limit)}, ` +
|
|
`which is not a whole positive number of bytes`,
|
|
);
|
|
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}`, ...(limit === undefined ? {} : { maxRequestBody: limit }) });
|
|
}
|
|
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);
|
|
// 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;
|
|
}
|
|
|
|
/**
|
|
* A contribution's `max-request-body`: `undefined` when it said nothing, the number of bytes when
|
|
* it is a whole positive number, and `null` when it is anything else — the same rule the
|
|
* controller's catalogue applies when it parses the manifest, so a limit that reaches here has
|
|
* already passed it once, and one that fails it was laid over by a setting.
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/** 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;
|
|
}
|