From 51163b9f1479fcabc42e271dbe66ff0d7d2cd4b7 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 23:46:51 +0200 Subject: [PATCH] The mesh's names are written into the hosts file, not over it (hq 128) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /etc/hosts is the machine's: the distribution's localhost lines, the operator's own entries, and marked blocks other tools maintain there. Writing node-names whole replaced all of it the moment the private network was taken, and every later write by those tools was lost at the next machine joining. The node-names fact is now emitted with into: "block", so the host owns only its marked region and keeps the rest byte for byte. The region holds only the mesh's names: no header claiming the file, no localhost, no 127.0.1.1 line — the floor was never the mesh's to write. How a fact is written is a property of the fact in the closed table; node-zones stays a whole file the mesh owns. Sequencing: a host older than the block mode refuses the whole declaration on an unknown into, so every host must be upgraded before this controller is rolled out. --- internal/catalogue/facts.go | 96 +++++++++++++++++++++++--------- internal/catalogue/facts_test.go | 57 ++++++++++++++++++- internal/overlay/names.go | 7 ++- 3 files changed, 128 insertions(+), 32 deletions(-) diff --git a/internal/catalogue/facts.go b/internal/catalogue/facts.go index 2fdd510..99752c9 100644 --- a/internal/catalogue/facts.go +++ b/internal/catalogue/facts.go @@ -18,8 +18,10 @@ import ( // module at all (novox/hq ADR 0040). const ( - // FactNodeNames is every machine's name and address, as a hosts file. + // FactNodeNames is every machine's name and address, as lines of a hosts file. // + // Written *into* the machine's hosts file as a region of its own, never as the file: the rest + // of that file is the distribution's, the operator's and other tools' (novox/hq issue 128). // 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" @@ -31,20 +33,44 @@ const ( FactNodeZones = "node-zones" ) -// facts is every fact the mesh computes, and what writes it. +// fact is one thing the mesh computes, and how it is written. +type fact struct { + // render is the fact's content. 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). + render func(r Resolution, every, machines map[string]string, suffix string) string + // shared is whether the file the fact goes to belongs to the machine rather than to the mesh. + // + // **A property of the fact, not of the path a module asked for.** A hosts file is the + // machine's wherever it lives: the distribution put `localhost` in it, the operator added + // their own lines, and a local development tool keeps marked blocks of its own there. The + // mesh writing it whole replaced all of that the moment the private network was taken, and + // every later write by the other tool was lost at the next machine joining — silently, with + // both sides believing they owned the file (novox/hq issue 128). That is ADR 0102's failure in + // a file 0102 did not name, because its merge is structured and a hosts file is not; the + // answer is the same idea for text — a marked region the host owns, everything outside it + // kept byte for byte. A resolver's zones file is the other way about: the mesh owns it, and + // nothing else writes there. + shared bool +} + +// facts is every fact the mesh computes, and how each is written. // // **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) +var facts = map[string]fact{ + FactNodeNames: { + render: func(r Resolution, every, _ map[string]string, suffix string) string { + return nodeNames(r, every, suffix) + }, + shared: true, }, - FactNodeZones: func(r Resolution, _, machines map[string]string, suffix string) string { - return nodeZones(r, machines, suffix) + FactNodeZones: { + render: func(r Resolution, _, machines map[string]string, suffix string) string { + return nodeZones(r, machines, suffix) + }, }, } @@ -64,7 +90,7 @@ func FactsInto(m Manifest, r Resolution, addresses, machines map[string]string, out := make([]map[string]any, 0, len(names)) for _, name := range names { - write, known := facts[name] + f, known := facts[name] if !known { return nil, fmt.Errorf( "%s asks the mesh for %q, which it does not compute. It has %s", @@ -75,10 +101,23 @@ func FactsInto(m Manifest, r Resolution, addresses, machines map[string]string, 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{ + file := map[string]any{ "id": "fact-" + name, "type": "file", "path": path, "mode": "0644", - "content": write(r, addresses, machines, suffix), - }) + "content": f.render(r, addresses, machines, suffix), + } + if f.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. Replacing nothing, it is written on an adopted node without being held, + // so a machine is named on the private network before its module is taken. + // + // **Hosts first, then this.** A host older than the block mode refuses the whole + // declaration on an `into` it does not know — not just this file, everything — so + // every host is upgraded before a controller emitting it is rolled out, the same + // order ADR 0102 set for `into: json` (novox/hq issue 128). + file["into"] = "block" + } + out = append(out, file) } return out, nil } @@ -93,7 +132,18 @@ func spokenFacts() string { return strings.Join(names, ", ") } -// nodeNames is every machine's name and address, as a hosts file. +// nodeNames is every machine's name and address, as the mesh's region of a hosts file. +// +// **Only the mesh's names.** No header claiming the file, no `localhost`, no `127.0.1.1` line for +// the machine itself. Those used to be written here as the floor every Linux expects, because the +// mesh wrote the whole file and removing them would have broken things with nothing to do with +// the mesh. They were never the mesh's: the distribution wrote them before the mesh arrived and +// will want them after it leaves, and a machine's own name belongs to whoever named the machine. +// Now the host writes this into a marked region (novox/hq issue 128) and leaves the rest of the +// file as it found it, so the floor stays where it always was — the machine's — and the mesh +// writing a second `localhost` beside it would be one more line nobody could say the owner of. +// The one comment line is for a person reading the file: which lines are the mesh's, and that +// editing them is pointless. // // **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 @@ -101,21 +151,15 @@ func spokenFacts() string { // 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") + b.WriteString("# The mesh's names. This region is replaced whenever a machine joins or leaves.\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. + // so a service binding the name it was given stays reachable from everywhere else. The + // bare name may be answered first by a line of the machine's own — `127.0.1.1 homer`, + // above the region — and that is the machine's choice to have made; the mesh name is the + // one nothing else in the file writes. fmt.Fprintf(&b, "%s\t%s\t%s", at, internal, bare) if bare == r.Node { b.WriteString("\t# this machine") diff --git a/internal/catalogue/facts_test.go b/internal/catalogue/facts_test.go index 1f30b69..577b309 100644 --- a/internal/catalogue/facts_test.go +++ b/internal/catalogue/facts_test.go @@ -54,9 +54,60 @@ func TestAMachinesOwnNameIsItsMeshAddress(t *testing.T) { 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) +} + +// novox/hq issue 128: the names are the mesh's region of the machine's hosts file, and only that. +// +// **The loopback floor is the machine's now, not the mesh's.** It was written here while the mesh +// wrote the whole file. Written into a region, a `localhost` or a `127.0.1.1 homer` of the mesh's +// would stand beside the distribution's own — a second answer nobody could say the owner of, and +// one that goes when the mesh leaves. A header claiming the file would be a lie about the rest of +// it. And no line may look like the host's own markers, or the host would refuse the region. +func TestTheNamesAreOnlyTheMeshsRegionOfTheFile(t *testing.T) { + out := nodeNames(Resolution{Node: "homer"}, threeMachines, "") + want := "# The mesh's names. This region is replaced whenever a machine joins or leaves.\n" + + "10.42.0.1\thomer.internal\thomer\t# this machine\n" + + "10.42.0.2\tmarge.internal\tmarge\n" + if out != want { + t.Fatalf("the region is not exactly the mesh's names:\n%s\n--- want ---\n%s", out, want) + } + for _, floor := range []string{"localhost", "127.0.", "::1", "Generated by the mesh", "# BEGIN mesh ", "# END mesh "} { + if strings.Contains(out, floor) { + t.Fatalf("the region carries %q, which is not the mesh's to write:\n%s", floor, out) + } + } +} + +// The hosts file is written into; the resolver's zones are written whole. A property of each fact, +// so a module asking for node-names at another path still does not get a file of the mesh's. +func TestOnlyTheNamesAreWrittenIntoASharedFile(t *testing.T) { + m := Manifest{Module: "resolver", Facts: map[string]string{ + FactNodeZones: "/etc/mesh-resolver/nodes.conf", FactNodeNames: "/etc/hosts", + }} + given, err := FactsInto(m, Resolution{Node: "homer"}, threeMachines, threeMachines, "") + if err != nil { + t.Fatal(err) + } + if len(given) != 2 { + t.Fatalf("expected two files, got %v", given) + } + for _, f := range given { + switch f["path"] { + case "/etc/hosts": + if f["into"] != "block" { + t.Errorf("the hosts file is written over rather than into: %v", f) + } + case "/etc/mesh-resolver/nodes.conf": + // The mesh owns the resolver's zones; nothing else writes there. + if into, set := f["into"]; set { + t.Errorf("the zones file is written into (%v), and it is the mesh's whole", into) + } + if !strings.HasPrefix(f["content"].(string), "# Generated by the mesh.") { + t.Errorf("the zones file lost its header: %v", f["content"]) + } + default: + t.Errorf("a file nobody asked for: %v", f) + } } } diff --git a/internal/overlay/names.go b/internal/overlay/names.go index a3b0b03..1c8f378 100644 --- a/internal/overlay/names.go +++ b/internal/overlay/names.go @@ -24,9 +24,10 @@ const DefaultSuffix = "internal" // // 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, -// generated whole and owned by the mesh (novox/hq ADR 0011), rather than a patch written -// underneath something else. +// 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