From b36f822cb6741beed094f86a02b492b10e33bcd2 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 01:33:20 +0200 Subject: [PATCH 1/2] Facts carry the format as a template, so the control plane holds none (ADR 0120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/catalogue/facts.go | 184 ----------------- internal/catalogue/facts_test.go | 188 ----------------- internal/catalogue/manifest.go | 27 ++- internal/catalogue/provided_test.go | 2 +- internal/catalogue/resolver_manifests_test.go | 2 +- internal/catalogue/roster.go | 165 +++++++++++++++ internal/catalogue/roster_test.go | 195 ++++++++++++++++++ internal/overlay/generator.go | 25 ++- internal/overlay/hosts_fact_test.go | 52 +++++ 9 files changed, 453 insertions(+), 387 deletions(-) delete mode 100644 internal/catalogue/facts.go delete mode 100644 internal/catalogue/facts_test.go create mode 100644 internal/catalogue/roster.go create mode 100644 internal/catalogue/roster_test.go create mode 100644 internal/overlay/hosts_fact_test.go diff --git a/internal/catalogue/facts.go b/internal/catalogue/facts.go deleted file mode 100644 index 2fdd510..0000000 --- a/internal/catalogue/facts.go +++ /dev/null @@ -1,184 +0,0 @@ -package catalogue - -import ( - "fmt" - "sort" - "strings" -) - -// What only the mesh knows, written where a module asks for it. -// -// **The graph is the control plane's; using it is the module's.** The mesh knows which machines -// exist, what they are called, and where they are. Turning that into a name that resolves is -// somebody's software, and which software is a choice the mesh should not be making. -// -// This replaced three modules — names, a resolver's data, and the private network's own -// configuration — that existed only because computed output needed somewhere to live. They ran no -// software and could not be swapped for anything, which is the test of whether something is a -// module at all (novox/hq ADR 0040). - -const ( - // FactNodeNames is every machine's name and address, as a hosts file. - // - // Exact names only: `homer` and `homer.internal` resolve to homer. Anything *under* a machine - // is a wildcard, which a hosts file cannot express — that is FactNodeZones. - FactNodeNames = "node-names" - - // FactNodeZones is every machine as a wildcard: `*.homer.internal` is homer. - // - // Written in the form a resolver reads. A machine's own name and everything under it are one - // fact — if homer is at an address, so is anything homer serves. - FactNodeZones = "node-zones" -) - -// facts is every fact the mesh computes, and what writes it. -// -// **A closed list.** A module asking for a fact the mesh does not have is asking for a file nobody -// will write, and finding that out on a machine — as a daemon that starts, reads nothing, and -// answers no queries — is worse than being told where the manifest is. -// A fact is written from the names it is about. `every` is every name the mesh serves — machines -// and the names it was told to route; `machines` is only the machines. A fact takes the set it is -// true of, and the two must not be confused (novox/hq 04-ISSUES/111). -var facts = map[string]func(r Resolution, every, machines map[string]string, suffix string) string{ - FactNodeNames: func(r Resolution, every, _ map[string]string, suffix string) string { - return nodeNames(r, every, suffix) - }, - FactNodeZones: func(r Resolution, _, machines map[string]string, suffix string) string { - return nodeZones(r, machines, suffix) - }, -} - -// FactsInto renders the facts a module asked for, as files it will be given. -// -// The module owns everything after the file exists: loading it, restarting on it, what a resolver -// does with it. This only puts it there. -func FactsInto(m Manifest, r Resolution, addresses, machines map[string]string, suffix string) ([]map[string]any, error) { - if len(m.Facts) == 0 { - return nil, nil - } - names := make([]string, 0, len(m.Facts)) - for name := range m.Facts { - names = append(names, name) - } - sort.Strings(names) - - out := make([]map[string]any, 0, len(names)) - for _, name := range names { - write, known := facts[name] - if !known { - return nil, fmt.Errorf( - "%s asks the mesh for %q, which it does not compute. It has %s", - m.Module, name, spokenFacts()) - } - path := m.Facts[name] - if !strings.HasPrefix(path, "/") { - return nil, fmt.Errorf( - "%s asks for %q at %q, which is not an absolute path", m.Module, name, path) - } - out = append(out, map[string]any{ - "id": "fact-" + name, "type": "file", "path": path, "mode": "0644", - "content": write(r, addresses, machines, suffix), - }) - } - return out, nil -} - -// spokenFacts lists them, so a refusal says what would have worked. -func spokenFacts() string { - names := make([]string, 0, len(facts)) - for name := range facts { - names = append(names, name) - } - sort.Strings(names) - return strings.Join(names, ", ") -} - -// nodeNames is every machine's name and address, as a hosts file. -// -// **A machine with no address is left out.** The mesh has a record for it — somebody added it — -// and does not yet know where it is, which is the ordinary state between adding a machine and it -// joining. Writing the name anyway would give a name that resolves to nothing, and a connection to -// that hangs; leaving it out fails at once and says the name is unknown. -func nodeNames(r Resolution, addresses map[string]string, suffix string) string { - var b strings.Builder - b.WriteString("# Generated by the mesh. Do not edit — this file is replaced whenever a machine\n") - b.WriteString("# joins or leaves, and an edit would survive until then and vanish.\n\n") - // The floor every Linux expects, and which removing would break things that have nothing to do - // with the mesh. - b.WriteString("127.0.0.1\tlocalhost\n") - b.WriteString("::1\t\tlocalhost ip6-localhost ip6-loopback\n") - if r.Node != "" { - fmt.Fprintf(&b, "127.0.1.1\t%s\n", r.Node) - } - b.WriteString("\n") - for _, name := range sortedNames(addresses) { - at := addresses[name] - internal, bare := meshName(name, suffix) - // Its mesh name resolves to its address on the private network rather than to loopback, - // so a service binding the name it was given stays reachable from everywhere else. - fmt.Fprintf(&b, "%s\t%s\t%s", at, internal, bare) - if bare == r.Node { - b.WriteString("\t# this machine") - } - b.WriteString("\n") - } - return b.String() -} - -// nodeZones is every machine as a wildcard, in the form a resolver reads. -// -// `*.homer.internal` is homer, which is the whole rule: if homer is at an address, so is anything -// homer serves. A module wanting this runs the resolver; the mesh only says what is true. -// -// **And the suffix itself, as a local domain.** A resolver that forwards what it cannot answer -// would otherwise send a mesh name it does not know — a machine that left, a typo — to a public -// resolver, which is a leak of the mesh's names for no answer. `local=` keeps everything under the -// suffix here: answered from the lines below or refused. Written in this file rather than in the -// resolver's own configuration because the suffix is the mesh's choice (the operator may have -// picked another) and this file is the one place the mesh writes what it chose. -func nodeZones(_ Resolution, addresses map[string]string, suffix string) string { - var b strings.Builder - b.WriteString("# Generated by the mesh. Do not edit — this file is replaced whenever a machine\n") - b.WriteString("# joins or leaves, and an edit would survive until then and vanish.\n\n") - fmt.Fprintf(&b, "local=/%s/\n", strings.TrimPrefix(suffixOr(suffix), ".")) - for _, name := range sortedNames(addresses) { - internal, _ := meshName(name, suffix) - fmt.Fprintf(&b, "address=/%s/%s\n", internal, addresses[name]) - } - return b.String() -} - -// meshName is a machine's internal name and its bare one, from either. The control plane keys -// the names it hands a resolution by the internal name (`homer.internal`), the same map a -// container gets as its hosts; a caller that keys by the bare name gets the same answer. The -// suffix is the one the control plane composed those names with, handed down rather than written -// here a second time — the alternative was `homer.internal.internal` on every machine. -func meshName(name, suffix string) (internal, bare string) { - dotted := "." + strings.TrimPrefix(suffixOr(suffix), ".") - if strings.HasSuffix(name, dotted) { - return name, strings.TrimSuffix(name, dotted) - } - return name + dotted, name -} - -// suffixOr is the suffix given, or the one the mesh composes names with when none was handed down. -// The one place the default is written in this file, so a fact and a name cannot disagree about it. -func suffixOr(suffix string) string { - if suffix == "" { - return "internal" - } - return suffix -} - -func sortedNames(addresses map[string]string) []string { - out := make([]string, 0, len(addresses)) - for name, at := range addresses { - // See nodeNames: a machine the mesh cannot place is left out rather than named at nothing. - if at == "" { - continue - } - out = append(out, name) - } - sort.Strings(out) - return out -} diff --git a/internal/catalogue/facts_test.go b/internal/catalogue/facts_test.go deleted file mode 100644 index 1f30b69..0000000 --- a/internal/catalogue/facts_test.go +++ /dev/null @@ -1,188 +0,0 @@ -package catalogue - -import ( - "strings" - "testing" -) - -// Keyed by the internal name, as the control plane hands them (issue 079). -var threeMachines = map[string]string{"homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2", "bart.internal": ""} - -// **`*.homer.internal` is homer. That is the whole rule.** And the suffix itself is local: a -// resolver that forwards what it cannot answer must not send a mesh name it does not know — a -// machine that left, a typo — to a public resolver (hal dnsmasq-app conversion, novox/hq -// 08-connectivity). -func TestEveryMachineIsAWildcardUnderItsOwnName(t *testing.T) { - out := nodeZones(Resolution{Node: "homer"}, threeMachines, "") - for _, want := range []string{ - "local=/internal/", - "address=/homer.internal/10.42.0.1", - "address=/marge.internal/10.42.0.2", - } { - if !strings.Contains(out, want) { - t.Fatalf("missing %q:\n%s", want, out) - } - } -} - -// A machine the mesh has a record for and cannot place is left out of both. -// -// **Not an oversight — the alternative is worse.** A name written with no address resolves to -// nothing, and a connection to that hangs. Leaving it out fails at once and says the name is -// unknown, which is a thing somebody can act on. -func TestAMachineWithNoAddressIsNotNamed(t *testing.T) { - for _, out := range []string{ - nodeNames(Resolution{Node: "homer"}, threeMachines, ""), - nodeZones(Resolution{Node: "homer"}, threeMachines, ""), - } { - if strings.Contains(out, "bart") { - t.Fatalf("a machine with no address was named, so its name resolves to nothing:\n%s", out) - } - } -} - -// A machine's own mesh name points at its address on the private network, not at loopback — or a -// service binding the name it was given is unreachable from everywhere else. -func TestAMachinesOwnNameIsItsMeshAddress(t *testing.T) { - out := nodeNames(Resolution{Node: "homer"}, threeMachines, "") - var line string - for _, l := range strings.Split(out, "\n") { - if strings.Contains(l, "homer.internal") { - line = l - } - } - if !strings.HasPrefix(line, "10.42.0.1") { - t.Fatalf("a machine's own mesh name is not its mesh address: %q", line) - } - // And the loopback floor is still there, or things with nothing to do with the mesh break. - if !strings.Contains(out, "127.0.0.1\tlocalhost") { - t.Fatalf("the loopback floor was removed:\n%s", out) - } -} - -// A module says where it wants a fact, and is given a file. -func TestAModuleIsGivenTheFactsItAskedFor(t *testing.T) { - m := Manifest{Module: "dnsmasq", Facts: map[string]string{FactNodeZones: "/etc/mesh/zones.conf"}} - given, err := FactsInto(m, Resolution{Node: "homer"}, threeMachines, threeMachines, "") - if err != nil { - t.Fatal(err) - } - if len(given) != 1 { - t.Fatalf("expected one file, got %d", len(given)) - } - if given[0]["path"] != "/etc/mesh/zones.conf" || given[0]["type"] != "file" { - t.Fatalf("not written where it was asked for: %v", given[0]) - } - if !strings.Contains(given[0]["content"].(string), "homer.internal") { - t.Fatalf("the file does not hold the fact: %v", given[0]["content"]) - } -} - -// **Asking for a fact the mesh does not have is refused here, not on a machine.** A daemon that -// starts, reads a file nobody wrote, and answers no queries is a much worse way to find out. -func TestAskingForAFactTheMeshDoesNotHaveIsRefused(t *testing.T) { - m := Manifest{Module: "dnsmasq", Facts: map[string]string{"the-weather": "/etc/weather"}} - _, err := FactsInto(m, Resolution{}, nil, nil, "") - if err == nil { - t.Fatal("a module asked for something nobody computes and was given nothing, silently") - } - for _, known := range []string{FactNodeNames, FactNodeZones} { - if !strings.Contains(err.Error(), known) { - t.Fatalf("the refusal does not say what would have worked: %v", err) - } - } -} - -// And a relative path is refused, or a module decides where the mesh writes on a machine. -func TestAFactMustBeAskedForAtAnAbsolutePath(t *testing.T) { - m := Manifest{Module: "dnsmasq", Facts: map[string]string{FactNodeNames: "etc/hosts"}} - if _, err := FactsInto(m, Resolution{}, nil, nil, ""); err == nil { - t.Fatal("a relative path was accepted") - } -} - -// **The names the control plane hands a resolution are already internal names** — `homer.internal`, -// the same map every container gets as its hosts. Appending the suffix again wrote -// `homer.internal.internal` into every hosts file and every resolver's zones, and the large mesh -// bed's name test was the first to read it back. Either key gives the same files. -func TestNamesKeyedByInternalNameAreNotSuffixedTwice(t *testing.T) { - internal := map[string]string{"homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2"} - bare := map[string]string{"homer": "10.42.0.1", "marge": "10.42.0.2"} - if a, b := nodeZones(Resolution{Node: "homer"}, internal, ""), nodeZones(Resolution{Node: "homer"}, bare, ""); a != b { - t.Fatalf("the zones differ by how the names were keyed:\n%s\n---\n%s", a, b) - } - if a, b := nodeNames(Resolution{Node: "homer"}, internal, ""), nodeNames(Resolution{Node: "homer"}, bare, ""); a != b { - t.Fatalf("the hosts differ by how the names were keyed:\n%s\n---\n%s", a, b) - } - zones := nodeZones(Resolution{Node: "homer"}, internal, "") - if strings.Contains(zones, "internal.internal") || !strings.Contains(zones, "address=/homer.internal/10.42.0.1") { - t.Fatalf("the zones carry a doubled suffix or miss the name:\n%s", zones) - } - hosts := nodeNames(Resolution{Node: "homer"}, internal, "") - if !strings.Contains(hosts, "10.42.0.1\thomer.internal\thomer\t# this machine") { - t.Fatalf("the hosts line for the machine itself is not name, bare name and the mark:\n%s", hosts) - } -} - -// The suffix the control plane composed the names with is the one the facts write — an operator -// who chose another does not get `.internal` appended to it. -func TestTheFactsWriteTheSuffixTheNamesWereComposedWith(t *testing.T) { - names := map[string]string{"homer.lan": "10.42.0.1"} - zones := nodeZones(Resolution{Node: "homer"}, names, "lan") - if !strings.Contains(zones, "address=/homer.lan/10.42.0.1") || strings.Contains(zones, "internal") { - t.Fatalf("the zones do not carry the operator's suffix as given:\n%s", zones) - } - if !strings.Contains(zones, "local=/lan/") { - t.Fatalf("the local domain is not the operator's suffix, so its names would leak upstream:\n%s", zones) - } - hosts := nodeNames(Resolution{Node: "homer"}, names, "lan") - if !strings.Contains(hosts, "10.42.0.1\thomer.lan\thomer\t# this machine") { - t.Fatalf("the hosts line does not carry the operator's suffix as given:\n%s", hosts) - } -} - -// novox/hq 04-ISSUES/111: the map the control plane hands a resolution holds every name the mesh -// serves — the machines, and the names it was told to route to whichever machine serves them. A -// container's hosts wants all of it. A resolver's zones want only the machines: told the mesh's -// suffix is its own, it answers authoritatively for everything under it and forwards nothing, so a -// routed name written there with the suffix appended is a name nobody will ever ask for, standing -// beside the machines and looking as real. -func TestTheResolverIsToldTheMachinesAndNotTheNamesTheMeshMerelyServes(t *testing.T) { - machines := map[string]string{"homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2"} - every := map[string]string{ - "homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2", - "drive.example.test": "10.42.0.1", "git.example.test": "10.42.0.2", - } - m := Manifest{Module: "resolver", Facts: map[string]string{ - FactNodeZones: "/etc/zones.conf", FactNodeNames: "/etc/hosts", - }} - given, err := FactsInto(m, Resolution{Node: "homer"}, every, machines, "") - if err != nil { - t.Fatal(err) - } - by := map[string]string{} - for _, f := range given { - by[f["path"].(string)] = f["content"].(string) - } - - zones := by["/etc/zones.conf"] - for _, machine := range []string{"address=/homer.internal/10.42.0.1", "address=/marge.internal/10.42.0.2"} { - if !strings.Contains(zones, machine) { - t.Fatalf("the resolver was not told %q:\n%s", machine, zones) - } - } - for _, served := range []string{"drive.example.test", "git.example.test"} { - if strings.Contains(zones, served) { - t.Fatalf("the resolver was told %q, a name the mesh serves rather than a machine:\n%s", served, zones) - } - } - - // And the hosts file is the other way about: every name, so a container reaching a routed name - // finds the machine serving it. - hosts := by["/etc/hosts"] - for _, name := range []string{"homer.internal", "drive.example.test", "git.example.test"} { - if !strings.Contains(hosts, name) { - t.Fatalf("a container would not resolve %q from its hosts:\n%s", name, hosts) - } - } -} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index e73da8b..5702437 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -366,24 +366,29 @@ type Manifest struct { // firewall does. Ignored on a converged node, whose derived filter already closes them. Guards []int `json:"guards,omitempty"` - // Facts are things only the mesh knows, written where this module asks for them. + // Facts are things only the mesh knows, written where this module asks for them — in the + // module's own format. // - // **The graph is the control plane's; how a machine uses it is the module's.** The mesh knows - // which machines exist, what they are called and where they are. Making a name resolve, or a - // peer reachable, is somebody's software — dnsmasq, a resolver, a VPN — and the mesh has no - // business shipping one, choosing which, or knowing its configuration language. + // **The graph is the control plane's; the format is the module's.** The mesh knows which + // machines exist, what they are called and where they are. Turning that into a name that + // resolves, a peer that is reachable, a host a client trusts, is somebody's software — dnsmasq, + // a resolver, a VPN, ssh — in its own configuration language, and the mesh has no business + // knowing it. So a module gives a path and a template; the mesh renders the roster through it + // and owns nothing of what the file says. // - // So a module says *put the node names here* and owns everything after that. The same shape as - // `filtering`, generalised: a fact, and a path. + // This used to be a closed list of fact names, each formatted in Go in the control plane, so a + // new consumer meant a new formatter here in the consumer's language. Now the data is the mesh's + // and the format is the module's: the two built-in cases — the network module's `/etc/hosts` and + // dnsmasq's zones — render through the same template path any module uses, and no format lives + // in the control plane at all. See RosterFile for what a template sees. // // It replaces three modules that existed only because computed output needed somewhere to // live — they ran no software, could not be swapped for anything, and appeared in the graph as // modules while being a data channel wearing a costume. // - // Keyed by fact name; the names are a closed list, because a module asking for one the mesh - // does not compute is asking for something nobody will write, and finding that out on a machine - // is worse than being told here. - Facts map[string]string `json:"facts,omitempty"` + // Keyed by a name the module chooses, which is the rendered file's id (`fact-`) — what a + // `restart-on` names to restart when the roster changes. + Facts map[string]RosterFile `json:"facts,omitempty"` // Certificate is where this module wants a certificate for its machine's name inside the // mesh, and where the key that goes with it can be found. diff --git a/internal/catalogue/provided_test.go b/internal/catalogue/provided_test.go index 17719e6..029b19d 100644 --- a/internal/catalogue/provided_test.go +++ b/internal/catalogue/provided_test.go @@ -54,7 +54,7 @@ func TestTheShippedNetworkingModulesResolveOnTheirOwn(t *testing.T) { // 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. for _, m := range got.Modules { - if m.Module == overlay.Name && m.Facts["node-names"] == "" { + 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) } } diff --git a/internal/catalogue/resolver_manifests_test.go b/internal/catalogue/resolver_manifests_test.go index 3336380..82d2bf0 100644 --- a/internal/catalogue/resolver_manifests_test.go +++ b/internal/catalogue/resolver_manifests_test.go @@ -50,7 +50,7 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T "\nno-resolv\n", "\nserver=1.1.1.1\n", "\nserver=8.8.8.8\n", "\nlisten-address=127.0.0.1\n", "\ninterface=mesh0\n", "\nbind-dynamic\n", "\ndomain-needed\n", "\nbogus-priv\n", - "\nconf-file=" + m.Facts[FactNodeZones] + "\n", + "\nconf-file=" + m.Facts["node-zones"].Path + "\n", } { if !strings.Contains(config, want) { t.Errorf("the resolver's configuration lacks %q:\n%s", strings.TrimSpace(want), config) diff --git a/internal/catalogue/roster.go b/internal/catalogue/roster.go new file mode 100644 index 0000000..849e813 --- /dev/null +++ b/internal/catalogue/roster.go @@ -0,0 +1,165 @@ +package catalogue + +import ( + "bytes" + "fmt" + "sort" + "strings" + "text/template" +) + +// What only the mesh knows, written where a module asks for it — in the module's own format. +// +// **The graph is the control plane's; the format is the module's.** The mesh knows which machines +// exist, what they are called and where they are. Turning that into a hosts file, a resolver's +// zones, an ssh known_hosts is somebody's configuration language, and the mesh has no business +// knowing it. So the mesh hands the roster to a template the module wrote and renders it; it never +// learns what the file means. +// +// This used to be a closed list of fact names, each with its format written in Go here — a hosts +// file, a resolver's zones. Every new consumer meant a new formatter in the control plane, in the +// consumer's configuration language. Now the data is the mesh's and the format is a template the +// module ships: the two built-in cases (the network module's `/etc/hosts`, dnsmasq's zones) render +// the same way any module's would, and the control plane holds no format at all. +// +// It replaced three modules that existed only because computed output needed somewhere to live — +// they ran no software, could not be swapped for anything, and appeared in the graph as modules +// while being a data channel wearing a costume (novox/hq ADR 0040). + +// A RosterFile is a file the mesh renders from the roster of machines, in the format the module +// gives as a Go text/template. The template sees a rosterView: `.Node` (this machine's bare name), +// `.Suffix` (what its mesh name ends in), and two sets of `{Name, FQDN, Address}` — `.Names`, every +// name the mesh serves, and `.Machines`, only the nodes of the mesh. Which set a template ranges is +// how the hq issue 111 distinction is drawn: a container's hosts wants every name; a resolver told +// the suffix is its own wants only the machines. +type RosterFile struct { + // Path is where on the machine the rendered file goes. Absolute, or it is refused here rather + // than discovered as a daemon that reads nothing. + Path string `json:"path"` + // Template is the module's format, a Go text/template over the rosterView. It is the module's, + // not the mesh's: the mesh renders it and does not read it. + Template string `json:"template"` +} + +// rosterView is what a RosterFile's template sees. A closed shape — a template referencing a field +// the mesh does not compute fails to render here, not on a machine. +type rosterView struct { + Node string + Suffix string + Names []rosterEntry + Machines []rosterEntry +} + +// rosterEntry is one machine as a template sees it: its bare name, its full mesh name, its address. +type rosterEntry struct { + Name string + FQDN string + Address string +} + +// FactsInto renders the roster files a module asked for, as files it will be given. +// +// The module owns everything after the file exists: loading it, restarting on it, what a resolver +// or a client does with it. This only puts it there. `every` is every name the mesh serves; +// `machines` is only the machines — the two must not be confused (novox/hq 04-ISSUES/111), so both +// are given and the template chooses. +func FactsInto(m Manifest, r Resolution, every, machines map[string]string, suffix string) ([]map[string]any, error) { + if len(m.Facts) == 0 { + return nil, nil + } + names := make([]string, 0, len(m.Facts)) + for name := range m.Facts { + names = append(names, name) + } + sort.Strings(names) + + view := rosterView{ + Node: r.Node, + Suffix: strings.TrimPrefix(suffixOr(suffix), "."), + Names: entriesFrom(every, suffix), + Machines: entriesFrom(machines, suffix), + } + + out := make([]map[string]any, 0, len(names)) + for _, name := range names { + fact := m.Facts[name] + if !strings.HasPrefix(fact.Path, "/") { + return nil, fmt.Errorf( + "%s asks for %q at %q, which is not an absolute path", m.Module, name, fact.Path) + } + content, err := renderRoster(fact.Template, view) + if err != nil { + return nil, fmt.Errorf("%s cannot render %q: %w", m.Module, name, err) + } + out = append(out, map[string]any{ + "id": "fact-" + name, "type": "file", "path": fact.Path, "mode": "0644", + "content": content, + }) + } + return out, nil +} + +// renderRoster runs a module's template over the roster. A template that will not parse, or reads +// a field the mesh does not have, is an error here — where the manifest is — rather than an empty +// file on a machine. +func renderRoster(tmpl string, view rosterView) (string, error) { + t, err := template.New("roster").Option("missingkey=error").Parse(tmpl) + if err != nil { + return "", err + } + var b bytes.Buffer + if err := t.Execute(&b, view); err != nil { + return "", err + } + return b.String(), nil +} + +// entriesFrom is a name→address map as sorted roster entries. +// +// **A machine with no address is left out.** The mesh has a record for it — somebody added it — +// and does not yet know where it is, which is the ordinary state between adding a machine and it +// joining. Writing the name anyway would give a name that resolves to nothing, and a connection to +// that hangs; leaving it out fails at once and says the name is unknown. +func entriesFrom(addresses map[string]string, suffix string) []rosterEntry { + out := make([]rosterEntry, 0, len(addresses)) + for _, name := range sortedNames(addresses) { + internal, bare := meshName(name, suffix) + out = append(out, rosterEntry{Name: bare, FQDN: internal, Address: addresses[name]}) + } + return out +} + +// meshName is a machine's internal name and its bare one, from either. The control plane keys +// the names it hands a resolution by the internal name (`homer.internal`), the same map a +// container gets as its hosts; a caller that keys by the bare name gets the same answer. The +// suffix is the one the control plane composed those names with, handed down rather than written +// here a second time — the alternative was `homer.internal.internal` on every machine. +func meshName(name, suffix string) (internal, bare string) { + dotted := "." + strings.TrimPrefix(suffixOr(suffix), ".") + if strings.HasSuffix(name, dotted) { + return name, strings.TrimSuffix(name, dotted) + } + return name + dotted, name +} + +// suffixOr is the suffix given, or the one the mesh composes names with when none was handed down. +// The one place the default is written, so a fact and a name cannot disagree about it. +func suffixOr(suffix string) string { + if suffix == "" { + return "internal" + } + return suffix +} + +func sortedNames(addresses map[string]string) []string { + out := make([]string, 0, len(addresses)) + for name, at := range addresses { + // A machine the mesh cannot place is left out rather than named at nothing. + if at == "" { + continue + } + out = append(out, name) + } + sort.Strings(out) + return out +} diff --git a/internal/catalogue/roster_test.go b/internal/catalogue/roster_test.go new file mode 100644 index 0000000..0025955 --- /dev/null +++ b/internal/catalogue/roster_test.go @@ -0,0 +1,195 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// Keyed by the internal name, as the control plane hands them (issue 079). bart has no address — +// the ordinary state between adding a machine and it joining. +var threeMachines = map[string]string{"homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2", "bart.internal": ""} + +// A module says where it wants a roster file and in what format, and is given the rendered file. +func TestAModuleIsGivenTheFileItAskedFor(t *testing.T) { + m := Manifest{Module: "resolver", Facts: map[string]RosterFile{ + "zones": {Path: "/etc/mesh/zones.conf", Template: "{{range .Machines}}address=/{{.FQDN}}/{{.Address}}\n{{end}}"}, + }} + given, err := FactsInto(m, Resolution{Node: "homer"}, threeMachines, threeMachines, "") + if err != nil { + t.Fatal(err) + } + if len(given) != 1 { + t.Fatalf("expected one file, got %d", len(given)) + } + if given[0]["path"] != "/etc/mesh/zones.conf" || given[0]["type"] != "file" { + t.Fatalf("not written where it was asked for: %v", given[0]) + } + // The id is fact-, which is what a restart-on names when the roster changes. + if given[0]["id"] != "fact-zones" { + t.Fatalf("the file's id is not fact-, so a restart-on cannot find it: %v", given[0]["id"]) + } + if !strings.Contains(given[0]["content"].(string), "address=/homer.internal/10.42.0.1") { + t.Fatalf("the file does not hold the fact: %v", given[0]["content"]) + } +} + +// **The format is the module's — the mesh renders whatever template it gives.** The same roster +// through two templates is two entirely different files, and the control plane reads neither. +func TestTheFormatIsTheModulesOwn(t *testing.T) { + roster := map[string]string{"homer.internal": "10.42.0.1"} + hostsish := Manifest{Module: "a", Facts: map[string]RosterFile{ + "f": {Path: "/f", Template: "{{range .Names}}{{.Address}}\t{{.Name}}\n{{end}}"}}} + sshish := Manifest{Module: "b", Facts: map[string]RosterFile{ + "f": {Path: "/f", Template: "{{range .Names}}Host {{.Name}}\n HostName {{.FQDN}}\n{{end}}"}}} + + h, err := FactsInto(hostsish, Resolution{Node: "homer"}, roster, roster, "") + if err != nil { + t.Fatal(err) + } + s, err := FactsInto(sshish, Resolution{Node: "homer"}, roster, roster, "") + if err != nil { + t.Fatal(err) + } + if h[0]["content"] != "10.42.0.1\thomer\n" { + t.Fatalf("the hosts-shaped template did not render its format: %q", h[0]["content"]) + } + if s[0]["content"] != "Host homer\n HostName homer.internal\n" { + t.Fatalf("the ssh-shaped template did not render its format: %q", s[0]["content"]) + } +} + +// **A template that will not parse is refused here, not on a machine.** A daemon that starts, reads +// a file the mesh could not render, and answers nothing is a much worse way to find out. +func TestABrokenTemplateIsRefusedHere(t *testing.T) { + m := Manifest{Module: "resolver", Facts: map[string]RosterFile{ + "zones": {Path: "/etc/zones", Template: "{{range .Machines}}oops"}}} + _, err := FactsInto(m, Resolution{}, nil, nil, "") + if err == nil { + t.Fatal("a template that does not parse was accepted, so the machine gets an empty file") + } + if !strings.Contains(err.Error(), "resolver") || !strings.Contains(err.Error(), "zones") { + t.Fatalf("the refusal does not say whose template, or which: %v", err) + } +} + +// A template reading something the mesh does not compute is refused, not rendered empty. The roster +// is a closed shape; asking it for the weather fails where the manifest is. +func TestATemplateReadingWhatTheMeshDoesNotHaveIsRefused(t *testing.T) { + m := Manifest{Module: "resolver", Facts: map[string]RosterFile{ + "zones": {Path: "/etc/zones", Template: "{{.Weather}}"}}} + if _, err := FactsInto(m, Resolution{}, nil, nil, ""); err == nil { + t.Fatal("a template read a field nobody computes and rendered anyway, silently") + } +} + +// A relative path is refused, or a module decides where the mesh writes on a machine. +func TestAFactMustBeAskedForAtAnAbsolutePath(t *testing.T) { + m := Manifest{Module: "resolver", Facts: map[string]RosterFile{ + "hosts": {Path: "etc/hosts", Template: "x"}}} + if _, err := FactsInto(m, Resolution{}, nil, nil, ""); err == nil { + t.Fatal("a relative path was accepted") + } +} + +// A machine the mesh has a record for and cannot place is left out of the roster. +// +// **Not an oversight — the alternative is worse.** A name written with no address resolves to +// nothing, and a connection to that hangs. Leaving it out fails at once and says the name is +// unknown, which is a thing somebody can act on. +func TestAMachineWithNoAddressIsNotInTheRoster(t *testing.T) { + m := Manifest{Module: "a", Facts: map[string]RosterFile{ + "f": {Path: "/f", Template: "{{range .Machines}}{{.Name}}\n{{end}}"}}} + given, err := FactsInto(m, Resolution{Node: "homer"}, threeMachines, threeMachines, "") + if err != nil { + t.Fatal(err) + } + if strings.Contains(given[0]["content"].(string), "bart") { + t.Fatalf("a machine with no address was in the roster, so its name resolves to nothing:\n%s", given[0]["content"]) + } +} + +// **The names the control plane hands a resolution are already internal names** — `homer.internal`, +// the same map every container gets as its hosts. A roster entry's FQDN is that name, not it with +// the suffix appended a second time; either key gives the same entries. +func TestNamesAreNotSuffixedTwice(t *testing.T) { + internal := map[string]string{"homer.internal": "10.42.0.1"} + bare := map[string]string{"homer": "10.42.0.1"} + tmpl := RosterFile{Path: "/f", Template: "{{range .Machines}}{{.FQDN}} {{.Name}}\n{{end}}"} + + fromInternal, err := FactsInto(Manifest{Module: "a", Facts: map[string]RosterFile{"f": tmpl}}, Resolution{Node: "homer"}, internal, internal, "") + if err != nil { + t.Fatal(err) + } + fromBare, err := FactsInto(Manifest{Module: "a", Facts: map[string]RosterFile{"f": tmpl}}, Resolution{Node: "homer"}, bare, bare, "") + if err != nil { + t.Fatal(err) + } + if fromInternal[0]["content"] != fromBare[0]["content"] { + t.Fatalf("the roster differs by how the names were keyed:\n%q\n%q", fromInternal[0]["content"], fromBare[0]["content"]) + } + got := fromInternal[0]["content"].(string) + if strings.Contains(got, "internal.internal") || !strings.Contains(got, "homer.internal homer") { + t.Fatalf("the entry carries a doubled suffix or the wrong bare name:\n%s", got) + } +} + +// The suffix the control plane composed the names with is the one a template sees — an operator who +// chose another does not get `.internal`. `.Suffix` is the bare form, and FQDNs carry it. +func TestTheSuffixIsCarriedAsComposed(t *testing.T) { + names := map[string]string{"homer.lan": "10.42.0.1"} + m := Manifest{Module: "a", Facts: map[string]RosterFile{ + "f": {Path: "/f", Template: "local=/{{.Suffix}}/\n{{range .Machines}}{{.FQDN}}\n{{end}}"}}} + given, err := FactsInto(m, Resolution{Node: "homer"}, names, names, "lan") + if err != nil { + t.Fatal(err) + } + got := given[0]["content"].(string) + if !strings.Contains(got, "local=/lan/") || strings.Contains(got, "internal") { + t.Fatalf("the operator's suffix was not carried, so its names would be wrong:\n%s", got) + } + if !strings.Contains(got, "homer.lan") { + t.Fatalf("the FQDN does not carry the operator's suffix:\n%s", got) + } +} + +// novox/hq 04-ISSUES/111: a template is given both sets and chooses. `.Names` is every name the mesh +// serves — the machines and the names it was told to route; `.Machines` is only the machines. A +// container's hosts wants every name so a routed name resolves to the machine serving it; a resolver +// told the suffix is its own wants only the machines, or a routed name written there with the suffix +// is a name nobody will ever ask for, standing beside the machines and looking as real. +func TestATemplateChoosesMachinesOrEveryName(t *testing.T) { + machines := map[string]string{"homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2"} + every := map[string]string{ + "homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2", + "drive.example.test": "10.42.0.1", "git.example.test": "10.42.0.2", + } + m := Manifest{Module: "resolver", Facts: map[string]RosterFile{ + "zones": {Path: "/etc/zones", Template: "{{range .Machines}}{{.FQDN}}\n{{end}}"}, + "hosts": {Path: "/etc/hosts", Template: "{{range .Names}}{{.FQDN}}\n{{end}}"}, + }} + given, err := FactsInto(m, Resolution{Node: "homer"}, every, machines, "") + if err != nil { + t.Fatal(err) + } + by := map[string]string{} + for _, f := range given { + by[f["path"].(string)] = f["content"].(string) + } + + zones := by["/etc/zones"] + for _, served := range []string{"drive.example.test", "git.example.test"} { + if strings.Contains(zones, served) { + t.Fatalf("a template over .Machines saw %q, a name the mesh serves rather than a machine:\n%s", served, zones) + } + } + if !strings.Contains(zones, "homer.internal") { + t.Fatalf("a template over .Machines did not see the machines:\n%s", zones) + } + + hosts := by["/etc/hosts"] + for _, name := range []string{"homer.internal", "drive.example.test", "git.example.test"} { + if !strings.Contains(hosts, name) { + t.Fatalf("a template over .Names did not see %q, so a container would not resolve it:\n%s", name, hosts) + } + } +} diff --git a/internal/overlay/generator.go b/internal/overlay/generator.go index eb0b9c9..d7b5ed5 100644 --- a/internal/overlay/generator.go +++ b/internal/overlay/generator.go @@ -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 ` 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"}}, } } diff --git a/internal/overlay/hosts_fact_test.go b/internal/overlay/hosts_fact_test.go new file mode 100644 index 0000000..485b379 --- /dev/null +++ b/internal/overlay/hosts_fact_test.go @@ -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) + } +} From a6d89e3f732ee7974c6f173d4a62bc0be8dad692 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 01:50:11 +0200 Subject: [PATCH 2/2] Reconcile with hq 128: hosts template is the region form, node-names is shared The merge commit took only the staged index; these reconciliation edits sat unstaged in the working tree. Integrate the template mechanism with #79's region write (hq 128): RosterFile gains Shared, FactsInto sets into:block for a shared fact, /etc/hosts becomes the region form (no floor) and node-names is marked shared. Without this the merge would have regressed /etc/hosts back to a whole-file write, replacing the operator's own lines. --- internal/catalogue/roster.go | 20 ++++++++++++++++++-- internal/catalogue/roster_test.go | 26 ++++++++++++++++++++++++++ internal/overlay/generator.go | 23 ++++++++++------------- 3 files changed, 54 insertions(+), 15 deletions(-) diff --git a/internal/catalogue/roster.go b/internal/catalogue/roster.go index 849e813..48db115 100644 --- a/internal/catalogue/roster.go +++ b/internal/catalogue/roster.go @@ -39,6 +39,13 @@ type RosterFile struct { // Template is the module's format, a Go text/template over the rosterView. It is the module's, // not the mesh's: the mesh renders it and does not read it. Template string `json:"template"` + // Shared is whether the file the fact goes to belongs to the machine rather than the mesh. When + // it does, the mesh owns only a marked region of it and keeps the rest byte for byte (novox/hq + // issue 128) — a hosts file is shared, since the distribution's `localhost`, the operator's own + // lines and other tools' blocks live there too; a resolver's zones file is not, the mesh owns it + // whole. A property of the fact, not of the path: the format determines whether the file is + // wholly the mesh's, not where a module happened to ask for it. + Shared bool `json:"shared,omitempty"` } // rosterView is what a RosterFile's template sees. A closed shape — a template referencing a field @@ -91,10 +98,19 @@ func FactsInto(m Manifest, r Resolution, every, machines map[string]string, suff if err != nil { return nil, fmt.Errorf("%s cannot render %q: %w", m.Module, name, err) } - out = append(out, map[string]any{ + file := map[string]any{ "id": "fact-" + name, "type": "file", "path": fact.Path, "mode": "0644", "content": content, - }) + } + if fact.Shared { + // The host owns only the lines between `# BEGIN mesh ` and `# END mesh ` and + // keeps the rest of the file byte for byte; undeclared, the region goes and nothing else + // does (novox/hq issue 128). Every node on the private network receives this, so every + // node's host — the controller's own machine included — must be block-aware before a + // controller emitting it is rolled out: the order ADR 0102 set for `into: json`. + file["into"] = "block" + } + out = append(out, file) } return out, nil } diff --git a/internal/catalogue/roster_test.go b/internal/catalogue/roster_test.go index 0025955..252f99d 100644 --- a/internal/catalogue/roster_test.go +++ b/internal/catalogue/roster_test.go @@ -33,6 +33,32 @@ func TestAModuleIsGivenTheFileItAskedFor(t *testing.T) { } } +// **A shared fact is written into a region of the machine's file, not over it** (novox/hq issue +// 128). A hosts file is the machine's — its localhost, the operator's lines, other tools' blocks — +// so the mesh owns only a marked region (`into: block`); a resolver's zones file is the mesh's +// whole, and carries no `into`. +func TestASharedFactIsWrittenIntoARegion(t *testing.T) { + roster := map[string]string{"homer.internal": "10.42.0.1"} + m := Manifest{Module: "net", Facts: map[string]RosterFile{ + "node-names": {Path: "/etc/hosts", Template: "{{range .Names}}{{.FQDN}}\n{{end}}", Shared: true}, + "node-zones": {Path: "/etc/zones", Template: "{{range .Machines}}{{.FQDN}}\n{{end}}"}, + }} + given, err := FactsInto(m, Resolution{Node: "homer"}, roster, roster, "") + if err != nil { + t.Fatal(err) + } + by := map[string]map[string]any{} + for _, f := range given { + by[f["path"].(string)] = f + } + if by["/etc/hosts"]["into"] != "block" { + t.Fatalf("a shared fact is not written into a region, so the mesh writes the file whole: %v", by["/etc/hosts"]) + } + if _, has := by["/etc/zones"]["into"]; has { + t.Fatalf("an unshared fact was written into a region, so the mesh does not own its own file whole: %v", by["/etc/zones"]) + } +} + // **The format is the module's — the mesh renders whatever template it gives.** The same roster // through two templates is two entirely different files, and the control plane reads neither. func TestTheFormatIsTheModulesOwn(t *testing.T) { diff --git a/internal/overlay/generator.go b/internal/overlay/generator.go index d7b5ed5..d41559a 100644 --- a/internal/overlay/generator.go +++ b/internal/overlay/generator.go @@ -161,21 +161,17 @@ 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. +// 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. // -// - The loopback floor stays, or things with nothing to do with the mesh break. -// - `127.0.1.1 ` only when there is a node, the ordinary Debian self-name line. +// - 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 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" + +const hostsTemplate = "# The mesh's names. This region is replaced whenever a machine joins or leaves.\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. @@ -194,9 +190,10 @@ func Manifest() map[string]any { // 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). + // 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}, + "node-names": map[string]any{"path": "/etc/hosts", "template": hostsTemplate, "shared": true}, }, "claims": []map[string]any{{"name": TheNetwork, "scope": "node"}}, }