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"}}, }