Facts carry the format as a template, so the control plane holds none (ADR 0120)

A roster fact used to be a name from a closed list, each formatted in Go
here — node-names as a hosts file, node-zones as a resolver's zones. Every
new consumer (ssh's known_hosts, an authorized_keys) meant another formatter
in the control plane, in the consumer's own configuration language.

Now a fact is a path and a Go template over the roster view (this node, the
suffix, and every served name vs the machines). The mesh owns the data; the
module owns the format. /etc/hosts is a template on the network module;
dnsmasq's zones move to dnsmasq. The controller renders and reads neither.

WireGuard stays a computed generator: the overlay is the substrate delivery
rides on, and its config is topology, not a roster projection.

Output is byte-for-byte unchanged, pinned by the hosts golden tests and the
resolver tests that compose the real dnsmasq manifest.
This commit is contained in:
2026-09-27 01:33:20 +02:00
parent f21a84c510
commit b36f822cb6
9 changed files with 453 additions and 387 deletions
+23 -2
View File
@@ -161,6 +161,23 @@ func (g *Generator) Nodes() []Node { return g.nodes }
// Graph is the peer list per node, for showing.
func (g *Generator) Graph() Graph { return g.graph }
// hostsTemplate is the `/etc/hosts` the network module asks the mesh to write — every machine's
// mesh name at its private address, so a service binding the name it was given stays reachable from
// everywhere else. It is a roster template like any module's (catalogue.RosterFile): the mesh owns
// the data, this owns the format.
//
// - The loopback floor stays, or things with nothing to do with the mesh break.
// - `127.0.1.1 <node>` only when there is a node, the ordinary Debian self-name line.
// - A machine's own line is marked, and its mesh name resolves to its mesh address, not loopback.
// - `.Names` is every name the mesh serves (issue 111), so a container reaching a routed name
// finds the machine serving it; machines with no address yet are already left out of the set.
const hostsTemplate = "# Generated by the mesh. Do not edit — this file is replaced whenever a machine\n" +
"# joins or leaves, and an edit would survive until then and vanish.\n\n" +
"127.0.0.1\tlocalhost\n" +
"::1\t\tlocalhost ip6-localhost ip6-loopback\n" +
"{{if .Node}}127.0.1.1\t{{.Node}}\n{{end}}\n" +
"{{range .Names}}{{.Address}}\t{{.FQDN}}\t{{.Name}}{{if eq .Name $.Node}}\t# this machine{{end}}\n{{end}}"
// Manifest is the module the mesh provides for itself.
//
// It ships with the control plane rather than coming from a repository, because the thing that
@@ -175,8 +192,12 @@ func Manifest() map[string]any {
// Being on the private network is what gives a machine a name, so the module that puts it
// there is what writes them. Asked for rather than generated by a module of its own: the
// mesh knows which machines exist and where; writing that into a hosts file is not a thing
// that needs a module to run nowhere.
"facts": map[string]string{"node-names": "/etc/hosts"},
// that needs a module to run nowhere. The format is a template like any other roster fact —
// the mesh's own module owns the `/etc/hosts` layout the way dnsmasq owns its zones, and the
// control plane holds no formatter (see catalogue.RosterFile).
"facts": map[string]any{
"node-names": map[string]any{"path": "/etc/hosts", "template": hostsTemplate},
},
"claims": []map[string]any{{"name": TheNetwork, "scope": "node"}},
}
}
+52
View File
@@ -0,0 +1,52 @@
package overlay
import (
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// The network module's `/etc/hosts` is a roster template like any module's (novox/hq: the graph is
// the control plane's, the format is the module's). These pin the format that used to be a Go
// formatter in the control plane, so the file a machine gets does not change with the mechanism:
// the loopback floor, the Debian self-name line, the machine's own line marked and at its mesh
// address, one line per machine, every served name (issue 111).
func hostsFor(t *testing.T, node string, every map[string]string) string {
t.Helper()
m := catalogue.Manifest{Module: "net", Facts: map[string]catalogue.RosterFile{
"node-names": {Path: "/etc/hosts", Template: hostsTemplate},
}}
given, err := catalogue.FactsInto(m, catalogue.Resolution{Node: node}, every, every, "")
if err != nil {
t.Fatal(err)
}
return given[0]["content"].(string)
}
func TestTheHostsFileIsThisExactly(t *testing.T) {
got := hostsFor(t, "homer", map[string]string{"homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2"})
want := "# Generated by the mesh. Do not edit — this file is replaced whenever a machine\n" +
"# joins or leaves, and an edit would survive until then and vanish.\n\n" +
"127.0.0.1\tlocalhost\n" +
"::1\t\tlocalhost ip6-localhost ip6-loopback\n" +
"127.0.1.1\thomer\n\n" +
"10.42.0.1\thomer.internal\thomer\t# this machine\n" +
"10.42.0.2\tmarge.internal\tmarge\n"
if got != want {
t.Fatalf("the hosts file changed with the mechanism:\ngot:\n%q\nwant:\n%q", got, want)
}
}
// With no node named there is no `127.0.1.1` self-line — but the blank line before the machines
// stays, exactly as the old formatter wrote it unconditionally.
func TestTheHostsFileWithoutASelfNameKeepsItsShape(t *testing.T) {
got := hostsFor(t, "", map[string]string{"homer.internal": "10.42.0.1"})
want := "# Generated by the mesh. Do not edit — this file is replaced whenever a machine\n" +
"# joins or leaves, and an edit would survive until then and vanish.\n\n" +
"127.0.0.1\tlocalhost\n" +
"::1\t\tlocalhost ip6-localhost ip6-loopback\n\n" +
"10.42.0.1\thomer.internal\thomer\n"
if got != want {
t.Fatalf("the hosts file without a self-name changed shape:\ngot:\n%q\nwant:\n%q", got, want)
}
}