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
8 changed files with 296 additions and 35 deletions
+15 -1
View File
@@ -923,12 +923,26 @@ func planCommand(ctx context.Context, args []string) error {
if !ok { if !ok {
continue continue
} }
fmt.Printf("\n--- %v %v ---\n%s", r["id"], r["path"], content) fmt.Printf("\n--- %s ---\n%s", shownAs(r), content)
} }
} }
return nil return nil
} }
// shownAs is the heading `plan --show` puts over a resource's content.
//
// **A file written into says so.** Its content is the mesh's part of a file that is otherwise the
// machine's — the keys of a JSON document (novox/hq ADR 0102), the region of a hosts file (issue
// 128). Shown under a bare path it reads as the whole file, and a person checking what a take
// replaces would see a hosts file of a dozen lines where the machine keeps thirty.
func shownAs(r map[string]any) string {
heading := fmt.Sprintf("%v %v", r["id"], r["path"])
if into, ok := r["into"].(string); ok && into != "" {
heading += fmt.Sprintf(" (written into, %s)", into)
}
return heading
}
// licencesFor is what this node can be answered with by record, and what it was put on. // licencesFor is what this node can be answered with by record, and what it was put on.
// //
// A mesh with no licences at all is the ordinary case and must not be an error: every existing // A mesh with no licences at all is the ordinary case and must not be an error: every existing
+14
View File
@@ -35,3 +35,17 @@ func TestListensLinesAreEmptyForAModuleWithNothingToListenOn(t *testing.T) {
t.Errorf("a module with no listens should print nothing, got %v", got) t.Errorf("a module with no listens should print nothing, got %v", got)
} }
} }
// `plan --show` says when a file is written into rather than over (novox/hq issue 128), or the
// mesh's region of a hosts file reads as the whole file.
func TestAFileWrittenIntoIsShownAsSuch(t *testing.T) {
region := shownAs(map[string]any{
"id": "mesh-wireguard.fact-node-names", "path": "/etc/hosts", "into": "block"})
if region != "mesh-wireguard.fact-node-names /etc/hosts (written into, block)" {
t.Errorf("the region is shown as %q", region)
}
whole := shownAs(map[string]any{"id": "dnsmasq.fact-node-zones", "path": "/etc/mesh-resolver/nodes.conf"})
if strings.Contains(whole, "written into") {
t.Errorf("a whole file is shown as written into: %q", whole)
}
}
+72 -24
View File
@@ -18,8 +18,10 @@ import (
// module at all (novox/hq ADR 0040). // module at all (novox/hq ADR 0040).
const ( 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 // 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. // is a wildcard, which a hosts file cannot express — that is FactNodeZones.
FactNodeNames = "node-names" FactNodeNames = "node-names"
@@ -31,21 +33,45 @@ const (
FactNodeZones = "node-zones" 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 // **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 // 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. // 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 var facts = map[string]fact{
// and the names it was told to route; `machines` is only the machines. A fact takes the set it is FactNodeNames: {
// true of, and the two must not be confused (novox/hq 04-ISSUES/111). render: func(r Resolution, every, _ map[string]string, suffix string) string {
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) 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) return nodeZones(r, machines, suffix)
}, },
},
} }
// FactsInto renders the facts a module asked for, as files it will be given. // 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)) out := make([]map[string]any, 0, len(names))
for _, name := range names { for _, name := range names {
write, known := facts[name] f, known := facts[name]
if !known { if !known {
return nil, fmt.Errorf( return nil, fmt.Errorf(
"%s asks the mesh for %q, which it does not compute. It has %s", "%s asks the mesh for %q, which it does not compute. It has %s",
@@ -75,10 +101,27 @@ func FactsInto(m Manifest, r Resolution, addresses, machines map[string]string,
return nil, fmt.Errorf( return nil, fmt.Errorf(
"%s asks for %q at %q, which is not an absolute path", m.Module, name, path) "%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", "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 — an order to roll out in, not a note.** A host older than
// the block mode refuses the whole declaration on an `into` it does not know: not just
// this file, everything the node was sent, so it stops converging on anything at all.
// Every node on the private network receives this fact, so every node's host —
// the controller's own machine included, which would otherwise stop taking the
// declaration that runs the controller — must run a block-aware host 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 return out, nil
} }
@@ -93,7 +136,18 @@ func spokenFacts() string {
return strings.Join(names, ", ") 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 — // **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 // and does not yet know where it is, which is the ordinary state between adding a machine and it
@@ -101,21 +155,15 @@ func spokenFacts() string {
// that hangs; leaving it out fails at once and says the name is unknown. // 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 { func nodeNames(r Resolution, addresses map[string]string, suffix string) string {
var b strings.Builder var b strings.Builder
b.WriteString("# Generated by the mesh. Do not edit — this file is replaced whenever a machine\n") b.WriteString("# The mesh's names. This region is replaced whenever a machine joins or leaves.\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) { for _, name := range sortedNames(addresses) {
at := addresses[name] at := addresses[name]
internal, bare := meshName(name, suffix) internal, bare := meshName(name, suffix)
// Its mesh name resolves to its address on the private network rather than to loopback, // 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) fmt.Fprintf(&b, "%s\t%s\t%s", at, internal, bare)
if bare == r.Node { if bare == r.Node {
b.WriteString("\t# this machine") 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") { if !strings.HasPrefix(line, "10.42.0.1") {
t.Fatalf("a machine's own mesh name is not its mesh address: %q", line) 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)
}
} }
} }
+106
View File
@@ -0,0 +1,106 @@
package catalogue_test
import (
"reflect"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/overlay"
)
// novox/hq issue 128, held where the host will see it: the shipped networking module's names,
// through the whole composition, and not only through FactsInto.
//
// Composition prefixes a fact's id with the module that asked for it and passes everything else
// through; a step that dropped `into` on the way would send the region as a whole file, and the
// host would write the machine's hosts file over again with every unit test above still green.
// onTheNetwork stands in for the overlay's generator: the node is part of the private network,
// and what the generator writes is not what is under test here.
type onTheNetwork struct{}
func (onTheNetwork) Resources(string) ([]map[string]any, bool, error) {
return []map[string]any{{"id": "overlay-config", "type": "file",
"path": "/etc/wireguard/mesh0.conf", "mode": "0600", "content": "[Interface]\n"}}, true, nil
}
func TestTheHostsRegionArrivesAsTheHostWillReadIt(t *testing.T) {
shelf := provided(t)
// A resolver restarting on the names another module put on the machine, and one resource it
// only runs at start — neither of which composition has any business changing.
resolver, err := catalogue.ParseManifest([]byte(`{
"module": "resolver", "version": "1", "requires": ["mesh-addressing"],
"resources": [
{"id": "seed", "type": "file", "path": "/etc/resolver/seed", "mode": "0644",
"content": "seed\n", "at": "start"},
{"id": "daemon", "type": "service", "unit": "resolver.service", "state": "running",
"restart-on": ["seed", "mesh-wireguard.fact-node-names"]}
]}`))
if err != nil {
t.Fatal(err)
}
shelf[resolver.Module] = resolver
got, err := catalogue.Resolve(shelf, []string{overlay.Domain, "resolver"},
catalogue.Node{Name: "homer", At: "homer.internal"}, catalogue.World{})
if err != nil {
t.Fatal(err)
}
names := map[string]string{"homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2"}
out, err := got.Declaration(catalogue.Rendering{
Names: names, Machines: names, Suffix: "internal",
Generators: map[string]catalogue.Generator{overlay.Name: onTheNetwork{}},
})
if err != nil {
t.Fatal(err)
}
ids := map[string]map[string]any{}
for _, r := range out {
ids[r["id"].(string)] = r
}
hosts := ids[overlay.Name+".fact-node-names"]
if hosts == nil {
t.Fatalf("no names reached the machine; the declaration has %v", keys(ids))
}
if hosts["path"] != "/etc/hosts" || hosts["into"] != "block" {
t.Fatalf("the hosts file is not written into as a region: %v", hosts)
}
content := hosts["content"].(string)
if !strings.Contains(content, "10.42.0.1\thomer.internal\thomer\t# this machine\n") {
t.Errorf("the region does not name the machine:\n%s", content)
}
for _, floor := range []string{"Generated by the mesh", "localhost", "127.0.1.1"} {
if strings.Contains(content, floor) {
t.Errorf("the region carries %q, which is the machine's:\n%s", floor, content)
}
}
// The resolver's reference to it still names a resource the host will be sent.
daemon := ids["resolver.daemon"]
if daemon == nil {
t.Fatalf("the resolver's service was not composed: %v", keys(ids))
}
for _, named := range daemon["restart-on"].([]any) {
if ids[named.(string)] == nil {
t.Errorf("the resolver restarts on %v, which is nothing the host is sent", named)
}
}
if !reflect.DeepEqual(daemon["restart-on"], []any{"resolver.seed", overlay.Name + ".fact-node-names"}) {
t.Errorf("restart-on is %v", daemon["restart-on"])
}
// And a resource's own `at` passes through as the manifest wrote it.
if seed := ids["resolver.seed"]; seed == nil || seed["at"] != "start" {
t.Errorf("a resource's at did not survive composition: %v", seed)
}
}
func keys(m map[string]map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
+7
View File
@@ -47,6 +47,13 @@ var seats = []Seat{
{Name: "the-private-network", Scope: ScopeNode, Decision: "novox/hq ADR 0110"}, {Name: "the-private-network", Scope: ScopeNode, Decision: "novox/hq ADR 0110"},
{Name: "the-resolver-configuration", Scope: ScopeNode, Decision: "novox/hq ADR 0110"}, {Name: "the-resolver-configuration", Scope: ScopeNode, Decision: "novox/hq ADR 0110"},
{Name: "the-showcase", Scope: ScopeNode, Decision: "novox/hq ADR 0110"}, {Name: "the-showcase", Scope: ScopeNode, Decision: "novox/hq ADR 0110"},
// The program that manages the machine's own network. It delivers nothing: its holder only
// keeps the manager and the mesh from contradicting each other — the resolver file left to the
// mesh, the private network's interface left alone — and never declares a link, an address or
// a wireless network, because the link is the only channel a fix could arrive on. A seat
// rather than a condition in the resolver's module, so a machine running two managers is
// refused at assignment instead of found by the resolver being rewritten (novox/hq ADR 0117).
{Name: "the-uplink", Scope: ScopeNode, Decision: "novox/hq ADR 0117"},
} }
// Seats is every seat the mesh defines, in reading order. // Seats is every seat the mesh defines, in reading order.
+22 -2
View File
@@ -44,12 +44,32 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
delivered[s.Delivers] = s.Name delivered[s.Delivers] = s.Name
} }
} }
if len(Seats()) != 14 { if len(Seats()) != 15 {
t.Errorf("the mesh defines %d seats rather than 14; the set is closed, so a change here is "+ t.Errorf("the mesh defines %d seats rather than 15; the set is closed, so a change here is "+
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames()) "a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
} }
} }
// novox/hq ADR 0117: a machine's uplink is a seat, held per machine, and delivers nothing.
//
// **Nothing, because nothing may be required of it.** A holder only keeps its network manager from
// contradicting the mesh; a requirement resolving to it would make the manager the mesh's answer
// for something, and the manager's link is the one thing the mesh must never be able to break.
func TestTheUplinkIsANodeSeatThatDeliversNothing(t *testing.T) {
seat, known := SeatNamed("the-uplink")
if !known {
t.Fatalf("the uplink is not a seat; the seats are: %s", seatNames())
}
if seat.Scope != ScopeNode || seat.Delivers != "" || seat.Decision != "novox/hq ADR 0117" {
t.Fatalf("the uplink is %+v, not a node seat delivering nothing by ADR 0117", seat)
}
// And a manager's module can hold it without providing anything.
raw := []byte(`{"module":"networkmanager","version":"1","claims":[{"name":"the-uplink","scope":"node"}]}`)
if _, err := ParseManifest(raw); err != nil {
t.Fatalf("a network manager's module could not hold the uplink: %v", err)
}
}
func claimed(claims string) []byte { func claimed(claims string) []byte {
return []byte(`{"module":"thing","version":"1","provides":[{"name":"npm-package-registry","scope":"mesh"}],"claims":` + claims + `}`) return []byte(`{"module":"thing","version":"1","provides":[{"name":"npm-package-registry","scope":"mesh"}],"claims":` + claims + `}`)
} }
+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 // 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 // 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, // 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 // (novox/hq ADR 0011). Written as a marked region *into* the file rather than as the file: the
// underneath something else. // 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 // 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 // package, and has no failure mode of its own. A daemon becomes necessary when names are wanted