Merge pull request 'The controller writes no /etc/hosts (hq ADR 0199)' (#60) from fix/the-controller-writes-no-hosts-file into main

This commit was merged in pull request #60.
This commit is contained in:
2026-10-05 19:23:41 +00:00
4 changed files with 20 additions and 163 deletions
-106
View File
@@ -1,106 +0,0 @@
package catalogue_test
import (
"reflect"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/overlay"
)
// novox/hq issue 128, held where the host will see it: the shipped networking module's names,
// through the whole composition, and not only through FactsInto.
//
// Composition prefixes a fact's id with the module that asked for it and passes everything else
// through; a step that dropped `into` on the way would send the region as a whole file, and the
// host would write the machine's hosts file over again with every unit test above still green.
// onTheNetwork stands in for the overlay's generator: the node is part of the private network,
// and what the generator writes is not what is under test here.
type onTheNetwork struct{}
func (onTheNetwork) Resources(string) ([]map[string]any, bool, error) {
return []map[string]any{{"id": "overlay-config", "type": "file",
"path": "/etc/wireguard/mesh0.conf", "mode": "0600", "content": "[Interface]\n"}}, true, nil
}
func TestTheHostsRegionArrivesAsTheHostWillReadIt(t *testing.T) {
shelf := provided(t)
// A resolver restarting on the names another module put on the machine, and one resource it
// only runs at start — neither of which composition has any business changing.
resolver, err := catalogue.ParseManifest([]byte(`{
"module": "resolver", "version": "1", "requires": ["mesh-addressing"],
"resources": [
{"id": "seed", "type": "file", "path": "/etc/resolver/seed", "mode": "0644",
"content": "seed\n", "at": "start"},
{"id": "daemon", "type": "service", "unit": "resolver.service", "state": "running",
"restart-on": ["seed", "mesh-wireguard.fact-node-names"]}
]}`))
if err != nil {
t.Fatal(err)
}
shelf[resolver.Module] = resolver
got, err := catalogue.Resolve(shelf, []string{overlay.Domain, "resolver"},
catalogue.Node{Name: "homer", At: "homer.internal"}, catalogue.World{})
if err != nil {
t.Fatal(err)
}
names := map[string]string{"homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2"}
out, err := got.Declaration(catalogue.Rendering{
Names: names, Machines: names, Suffix: "internal",
Generators: map[string]catalogue.Generator{overlay.Name: onTheNetwork{}},
})
if err != nil {
t.Fatal(err)
}
ids := map[string]map[string]any{}
for _, r := range out {
ids[r["id"].(string)] = r
}
hosts := ids[overlay.Name+".fact-node-names"]
if hosts == nil {
t.Fatalf("no names reached the machine; the declaration has %v", keys(ids))
}
if hosts["path"] != "/etc/hosts" || hosts["into"] != "block" {
t.Fatalf("the hosts file is not written into as a region: %v", hosts)
}
content := hosts["content"].(string)
if !strings.Contains(content, "10.42.0.1\thomer.internal\thomer\t# this machine\n") {
t.Errorf("the region does not name the machine:\n%s", content)
}
for _, floor := range []string{"Generated by the mesh", "localhost", "127.0.1.1"} {
if strings.Contains(content, floor) {
t.Errorf("the region carries %q, which is the machine's:\n%s", floor, content)
}
}
// The resolver's reference to it still names a resource the host will be sent.
daemon := ids["resolver.daemon"]
if daemon == nil {
t.Fatalf("the resolver's service was not composed: %v", keys(ids))
}
for _, named := range daemon["restart-on"].([]any) {
if ids[named.(string)] == nil {
t.Errorf("the resolver restarts on %v, which is nothing the host is sent", named)
}
}
if !reflect.DeepEqual(daemon["restart-on"], []any{"resolver.seed", overlay.Name + ".fact-node-names"}) {
t.Errorf("restart-on is %v", daemon["restart-on"])
}
// And a resource's own `at` passes through as the manifest wrote it.
if seed := ids["resolver.seed"]; seed == nil || seed["at"] != "start" {
t.Errorf("a resource's at did not survive composition: %v", seed)
}
}
func keys(m map[string]map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
+7 -5
View File
@@ -50,12 +50,14 @@ func TestTheShippedNetworkingModulesResolveOnTheirOwn(t *testing.T) {
}
}
// **The names come WITH the network now, not from a third module.** Being on the private
// network is what gives a machine a name, so the provider asks for the node-names fact and
// there is nothing else to bring in. A module that ran nothing used to be here.
// **The network writes no names** (novox/hq ADR 0199): /etc/hosts is the hosts seat holder's
// file, and the mesh's resolver answers the machines' names. No fact of the network's module
// may name that file.
for _, m := range got.Modules {
if m.Module == overlay.Name && m.Facts["node-names"].Path == "" {
t.Fatalf("the network's provider does not ask for the names: %+v", m.Facts)
for name, f := range m.Facts {
if m.Module == overlay.Name && f.Path == "/etc/hosts" {
t.Fatalf("the network's provider still writes /etc/hosts, as its %q fact", name)
}
}
}
}
+11 -35
View File
@@ -31,19 +31,18 @@ const Requirement = "private-network"
// Name is the module that answers it with WireGuard.
//
// **The names went with it.** A mesh-names module used to sit beside this — it wrote /etc/hosts
// and ran nothing, which is not a module. Being on the private network is what gives a machine a
// name, so this module asks for the `node-names` fact and the mesh writes the file. The
// name-resolution provision went the same way: names are facts the mesh computes, not something a
// module that runs nowhere can provide.
// **It writes no names.** A machine's mesh names are answered by the mesh's one resolver (novox/hq
// ADR 0194), and /etc/hosts is the file of one module, the holder of `node-hosts-file` (ADR 0199): the
// controller writes into no file another seat's holder owns. If the mesh ever needs a line there, it
// asks that holder to register it. This module asked for a `node-names` fact written into /etc/hosts
// until 2026-10-05; the host gives that region back at the first push without it.
const Name = "mesh-wireguard"
// Addressing is the mesh handing out addresses on the private network itself.
//
// Names are computed from it, which is why they require this rather than a private network in
// general. A different VPN that hands out its own addresses would come with its own names — the
// mesh has nothing to write about a machine whose address it did not choose. Saying so here is
// what keeps a machine from being given a hosts file full of addresses that mean nothing.
// The mesh's resolver answers names from it, which is why it requires this rather than a private
// network in general. A different VPN that hands out its own addresses would come with its own
// names — the mesh has nothing to answer about a machine whose address it did not choose.
const Addressing = "mesh-addressing"
// TheNetwork is what a machine can only have one of.
@@ -82,7 +81,8 @@ type Generator struct {
// registry is the mesh's artifact store as the network reaches it (host:port), or empty when
// the mesh has none. Being on the network is what grants a machine the right to pull from it
// (novox/hq ADR 0082), so the module that puts a machine on the network is what writes the
// runtime's trust — the same reasoning that has it write /etc/hosts.
// runtime's trust. (That is still a write into another module's file, the container runtime's;
// novox/hq issue 190 has it handed to that module as a value.)
registry string
}
@@ -161,20 +161,6 @@ 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 mesh's region of `/etc/hosts` — every machine's mesh name at its private
// address, written into a marked region and merged (RosterFile.Shared → `into: block`), so the rest
// of the file (localhost, the machine's own name, other tools' blocks) is kept byte for byte
// (novox/hq issue 128). It is a roster template like any module's: the mesh owns the data, this owns
// the format, and the control plane holds no formatter.
//
// - No floor: no header, no localhost, no `127.0.1.1` — those are the machine's, above the region.
// - A machine's own line is marked, and its mesh name resolves to its mesh address, not loopback.
// - `.Names` is the machines (novox/hq ADR 0191): a route's internal name is under its node's
// internal domain and the resolver answers it by wildcard, and a public name is public DNS's.
// Machines with no address yet are already left out of the set.
const hostsTemplate = "# The mesh's names. This region is replaced whenever a machine joins or leaves.\n" +
"{{range .Names}}{{.Address}}\t{{.FQDN}}{{if ne .Name .FQDN}}\t{{.Name}}{{end}}{{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
@@ -186,17 +172,7 @@ func Manifest() map[string]any {
"version": "1",
"computed": Name,
"provides": []string{Requirement, Addressing},
// 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. 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). `shared`: the mesh owns only
// its region of the file and keeps the rest (novox/hq issue 128).
"facts": map[string]any{
"node-names": map[string]any{"path": "/etc/hosts", "template": hostsTemplate, "shared": true},
},
"claims": []map[string]any{{"name": TheNetwork, "scope": "node"}},
"claims": []map[string]any{{"name": TheNetwork, "scope": "node"}},
}
}
+2 -17
View File
@@ -20,20 +20,6 @@ const SuffixVar = "MESH_INTERNAL_SUFFIX"
// rather than reaching a stranger's machine.
const DefaultSuffix = "internal"
// HostsPath is where the names go.
//
// This is not the `/etc/hosts` floor the design removes. That floor existed because a node had to
// reach the mesh's database before its own DNS worked — a fallback for a circularity, and the
// circularity is gone. This is the mechanism itself: the complete set of names in this mesh
// (novox/hq ADR 0011). Written as a marked region *into* the file rather than as the file: the
// rest of it — `localhost`, the machine's own name, other tools' blocks — is the machine's, and
// writing it whole replaced all of that (novox/hq issue 128).
//
// A file rather than a resolver daemon, deliberately, for now: it works on every Linux, needs no
// package, and has no failure mode of its own. A daemon becomes necessary when names are wanted
// that are not one-per-node — service names, wildcards — and that is not yet true.
const HostsPath = "/etc/hosts"
// Suffix is what internal names end in.
func Suffix() string {
if v := strings.TrimSpace(os.Getenv(SuffixVar)); v != "" {
@@ -48,7 +34,6 @@ var nodeName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
// InternalName is a node's name inside the mesh.
func InternalName(node string) string { return node + "." + Suffix() }
// **What remains of a larger file.** The rest wrote /etc/hosts — that is the `node-names` fact now
// (catalogue.FactsInto), computed where the graph lives instead of by a module that ran nothing.
// The naming stays here, because several things compose a node's internal name and one of them
// **What remains of a larger file.** The rest wrote /etc/hosts, which the controller no longer
// writes at all (novox/hq ADR 0199): the mesh's resolver answers these names. The naming stays here, because several things compose a node's internal name and one of them
// writing the suffix differently would be a name nothing answers to.