The mesh's names are written into the hosts file, not over it (hq 128)

/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.
This commit is contained in:
jochen
2026-09-26 23:46:51 +02:00
parent 6557aca750
commit 51163b9f14
3 changed files with 128 additions and 32 deletions
+68 -24
View File
@@ -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,21 +33,45 @@ 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 {
var facts = map[string]fact{
FactNodeNames: {
render: 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 {
shared: true,
},
FactNodeZones: {
render: 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.
@@ -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 <id>` and `# END mesh <id>` 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")
+54 -3
View File
@@ -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)
}
}
}
+4 -3
View File
@@ -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