route-adapter: provide route by writing into the predecessor's proxy (hq ADR 0104) #42
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["adapter.ts", "index.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user