From 6557aca750486979d59b3826f4b7ba9b281b986c Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 23:45:47 +0200 Subject: [PATCH 1/3] A machine's uplink is a seat (hq ADR 0117) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the-uplink joins the closed set as a node seat delivering nothing. Its holder is the module for the machine's own network manager, and keeps that manager from contradicting the mesh — the resolver file left to resolv-conf, mesh0 left alone — without ever declaring a link. Held per machine, so a machine running two managers is refused at assignment rather than found by its resolver being rewritten. The count test moves to fifteen; one test holds the seat's shape. --- internal/catalogue/seats.go | 7 +++++++ internal/catalogue/seats_test.go | 24 ++++++++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) 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 + `}`) } From 51163b9f1479fcabc42e271dbe66ff0d7d2cd4b7 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 23:46:51 +0200 Subject: [PATCH 2/3] The mesh's names are written into the hosts file, not over it (hq 128) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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. --- internal/catalogue/facts.go | 96 +++++++++++++++++++++++--------- internal/catalogue/facts_test.go | 57 ++++++++++++++++++- internal/overlay/names.go | 7 ++- 3 files changed, 128 insertions(+), 32 deletions(-) diff --git a/internal/catalogue/facts.go b/internal/catalogue/facts.go index 2fdd510..99752c9 100644 --- a/internal/catalogue/facts.go +++ b/internal/catalogue/facts.go @@ -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 ` and `# END mesh ` 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") diff --git a/internal/catalogue/facts_test.go b/internal/catalogue/facts_test.go index 1f30b69..577b309 100644 --- a/internal/catalogue/facts_test.go +++ b/internal/catalogue/facts_test.go @@ -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) + } } } 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 From 63ba2d178f872d029965f3885bad60d97aaeb30e Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 00:05:41 +0200 Subject: [PATCH 3/3] review: hold the hosts region through composition, and say so in plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A declaration-level test composes the shipped networking module with a resolver and asserts /etc/hosts arrives as mesh-wireguard.fact-node-names with into: block and region-only content, that the resolver's restart-on still names it, and that a resource's at passes through untouched — a composition step dropping into would otherwise go unnoticed. plan --show marks files written into, so a region is not read as the whole file. The rollout order is spelled out: every node's host, the controller's own included, must be block-aware before this controller ships (hq 128). --- cmd/mesh-controller/plan.go | 16 +++- cmd/mesh-controller/plan_test.go | 14 ++++ internal/catalogue/facts.go | 12 ++- internal/catalogue/hosts_region_test.go | 106 ++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 internal/catalogue/hosts_region_test.go 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 +}