Files
mesh-catalog/modules/route-adapter/README.md
T
jschoubben bd5b349a0d route-adapter: provide route by writing into the predecessor's proxy (hq ADR 0104)
A node being adopted cannot take a web module: every module reachable by
name requires route, the mesh's only provider of it binds the two public
ports, and the predecessor's proxy holds them and serves every public
name there. Stopping the predecessor to break the circle darkens every
name at once, with every certificate to re-obtain in the same window.

So this answers the same provision without binding anything. It provides
route and receives the same contributions file, and writes each
contribution as one route file where the predecessor's file provider
reads, naming the predecessor's own certificate resolver so no
certificate is asked for. It removes a file it wrote when its
contribution goes and never touches a file it did not write — the name
and a marker inside both have to say it is the mesh's.

A step, not a daemon: run-once, re-run by restart-on over the received
file and the settings. The predecessor's dynamic directory is a node
setting, because it is a fact about one machine.

Migration scaffolding with a stated end: assigned only on an adopted
node, deleted when the predecessor's proxy retires.
2026-09-23 00:11:21 +02:00

8.8 KiB

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, which decides it, and issue 093, 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 — 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:

# 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 and mosquitto 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.