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/facts.go b/internal/catalogue/facts.go index 99752c9..c28ce17 100644 --- a/internal/catalogue/facts.go +++ b/internal/catalogue/facts.go @@ -111,10 +111,14 @@ func FactsInto(m Manifest, r Resolution, addresses, machines map[string]string, // 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). + // **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) 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 +}