diff --git a/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index 4964f80..d03743b 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -341,7 +341,7 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world // trust boundary the moment both ends are containers**, and treating it as one gave the // commonest arrangement of all — a service and its database on one node — the weakest // handling, silently. - if satisfied[want] && !isModule(catalogue, want) { + if satisfied[want] && !isModule(catalogue, want) && !answeredElsewhere(want, node, world, brokered) { here := func(name string) bool { return chosen[name] || assignedHere[name] } local := providersHere(catalogue, here, want) // Which of them it matters to choose between. A plain capability — a shell, a display @@ -1134,3 +1134,28 @@ func machineReachRemedy(catalogue map[string]Manifest, want, node string) string } return fmt.Sprintf("assign one to %s: %s", node, strings.Join(named, "; ")) } + +// answeredElsewhere says that a mesh-wide provision this machine could answer itself is answered by +// another machine all the same: one this machine was pinned to, or the holder of the mesh seat that +// delivers it (novox/hq ADR 0110, ADR 0194). +// +// **A provider on the machine was taken before either was asked.** The branch for a provision +// answered here bound the consumer to the local provider and consulted a pin only between two local +// ones, and the seat's holder never; both were read only for a provider on another machine. So when +// every machine ran a provider of `wildcard-resolution` — each machine's own resolver, still assigned +// while the mesh moved to one — every machine bound its resolver configuration to itself, with the +// mesh's one resolver recorded, held and pinned (novox/hq issue 258). The seat is the mesh's choice of +// who answers, made once; a provider that merely runs here does not overrule it, and neither does it +// overrule a pin somebody set on this machine. +// +// Not in the first pass, which asks only what this machine offers and has no providers to read. +func answeredElsewhere(want string, node Node, world World, brokered map[string]bool) bool { + if world.Unchecked || !brokered[want] { + return false + } + if c, pinned := world.Pinned[want]; pinned { + return c.Node != node.Name + } + holder, held := HolderAmong(want, world.Offered[want], world.Held) + return held && holder.Node != node.Name +} diff --git a/internal/catalogue/resolver_manifests_test.go b/internal/catalogue/resolver_manifests_test.go index 0f5474a..d47431c 100644 --- a/internal/catalogue/resolver_manifests_test.go +++ b/internal/catalogue/resolver_manifests_test.go @@ -15,7 +15,8 @@ import ( // same arrangement, and to the two things a resolver here must never do — read resolv.conf for // its upstreams, or take an address systemd-resolved holds. -// resolverShelf is the three resolver modules beside something that answers `mesh-addressing`. +// resolverShelf is the three resolver modules and the container runtime beside something that +// answers `mesh-addressing`. // The networking module that really does is composed in the controller and cannot be imported // here, so a stand-in offers the same word; what is under test is the manifests, not the network. func resolverShelf(t *testing.T) map[string]Manifest { @@ -23,7 +24,7 @@ func resolverShelf(t *testing.T) map[string]Manifest { shelf := map[string]Manifest{ "net": {Module: "net", Version: "1", Provides: []Offer{{Name: "mesh-addressing"}}}, } - for _, name := range []string{"dnsmasq", "resolv-conf", "resolved-split-dns"} { + for _, name := range []string{"dnsmasq", "resolv-conf", "resolved-split-dns", "docker"} { shelf[name] = catalogueManifest(t, name) } return shelf @@ -117,8 +118,9 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T // that file, the machine pointed at the resolver by address, and the runtime given no resolver of // its own but kept running across a restart (ADR 0196). func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) { - got, err := Resolve(resolverShelf(t), []string{"dnsmasq", "resolv-conf"}, - Node{Name: "anchor", At: "anchor.internal"}, World{}) + got, err := Resolve(resolverShelf(t), []string{"dnsmasq", "resolv-conf", "docker"}, + Node{Name: "anchor", At: "anchor.internal", Capabilities: map[string]bool{ + "package-manager": true, "service-manager": true, "privileged": true}}, World{}) if err != nil { t.Fatal(err) } @@ -167,13 +169,19 @@ func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) { t.Errorf("the resolver was not told to forward the zone to its answerer:\n%s", z) } - // The runtime's own file, written into (novox/hq ADR 0102) with one key, by what decides how the - // machine resolves: a restart keeps every container running. No `dns` — a container copies its - // machine's resolvers (ADR 0196), and the mesh's resolver is not written into the runtime twice. + // The runtime's own file, written into (novox/hq ADR 0102) with one key, by the runtime's own + // module — a module does not write another software's configuration (issue 190): a restart keeps + // every container running. No `dns` — a container copies its machine's resolvers (ADR 0196), and + // neither the resolver nor what decides how the machine resolves writes the runtime's file. if ids["dnsmasq.runtime-dns"] != nil { t.Errorf("the resolver still writes the runtime's dns: %v", ids["dnsmasq.runtime-dns"]) } - runtime := ids["resolv-conf.runtime-config"] + for id := range ids { + if strings.HasPrefix(id, "resolv-conf.runtime") { + t.Errorf("what the machine asks still writes the runtime's file: %s", id) + } + } + runtime := ids["docker.daemon"] if runtime == nil || runtime["path"] != "/etc/docker/daemon.json" || runtime["into"] != "json" { t.Fatalf("live-restore is not written into the runtime's file: %v", runtime) } @@ -195,7 +203,7 @@ func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) { t.Errorf("the runtime is ordered restarted, which stops every container (ADR 0102): %v", r) } for _, on := range asStrings(r["reload-on"]) { - if on == "resolv-conf.runtime-config" { + if on == "docker.daemon" { reloaded = true } } diff --git a/internal/catalogue/seat_answers_before_local_test.go b/internal/catalogue/seat_answers_before_local_test.go new file mode 100644 index 0000000..6aef0a9 --- /dev/null +++ b/internal/catalogue/seat_answers_before_local_test.go @@ -0,0 +1,67 @@ +package catalogue + +import "testing" + +// A mesh-wide provision whose seat is held on another machine is answered by that holder, even on a +// machine that runs a provider of its own (novox/hq issue 258). Every machine ran its own resolver +// while the mesh moved to one; each bound its resolver configuration to itself, with the mesh's +// resolver held elsewhere and pinned. +func resolverMesh() map[string]Manifest { + return map[string]Manifest{ + "resolver": {Module: "resolver", Version: "1", + Provides: []Offer{{Name: "wildcard-resolution", Scope: ScopeMesh}}, + Claims: []Claim{{Name: "mesh-dns-resolver", Scope: ScopeMesh}}}, + "asker": {Module: "asker", Version: "1", Requires: []string{"wildcard-resolution"}}, + } +} + +func resolverWorld(held string, pin *Chosen) World { + w := World{ + Offered: map[string][]Provider{"wildcard-resolution": { + {Node: "anchor", At: "anchor.internal", Module: "resolver"}, + {Node: "laptop", At: "laptop.internal", Module: "resolver"}, + }}, + } + // Held is who holds it across the mesh; Holdings is the same holder on record, which lets this + // machine's own claimant stand beside it (ADR 0131). + w.Held = []Held{{Claim: "mesh-dns-resolver", Scope: ScopeMesh, Node: held, Module: "resolver"}} + w.Holdings = w.Held + if pin != nil { + w.Pinned = map[string]Chosen{"wildcard-resolution": *pin} + } + return w +} + +func answeredFrom(t *testing.T, world World) string { + t.Helper() + laptop := Node{Name: "laptop", At: "laptop.internal"} + got, err := Resolve(resolverMesh(), []string{"resolver", "asker"}, laptop, world) + if err != nil { + t.Fatal(err) + } + for _, n := range got.Needs { + if n.Name == "wildcard-resolution" { + return n.From + } + } + t.Fatalf("no binding for wildcard-resolution: %+v", got.Needs) + return "" +} + +func TestTheSeatsHolderElsewhereAnswersBeforeThisMachinesOwnProvider(t *testing.T) { + if from := answeredFrom(t, resolverWorld("anchor", nil)); from != "anchor" { + t.Fatalf("bound to %s; the seat is held on anchor", from) + } +} + +func TestAPinElsewhereAnswersBeforeThisMachinesOwnProvider(t *testing.T) { + if from := answeredFrom(t, resolverWorld("laptop", &Chosen{Node: "anchor", Module: "resolver"})); from != "anchor" { + t.Fatalf("bound to %s; this machine was pinned to anchor", from) + } +} + +func TestTheHolderOnThisMachineStillAnswersHere(t *testing.T) { + if from := answeredFrom(t, resolverWorld("laptop", nil)); from != "laptop" { + t.Fatalf("bound to %s; the seat is held here", from) + } +}