ADR 0120: a roster fact carries its format as a template; rewrite to-be 29 around it
The facts mechanism formatted the roster in Go in the control plane — one formatter per fact, in the consumer's own configuration language. ADR 0120 makes a fact a path and a template: the mesh owns the data, the module owns the format, and the control plane holds no format at all. to-be 29 (operator accounts + what lives under a home) is rewritten to ride it: the ssh files become roster templates, the whole ~/.ssh is owned with a found/owned boundary that cannot lock the operator out, keys are mesh-owned through an SSH CA (existing keys adopted not regenerated, the operator's personal key signed not minted), and the ssh-agent is a user-scoped service.
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
---
|
||||
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.
|
||||
|
||||
**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)
|
||||
Reference in New Issue
Block a user