The uplink seat (ADR 0117), and the mesh's names written into the hosts file, not over it (hq 128) #79

Merged
jschoubben merged 3 commits from feat/the-uplink-seat-and-the-hosts-region into main 2026-09-26 23:00:19 +00:00
3 changed files with 128 additions and 32 deletions
Showing only changes of commit 51163b9f14 - Show all commits
+70 -26
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,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 <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