A roster fact may be shared — written into a marked region of the machine's file (into: block, hq 128) rather than as the whole file. The template renders the content; shared decides how the host lays it down. Composes with hq 128: the region mechanism is the host's, the format is the module's.
137 lines
8.9 KiB
Markdown
137 lines
8.9 KiB
Markdown
---
|
|
topic: what runs on it
|
|
status: accepted
|
|
date: 2026-09-27
|
|
deciders: jochen
|
|
reconstructed: false
|
|
extends: 0112-a-module-definition-names-no-node-mesh-or-path.md
|
|
---
|
|
|
|
# 120. A roster fact carries its format as a template: the mesh owns the data, the module owns the format
|
|
|
|
## Context
|
|
|
|
A **fact** is a thing only the mesh knows — which machines exist, what they are called, where they
|
|
are — written into a file where a module asks for it. The mesh computes it from the graph; a module
|
|
loads it, restarts on it, does what its software does with it. Facts replaced three modules that
|
|
existed only because computed output needed somewhere to live and ran no software of their own
|
|
([ADR 0040](0040-what-a-module-is.md)).
|
|
|
|
But the *format* lived in the control plane. A fact was a name from a closed list, and each name had
|
|
a formatter written in Go beside the others: `node-names` wrote the roster as an `/etc/hosts` file,
|
|
`node-zones` wrote it as a dnsmasq resolver's `local=`/`address=` lines. Adding a consumer meant
|
|
adding a formatter — in the consumer's own configuration language — to the mesh.
|
|
|
|
The ssh work made the cost plain. An operator's `~/.ssh` wants three roster projections — a
|
|
`known_hosts`, an ssh `config` of `Host` blocks, an `authorized_keys` — each in ssh's syntax. Under
|
|
the closed list that is three more formatters in the control plane, teaching it ssh's configuration
|
|
language. And it does not stop at ssh: every daemon that reads the roster in its own file format
|
|
would put its grammar here. The control plane was accreting the configuration languages of software
|
|
it does not run — the exact thing [ADR 0040](0040-what-a-module-is.md) says is a
|
|
module's and not the mesh's.
|
|
|
|
The shape underneath is one shape. WireGuard's `[Peer]` blocks, `/etc/hosts`, dnsmasq's zones, an
|
|
ssh `known_hosts` — all of them are *the roster, projected into a file*. Only the projection differs,
|
|
and the projection belongs to whoever runs the software that reads it.
|
|
|
|
## Considered Options
|
|
|
|
**1. Keep the closed list; add a formatter per consumer.** Rejected. The control plane learns the
|
|
configuration language of every daemon any module might run, without bound, and each format lives in
|
|
the mesh rather than in the module that owns the file. A module cannot change how its own file is
|
|
written without a control-plane change.
|
|
|
|
**2. A general placeholder vocabulary over `content`, like `${machine:address}` but for the
|
|
roster.** Rejected. The mesh's other substitutions each resolve to *one* scalar — this machine's
|
|
address, one provider's port. The roster is inherently a *repetition*: one block per machine. A flat
|
|
`${…}` vocabulary cannot iterate, and a mechanism that could would be a template in all but name.
|
|
|
|
**3. The module gives a path and a template over the roster; the mesh renders it.** Chosen. The mesh
|
|
owns the data — who exists, their names and addresses — and hands it to a Go `text/template` the
|
|
module wrote. The mesh renders and reads neither the template's intent nor the file's meaning.
|
|
|
|
## Decision
|
|
|
|
**A fact is a path and a template.** In a module's manifest, `facts` maps a name the module chooses
|
|
to a `{ path, template }`. The template is a Go `text/template` over a fixed **roster view**:
|
|
|
|
- `.Node` — this machine's bare name.
|
|
- `.Suffix` — what a mesh name ends in (`internal`, or the operator's choice), as composed.
|
|
- `.Names` — every name the mesh serves: the machines *and* the names it was told to route.
|
|
- `.Machines` — only the machines that are nodes of this mesh.
|
|
|
|
Each of `.Names` and `.Machines` is a list of `{ Name, FQDN, Address }`. A machine the mesh has a
|
|
record for but cannot yet place has no address and is left out of both — a name that resolves to
|
|
nothing is a connection that hangs, so it is omitted rather than written (the same rule as before).
|
|
|
|
**The mesh owns the data; the module owns the format.** The control plane holds **no** formatter.
|
|
The two built-in projections render through the same path any module uses:
|
|
|
|
- **`/etc/hosts`** is a template on the mesh's own network module. The mesh writes `/etc/hosts`
|
|
because being on the private network is what gives a machine a name — but the *layout* is a
|
|
template like any other, shipped with the control plane because that module ships with it, not
|
|
because the control plane knows the hosts-file format.
|
|
- **dnsmasq's zones** move into dnsmasq. The `local=`/`address=` grammar is dnsmasq's configuration
|
|
language, and it now lives in dnsmasq's manifest, where the module that runs dnsmasq owns it.
|
|
|
|
**A fact also says whether its file is the mesh's whole or a region of the machine's.** A hosts file
|
|
is the machine's — its `localhost`, the operator's lines, another tool's marked blocks — so
|
|
`node-names` is `shared`: the mesh owns only its region and keeps the rest byte for byte, the host
|
|
laying it down `into: block`
|
|
([ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md), hq issue 128). A resolver's
|
|
zones file is the mesh's whole, and is not shared. The template renders the content either way;
|
|
`shared` decides how the host writes it. This composes with hq 128 rather than replacing it: the
|
|
region *mechanism* is the host's, the region's *format* is the module's template.
|
|
|
|
**The names-vs-machines distinction is the template's choice** ([04-ISSUES/111](../04-ISSUES/111-the-resolver-is-told-names-the-mesh-serves-not-only-machines/00-report.md)):
|
|
a container's hosts ranges `.Names`, so a routed name resolves to the machine serving it; a resolver
|
|
told the mesh's suffix is its own ranges `.Machines`, or a routed name written there with the suffix
|
|
appended is a name nobody will ever ask for.
|
|
|
|
**A template that will not render is refused at composition, not on a machine.** A template that does
|
|
not parse, or reads a field the roster does not have, fails where the manifest is — the closed-list
|
|
safety, moved from the fact's *name* to the roster's *shape*. A daemon that starts, reads a file the
|
|
mesh could not render, and answers nothing is a much worse way to find out.
|
|
|
|
**WireGuard stays a computed generator, and that is the line.** Its `mesh0.conf` is not a pure roster
|
|
projection — it carries topology the control plane decides: which peers are reachable, endpoints, hub
|
|
forwarding, keepalive for a NAT'd node. And it is *foundational*: the overlay must be up before any
|
|
module can be delivered, so the thing that writes it cannot itself be a delivered module. The line
|
|
this draws: **the substrate that delivery rides on is the control plane's; everything layered on a
|
|
working overlay is a roster template.** DNS, hosts, and ssh are layered; the overlay is the floor.
|
|
|
|
## Consequences
|
|
|
|
- **ssh is two templates and no control-plane change.** Once the roster view carries a machine's ssh
|
|
host key and its operator account ([to-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md)),
|
|
`known_hosts`, the ssh `config`, and `authorized_keys` are templates on the ssh modules — the mesh
|
|
gains no knowledge of ssh's syntax. This ADR is what makes that work land without touching the
|
|
controller.
|
|
- **A new roster projection never touches the control plane.** Any module that reads the roster in
|
|
its own format ships its own template.
|
|
- **A module can change how its own file is written** without a control-plane change — it is editing
|
|
its own manifest.
|
|
- **The schema changed and is not backward compatible.** A fact was a string (a path); it is now
|
|
`{ path, template }`. The old string form has no template and cannot be auto-upgraded, because the
|
|
format it implied was the formatter this ADR deletes. The controller and every catalogue module
|
|
using facts — only dnsmasq — land together. A controller and a catalogue that disagree cannot
|
|
compose the module: the running daemon on a machine is unaffected, but the mesh will not send it a
|
|
new declaration until both sides agree.
|
|
- **The output did not change.** The `/etc/hosts` and dnsmasq zones a machine receives are
|
|
byte-for-byte what the deleted formatters wrote, pinned by tests that render the built-in template
|
|
and compose the real dnsmasq manifest.
|
|
|
|
## References
|
|
|
|
- [ADR 0040](0040-what-a-module-is.md): a module is software the mesh runs — a
|
|
format the mesh knows for software it does not run was the accretion this stops
|
|
- [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md): a module definition names no
|
|
path; this is its sibling for content — a module definition names no format the mesh must know
|
|
- [to-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md): the ssh consumer this
|
|
unblocks, and the roster fields it will add
|
|
- [04-ISSUES/111](../04-ISSUES/111-the-resolver-is-told-names-the-mesh-serves-not-only-machines/00-report.md): every served name is not a
|
|
machine — now the template's choice of `.Names` or `.Machines`
|
|
- mesh-controller `internal/catalogue/roster.go` (the mechanism), `internal/overlay/generator.go`
|
|
(the built-in `/etc/hosts` template), `internal/catalogue/manifest.go` (`RosterFile`)
|
|
- mesh-catalog `modules/dnsmasq/module.json` (the zones template, dnsmasq's own)
|