diff --git a/internal/catalogue/hosts_region_test.go b/internal/catalogue/hosts_region_test.go deleted file mode 100644 index 16e817d..0000000 --- a/internal/catalogue/hosts_region_test.go +++ /dev/null @@ -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 -} diff --git a/internal/catalogue/provided_test.go b/internal/catalogue/provided_test.go index 029b19d..676321f 100644 --- a/internal/catalogue/provided_test.go +++ b/internal/catalogue/provided_test.go @@ -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) + } } } } diff --git a/internal/overlay/generator.go b/internal/overlay/generator.go index c2d432d..712578d 100644 --- a/internal/overlay/generator.go +++ b/internal/overlay/generator.go @@ -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"}}, } } diff --git a/internal/overlay/names.go b/internal/overlay/names.go index 1c8f378..870b81d 100644 --- a/internal/overlay/names.go +++ b/internal/overlay/names.go @@ -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.