Files
mesh-catalog/modules/route-adapter/README.md
T
jschoubben 3249b9a0cc The registry's public name is a second module beside the store, locked by the registry itself
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.
2026-09-23 23:19:12 +02:00

9.7 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

A contribution may also carry max-request-body, the largest request body in bytes the proxy may carry to it — the registry's public name needs twenty gigabytes for a layer push (novox/hq ADR 0082, the registry hand-over). It is written as the predecessor's own buffering middleware, named after the router, and only when asked for:

  routers:
    mesh-registry-api-example:
      # …
      middlewares: [mesh-registry-api-example-body]
  middlewares:
    mesh-registry-api-example-body:
      buffering:
        maxRequestBodyBytes: 21474836480
  • 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.
  • A limit it cannot honour is a route it does not write. A max-request-body that is not a whole positive number of bytes is skipped and named, like a port that is not one — written without the limit, the predecessor would carry exactly what the module said not to carry.
  • 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 three facts a route file has to get right: the port the contributor publishes, the address of the machine it is on, and the body limit it asked for, written as the predecessor's middleware or not at all.