mesh/merge-gate pass: builds claude-code, mounts, route-adapter, new: modules/mesh-cli → ace, g14, novox, shanks; no bus step; every machine composes with …
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
The records send the operator to "the controller's terminal", and on the control-node nothing answered (hq issue 343). mesh-cli is a module of its own, assigned to every node and built from its own directory: its program is unpacked into /usr/local/bin by an archive resource (one file, no link, no copy), and its zsh contribution makes nox its alias. It holds no credential: it asks the node-engine on its machine over the SDK's go/cli socket, and prints the controller's answer with its exit. The agent module's refusals and README, and the route adapter's README, now name `mesh-cli settings set …` on the control-node instead of a mesh-controller command nobody could type; a settings file is passed inline, since a path would be read where the controller runs.
174 lines
9.4 KiB
Markdown
174 lines
9.4 KiB
Markdown
# 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 no certificate issuer: the predecessor holds the certificates and this must not obtain 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 the mechanism is
|
|
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-cli settings set route-adapter "$(cat 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
|
|
[mosquitto](../mosquitto/Dockerfile) does — 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.
|
|
|
|
## A body limit
|
|
|
|
A contribution may say `max-request-body`, in bytes, and the adapter writes it as the predecessor's
|
|
own `buffering` middleware, named after the router so the two halves cannot drift. A route that says
|
|
nothing gets no middleware and the predecessor's default stands.
|
|
|
|
This is the one thing the file shape *can* say that a policy cannot, which is why it is written
|
|
rather than skipped: the predecessor already served its own registry name this way. A limit that is
|
|
not a whole positive number of bytes takes the route with it — written without the limit, the
|
|
predecessor would carry exactly what the module said not to carry, and this module would report
|
|
success doing it.
|