diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index 6927def..6dcb96e 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -923,12 +923,26 @@ func planCommand(ctx context.Context, args []string) error { if !ok { continue } - fmt.Printf("\n--- %v %v ---\n%s", r["id"], r["path"], content) + fmt.Printf("\n--- %s ---\n%s", shownAs(r), content) } } 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. // // A mesh with no licences at all is the ordinary case and must not be an error: every existing diff --git a/cmd/mesh-controller/plan_test.go b/cmd/mesh-controller/plan_test.go index 35a86b8..0075d8f 100644 --- a/cmd/mesh-controller/plan_test.go +++ b/cmd/mesh-controller/plan_test.go @@ -35,3 +35,17 @@ func TestListensLinesAreEmptyForAModuleWithNothingToListenOn(t *testing.T) { 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) + } +} diff --git a/internal/catalogue/hosts_region_test.go b/internal/catalogue/hosts_region_test.go new file mode 100644 index 0000000..16e817d --- /dev/null +++ b/internal/catalogue/hosts_region_test.go @@ -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 +} diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index 2475e06..dfcadc9 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -47,6 +47,13 @@ var seats = []Seat{ {Name: "the-private-network", 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"}, + // 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. diff --git a/internal/catalogue/seats_test.go b/internal/catalogue/seats_test.go index 96cdfc2..f1c2087 100644 --- a/internal/catalogue/seats_test.go +++ b/internal/catalogue/seats_test.go @@ -44,12 +44,32 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { delivered[s.Delivers] = s.Name } } - if len(Seats()) != 14 { - t.Errorf("the mesh defines %d seats rather than 14; the set is closed, so a change here is "+ + if len(Seats()) != 15 { + 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()) } } +// 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 { return []byte(`{"module":"thing","version":"1","provides":[{"name":"npm-package-registry","scope":"mesh"}],"claims":` + claims + `}`) } diff --git a/internal/overlay/hosts_fact_test.go b/internal/overlay/hosts_fact_test.go deleted file mode 100644 index 485b379..0000000 --- a/internal/overlay/hosts_fact_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package overlay - -import ( - "testing" - - "github.com/novox/mesh-controller/internal/catalogue" -) - -// The network module's `/etc/hosts` is a roster template like any module's (novox/hq: the graph is -// the control plane's, the format is the module's). These pin the format that used to be a Go -// formatter in the control plane, so the file a machine gets does not change with the mechanism: -// the loopback floor, the Debian self-name line, the machine's own line marked and at its mesh -// address, one line per machine, every served name (issue 111). -func hostsFor(t *testing.T, node string, every map[string]string) string { - t.Helper() - m := catalogue.Manifest{Module: "net", Facts: map[string]catalogue.RosterFile{ - "node-names": {Path: "/etc/hosts", Template: hostsTemplate}, - }} - given, err := catalogue.FactsInto(m, catalogue.Resolution{Node: node}, every, every, "") - if err != nil { - t.Fatal(err) - } - return given[0]["content"].(string) -} - -func TestTheHostsFileIsThisExactly(t *testing.T) { - got := hostsFor(t, "homer", map[string]string{"homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2"}) - want := "# 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" + - "127.0.1.1\thomer\n\n" + - "10.42.0.1\thomer.internal\thomer\t# this machine\n" + - "10.42.0.2\tmarge.internal\tmarge\n" - if got != want { - t.Fatalf("the hosts file changed with the mechanism:\ngot:\n%q\nwant:\n%q", got, want) - } -} - -// With no node named there is no `127.0.1.1` self-line — but the blank line before the machines -// stays, exactly as the old formatter wrote it unconditionally. -func TestTheHostsFileWithoutASelfNameKeepsItsShape(t *testing.T) { - got := hostsFor(t, "", map[string]string{"homer.internal": "10.42.0.1"}) - want := "# 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\n" + - "10.42.0.1\thomer.internal\thomer\n" - if got != want { - t.Fatalf("the hosts file without a self-name changed shape:\ngot:\n%q\nwant:\n%q", got, want) - } -} 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