The controller writes no /etc/hosts (hq ADR 0199)
/etc/hosts is the file of the node-hosts-file seat's holder; the controller writes into no file another seat's holder owns, and asks that holder if it ever needs a line there. The private network's module stops asking for the node-names fact; every machine already asks the mesh's one resolver for these names, and the host gives the region back at the next push.
This commit is contained in:
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
// **The network writes no names** (novox/hq ADR 0199): /etc/hosts is the hosts seat holder's
|
||||||
// network is what gives a machine a name, so the provider asks for the node-names fact and
|
// file, and the mesh's resolver answers the machines' names. No fact of the network's module
|
||||||
// there is nothing else to bring in. A module that ran nothing used to be here.
|
// may name that file.
|
||||||
for _, m := range got.Modules {
|
for _, m := range got.Modules {
|
||||||
if m.Module == overlay.Name && m.Facts["node-names"].Path == "" {
|
for name, f := range m.Facts {
|
||||||
t.Fatalf("the network's provider does not ask for the names: %+v", 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,19 +31,18 @@ const Requirement = "private-network"
|
|||||||
|
|
||||||
// Name is the module that answers it with WireGuard.
|
// 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
|
// **It writes no names.** A machine's mesh names are answered by the mesh's one resolver (novox/hq
|
||||||
// and ran nothing, which is not a module. Being on the private network is what gives a machine a
|
// ADR 0194), and /etc/hosts is the file of one module, the holder of `node-hosts-file` (ADR 0199): the
|
||||||
// name, so this module asks for the `node-names` fact and the mesh writes the file. The
|
// controller writes into no file another seat's holder owns. If the mesh ever needs a line there, it
|
||||||
// name-resolution provision went the same way: names are facts the mesh computes, not something a
|
// asks that holder to register it. This module asked for a `node-names` fact written into /etc/hosts
|
||||||
// module that runs nowhere can provide.
|
// until 2026-10-05; the host gives that region back at the first push without it.
|
||||||
const Name = "mesh-wireguard"
|
const Name = "mesh-wireguard"
|
||||||
|
|
||||||
// Addressing is the mesh handing out addresses on the private network itself.
|
// 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
|
// The mesh's resolver answers names from it, which is why it requires this rather than a private
|
||||||
// general. A different VPN that hands out its own addresses would come with its own names — the
|
// network in general. A different VPN that hands out its own addresses would come with its own
|
||||||
// mesh has nothing to write about a machine whose address it did not choose. Saying so here is
|
// names — the mesh has nothing to answer about a machine whose address it did not choose.
|
||||||
// what keeps a machine from being given a hosts file full of addresses that mean nothing.
|
|
||||||
const Addressing = "mesh-addressing"
|
const Addressing = "mesh-addressing"
|
||||||
|
|
||||||
// TheNetwork is what a machine can only have one of.
|
// 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
|
// 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
|
// 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
|
// (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
|
registry string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,20 +161,6 @@ func (g *Generator) Nodes() []Node { return g.nodes }
|
|||||||
// Graph is the peer list per node, for showing.
|
// Graph is the peer list per node, for showing.
|
||||||
func (g *Generator) Graph() Graph { return g.graph }
|
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.
|
// 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
|
// 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",
|
"version": "1",
|
||||||
"computed": Name,
|
"computed": Name,
|
||||||
"provides": []string{Requirement, Addressing},
|
"provides": []string{Requirement, Addressing},
|
||||||
// Being on the private network is what gives a machine a name, so the module that puts it
|
"claims": []map[string]any{{"name": TheNetwork, "scope": "node"}},
|
||||||
// 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"}},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,20 +20,6 @@ const SuffixVar = "MESH_INTERNAL_SUFFIX"
|
|||||||
// rather than reaching a stranger's machine.
|
// rather than reaching a stranger's machine.
|
||||||
const DefaultSuffix = "internal"
|
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.
|
// Suffix is what internal names end in.
|
||||||
func Suffix() string {
|
func Suffix() string {
|
||||||
if v := strings.TrimSpace(os.Getenv(SuffixVar)); v != "" {
|
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.
|
// InternalName is a node's name inside the mesh.
|
||||||
func InternalName(node string) string { return node + "." + Suffix() }
|
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
|
// **What remains of a larger file.** The rest wrote /etc/hosts, which the controller no longer
|
||||||
// (catalogue.FactsInto), computed where the graph lives instead of by a module that ran nothing.
|
// 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
|
||||||
// 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.
|
// writing the suffix differently would be a name nothing answers to.
|
||||||
|
|||||||
Reference in New Issue
Block a user