diff --git a/cmd/mesh-controller/gate.go b/cmd/mesh-controller/gate.go index 235a53b7..36eefc4d 100644 --- a/cmd/mesh-controller/gate.go +++ b/cmd/mesh-controller/gate.go @@ -121,7 +121,7 @@ type gateFacts struct { health map[string]inventory.NodeHealth healthErr error // heldOn is, per "@", the provider its findings are held under (ADR 0240 rule 5). - heldOn map[string]string + heldOn map[string]heldReading // groupsAdded is, per module, whether the move judged puts an account in a group its previous build did // not (issue 318 review): the only move whose wait for a new login is excused. groupsAdded map[string]bool @@ -137,6 +137,29 @@ type gateFacts struct { commits map[string]string } +// heldReading is the provider a module's findings are held under, as " on ", and, when that +// provider only waits for the operator for the part the module needs, its wait in one sentence (novox/hq issue +// 405): the module's gate then reads as a wait for a person (ADR 0254), a pass carrying the wait, as ADR 0283 +// decision 4 reads the waiting provider itself. A walk never waits on the operator's secret, there or here. +type heldReading struct { + on, waits string +} + +// heldReadings is, per "@" in every machine's newest statement, what its findings are held under. +func heldReadings(hold *holding) map[string]heldReading { + out := map[string]heldReading{} + for machine := range hold.healths { + for module, by := range hold.heldModules(machine) { + reading := heldReading{on: by.provider.Module + " on " + by.provider.Node} + if len(by.waits) > 0 { + reading.waits = operatorWaitSaid(by.provider.Module, by.provider.Node, by.waits) + } + out[module+"@"+machine] = reading + } + } + return out +} + // reportedOn says a machine's last report is on what the gate sent it (novox/hq issue 352): on that // declaration, or one it was sent after it — or, for a gate kept before sends were kept on it, on the // declaration last sent. On 2026-10-09 a release's gate read the control node's report against a newer @@ -202,12 +225,7 @@ var gatherGateFacts = func(ctx context.Context, open *stores, component string) // Whose findings wait on an unhealthy provider (ADR 0240 rule 5): their gates wait, not fail. if f.healthErr == nil && f.openErr == nil { if hold, err := readHolding(ctx, inv, f.open); err == nil { - f.heldOn = map[string]string{} - for machine := range f.health { - for module, p := range hold.heldModules(machine) { - f.heldOn[module+"@"+machine] = p.Module + " on " + p.Node - } - } + f.heldOn = heldReadings(hold) } } if theLease != nil { diff --git a/cmd/mesh-controller/module_health.go b/cmd/mesh-controller/module_health.go index 3ca785dd..3d273781 100644 --- a/cmd/mesh-controller/module_health.go +++ b/cmd/mesh-controller/module_health.go @@ -246,6 +246,12 @@ func judgeModuleHealth(ctx context.Context, inv *inventory.Inventory, k *conditi continue } sayWaitingOn(&o, hold.waitersOn(catalogue.Chosen{Node: node, Module: m})) + // A provider that waits for the operator for a part this finding cannot be matched to does not hold it + // (novox/hq issue 405): raised as its own, and saying the provider waits, so neither is hidden. + if p, waiting := hold.waitingUncovered(node, m, unhealthy[m]); waiting { + o.Said += fmt.Sprintf("; its provider %s on %s waits for the operator, for a part not known to be "+ + "the one %s needs, so this is said as its own", p.Module, p.Node, m) + } } seen[o.Key()] = true became[m] = kindModuleUnhealthy @@ -276,7 +282,7 @@ func judgeModuleHealth(ctx context.Context, inv *inventory.Inventory, k *conditi why = fmt.Sprintf("%s says %s no longer waits for the operator", node, module) } if on, held := heldOn[key]; held { - why = fmt.Sprintf("what %s finds on %s waits on %s, which is not healthy: held under its condition", module, node, on) + why = fmt.Sprintf("what %s finds on %s waits on %s, which is unhealthy or waits for the operator: held under its condition", module, node, on) } // **A condition that became the other kind** is not "working again" (issue 318 review): its clearing // line says what it became. @@ -452,12 +458,15 @@ func waitingAccounts(module string, rs []inventory.ResourceHealth) []string { } // sayWaitingOn adds to a module's condition the consumers held under it (to-be 48 §6): urgent while anyone -// waits on it, whether it is not working or waits for a new login. +// waits on it, whether it is not working or waits for a new login. **A wait for the operator's secret or setting +// stays a warning** (ADR 0283 decision 5, novox/hq issue 405): who waits on it is listed, and nothing escalates it. func sayWaitingOn(o *conditions.Observation, waiters []string) { if len(waiters) == 0 { return } - o.Severity = conditions.Urgent + if o.Kind != kindNeedsOperator { + o.Severity = conditions.Urgent + } o.Said += "; " + waitingWords(waiters) o.Summary += fmt.Sprintf("; %d consumer(s) wait on it", len(waiters)) o.Explanation += fmt.Sprintf(" %d module(s) that depend on it wait for it.", len(waiters)) @@ -615,7 +624,7 @@ func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (hea if waits && !f.groupsAdded[module] { waits = false } - var found []string + var found, onWaiting []string var forOperator []inventory.Wait for _, r := range resources { if r.Module != module { @@ -644,8 +653,16 @@ func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (hea return healthNotYet, fmt.Sprintf("its %s %s on %s is still starting", r.Kind, r.Resource, machine) case link.StateUnhealthy: if on, held := f.heldOn[module+"@"+machine]; held { - return healthWaiting, fmt.Sprintf("its %s %s on %s waits on %s, which is not healthy", r.Kind, - r.Resource, machine, on) + // **Held under a provider that only waits for the operator** (novox/hq issue 405): a wait for a + // person, a pass carrying the wait (ADR 0254, ADR 0283 decision 4) — the walk never waits for the + // operator's secret, whichever module owes it. + if on.waits != "" { + onWaiting = append(onWaiting, fmt.Sprintf("its %s %s on %s waits on %s, which waits for you: %s", + r.Kind, r.Resource, machine, on.on, on.waits)) + continue + } + return healthWaiting, fmt.Sprintf("its %s %s on %s waits on %s, which is unhealthy", r.Kind, + r.Resource, machine, on.on) } return healthNotYet, fmt.Sprintf("its %s %s on %s %s", r.Kind, r.Resource, machine, reasonWords(r)) default: @@ -653,8 +670,8 @@ func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (hea reasonAfter(r.Reason)) } } - if waits || len(found) > 0 || len(forOperator) > 0 { - var said []string + if waits || len(found) > 0 || len(forOperator) > 0 || len(onWaiting) > 0 { + said := onWaiting if waits { said = append(said, wait) } diff --git a/cmd/mesh-controller/provider_hold.go b/cmd/mesh-controller/provider_hold.go index c9bd8c59..51651015 100644 --- a/cmd/mesh-controller/provider_hold.go +++ b/cmd/mesh-controller/provider_hold.go @@ -3,7 +3,6 @@ package main import ( "context" "fmt" - "slices" "sort" "strings" @@ -116,45 +115,137 @@ func (h *holding) lookUpProvider(machine, consumer, provision string) (catalogue return catalogue.Chosen{}, false } -// unhealthy says a provider is not healthy on the record: its unhealthy or needs-operator condition is open, or its -// machine's newest statement says a resource of it is unhealthy or waiting. **A provider waiting for the operator -// holds its consumers too** (novox/hq issue 405): its secret or setting not given, what its consumers find is no more -// theirs than when it is broken, and ADR 0240 rule 5 says it once, at the provider — here under its needs-operator -// condition, which names the act. -func (h *holding) unhealthy(p catalogue.Chosen) bool { - keys := []string{moduleUnhealthyKey(p.Module, p.Node), needsOperatorKey(p.Module, p.Node)} +// providerState is how a provider stands on the record: unhealthy when its unhealthy condition is open or its +// machine's newest statement says a resource of it is unhealthy; else waiting for the operator when that statement +// says a resource of it waits, with the waits it names, or its needs-operator condition is open (waits then +// unknown); else healthy. +func (h *holding) providerState(p catalogue.Chosen) (unhealthy, waiting bool, waits []inventory.Wait) { for _, c := range h.open { - if slices.Contains(keys, c.Key) { - return true + if c.Key == moduleUnhealthyKey(p.Module, p.Node) { + return true, false, nil } } for _, r := range h.healths[p.Node].Resources { - if r.Module == p.Module && (r.State == link.StateUnhealthy || r.State == link.StateWaiting) { - return true + if r.Module != p.Module { + continue + } + switch r.State { + case link.StateUnhealthy: + return true, false, nil + case link.StateWaiting: + waiting = true + waits = append(waits, r.Waits...) } } - return false + if !waiting { + for _, c := range h.open { + waiting = waiting || c.Key == needsOperatorKey(p.Module, p.Node) + } + } + return false, waiting, waits +} + +// manifestOf is a module's manifest from the catalogue, read once; false when it cannot be read. +func (h *holding) manifestOf(module string) (catalogue.Manifest, bool) { + if h.shelf == nil && h.inv != nil { + shelf, err := h.inv.Catalogue(h.ctx) + if err != nil { + return catalogue.Manifest{}, false + } + h.shelf = shelf + } + m, ok := h.shelf[module] + return m, ok +} + +// covering is the waits of a provider that cover a provision it gives (novox/hq issue 405): a module that waits +// says nothing else of it is wrong and names each part that waits (ADR 0283 decision 1), so only a consumer of +// the waiting part waits on it. A wait covers a provision when its part is that provision, or the secret it waits +// for is the provision's shared credential (ADR 0158). Nothing when no wait can be matched: a consumer failing +// then is not held, since holding it would hide a fault that may be its own. +func (h *holding) covering(p catalogue.Chosen, provision string, waits []inventory.Wait) []inventory.Wait { + credential := "" + if m, ok := h.manifestOf(p.Module); ok { + credential, _ = m.SharedCredentialOf(provision) + } + var out []inventory.Wait + for _, w := range waits { + if w.Part == provision || (w.Secret != "" && w.Secret == credential) { + out = append(out, w) + } + } + return out +} + +// heldUnderProvider is the provider a consumer's findings are held under, and — when that provider only waits for the +// operator, for the part the consumer needs — the waits that hold it. +type heldUnderProvider struct { + provider catalogue.Chosen + // waits is set when every finding held waits on a provider that only waits for the operator: the consumer's + // gate then reads as ADR 0254's waits for a person, a pass with the wait carried (ADR 0283 decision 4). + waits []inventory.Wait } // heldUnder is the provider a consumer's unhealthy resources wait on: when every one of them is a finding -// of a check naming a provision whose provider for this consumer is unhealthy on the record. False when any -// is the consumer's own. +// of a check naming a provision whose provider for this consumer is unhealthy on the record, or waits for the +// operator for the part that gives it (novox/hq issue 405). False when any is the consumer's own. func (h *holding) heldUnder(machine, module string, rs []inventory.ResourceHealth) (catalogue.Chosen, bool) { - var on catalogue.Chosen + by, held := h.heldWith(machine, module, rs) + return by.provider, held +} + +func (h *holding) heldWith(machine, module string, rs []inventory.ResourceHealth) (heldUnderProvider, bool) { + var on heldUnderProvider + onlyWaits := true for _, r := range rs { if r.State != link.StateUnhealthy { continue } if !heldFinding(r) { - return catalogue.Chosen{}, false + return heldUnderProvider{}, false } p, ok := h.providerFor(machine, module, r.Needs) - if !ok || (p.Node == machine && p.Module == module) || !h.unhealthy(p) { - return catalogue.Chosen{}, false + if !ok || (p.Node == machine && p.Module == module) { + return heldUnderProvider{}, false } - on = p + unhealthy, waiting, waits := h.providerState(p) + switch { + case unhealthy: + onlyWaits = false + case waiting: + covered := h.covering(p, r.Needs, waits) + if len(covered) == 0 { + return heldUnderProvider{}, false + } + on.waits = append(on.waits, covered...) + default: + return heldUnderProvider{}, false + } + on.provider = p } - return on, on.Module != "" + if !onlyWaits { + on.waits = nil + } + return on, on.provider.Module != "" +} + +// waitingUncovered is a provider of a consumer's failing finding that waits for the operator for a part the +// finding cannot be matched to (novox/hq issue 405): the consumer is raised on its own, and its condition says +// the provider waits, so neither is hidden. +func (h *holding) waitingUncovered(machine, module string, rs []inventory.ResourceHealth) (catalogue.Chosen, bool) { + for _, r := range rs { + if r.State != link.StateUnhealthy || !heldFinding(r) { + continue + } + p, ok := h.providerFor(machine, module, r.Needs) + if !ok || (p.Node == machine && p.Module == module) { + continue + } + if unhealthy, waiting, waits := h.providerState(p); !unhealthy && waiting && len(h.covering(p, r.Needs, waits)) == 0 { + return p, true + } + } + return catalogue.Chosen{}, false } // waitersOn is every consumer held under a provider, as " on ", sorted. @@ -178,8 +269,8 @@ func (h *holding) waitersOn(p catalogue.Chosen) []string { } // heldModules is, for one machine's statement, each module whose finding is held, with the provider. -func (h *holding) heldModules(machine string) map[string]catalogue.Chosen { - out := map[string]catalogue.Chosen{} +func (h *holding) heldModules(machine string) map[string]heldUnderProvider { + out := map[string]heldUnderProvider{} byModule := map[string][]inventory.ResourceHealth{} for _, r := range h.healths[machine].Resources { if r.Module != "" && r.State == link.StateUnhealthy { @@ -187,7 +278,7 @@ func (h *holding) heldModules(machine string) map[string]catalogue.Chosen { } } for module, rs := range byModule { - if on, held := h.heldUnder(machine, module, rs); held { + if on, held := h.heldWith(machine, module, rs); held { out[module] = on } } diff --git a/cmd/mesh-controller/waiting_provider_test.go b/cmd/mesh-controller/waiting_provider_test.go index c1554e28..30d0357b 100644 --- a/cmd/mesh-controller/waiting_provider_test.go +++ b/cmd/mesh-controller/waiting_provider_test.go @@ -20,23 +20,33 @@ import ( // began before it was, and its unit failed in that account's own service manager. // waitingDatabase is a provider whose only part not healthy waits for the operator's secret: a database's -// process stated waiting, as the node-engine states a tool check answering waits (ADR 0283 decision 2). +// process stated waiting, as the node-engine states a tool check answering waits (ADR 0283 decision 2). Its wait +// names the part that waits by the provision it gives. func waitingDatabase() inventory.ResourceHealth { - return inventory.ResourceHealth{Module: "db", Resource: "db.server", Kind: "process", Target: "db.service", - State: link.StateWaiting, Check: "tool", Reason: "the database waits for its licence", - Waits: []inventory.Wait{{Part: "the server", Secret: "licence", What: "the licence key of the database"}}} + return waitingDatabaseFor(inventory.Wait{Part: "postgres-database", Secret: "licence", + What: "the licence key of the database"}) } +func waitingDatabaseFor(waits ...inventory.Wait) inventory.ResourceHealth { + return inventory.ResourceHealth{Module: "db", Resource: "db.server", Kind: "process", Target: "db.service", + State: link.StateWaiting, Check: "tool", Reason: "the database waits for its licence", Waits: waits} +} + +// backupsWait is a wait of the same provider for another part than the one its consumers need. +var backupsWait = inventory.Wait{Part: "the nightly backups", Secret: "backup-key", What: "the key of the backups"} + // failingConsumer is a consumer whose check that needs the database fails. func failingConsumer(module string) inventory.ResourceHealth { return inventory.ResourceHealth{Module: module, Resource: module + ".web", Kind: "container", Target: module, State: link.StateUnhealthy, Reason: "http /health on web: answered 500", Check: "http", Needs: "postgres-database"} } -// holdingOf is one reading of the record without a store: the newest statements, the open conditions, and each -// consumer's provider already looked up, as providerFor memoises it. +// holdingOf is one reading of the record without a store: the newest statements, the open conditions, the +// catalogue, and each consumer's provider already looked up, as providerFor memoises it. func holdingOf(open []conditions.Condition, healths map[string]inventory.NodeHealth, bound map[[3]string]catalogue.Chosen) *holding { - h := &holding{ctx: context.Background(), healths: healths, open: open, providers: map[string]providerLookup{}} + h := &holding{ctx: context.Background(), healths: healths, open: open, providers: map[string]providerLookup{}, + shelf: map[string]catalogue.Manifest{"db": {Module: "db", Version: "1", + Provides: []catalogue.Offer{{Name: "postgres-database", Scope: catalogue.ScopeMesh}}}}} for k, p := range bound { h.providers[k[0]+"\x00"+k[1]+"\x00"+k[2]] = providerLookup{p, true} } @@ -45,58 +55,114 @@ func holdingOf(open []conditions.Condition, healths map[string]inventory.NodeHea var theDatabase = catalogue.Chosen{Node: "anchor", Module: "db"} -// (1) A provider whose only part not healthy waits for the operator holds the findings of its consumers under its -// needs-operator condition, as ADR 0240 rule 5 holds them under an unhealthy one. -func TestAProviderWaitingForTheOperatorHoldsItsConsumers(t *testing.T) { - healths := map[string]inventory.NodeHealth{ - "anchor": {Node: "anchor", Resources: []inventory.ResourceHealth{waitingDatabase()}}, +var shopOnTheDatabase = map[[3]string]catalogue.Chosen{{"laptop", "shop", "postgres-database"}: theDatabase} + +func shopFailingBeside(provider ...inventory.ResourceHealth) map[string]inventory.NodeHealth { + return map[string]inventory.NodeHealth{ + "anchor": {Node: "anchor", Resources: provider}, "laptop": {Node: "laptop", Resources: []inventory.ResourceHealth{failingConsumer("shop")}}, } - bound := map[[3]string]catalogue.Chosen{{"laptop", "shop", "postgres-database"}: theDatabase} - hold := holdingOf(nil, healths, bound) - p, held := hold.heldUnder("laptop", "shop", []inventory.ResourceHealth{failingConsumer("shop")}) - if !held || p != theDatabase { - t.Fatalf("a consumer of a provider waiting for the operator is not held under it: %v %v", p, held) - } - if got := hold.waitersOn(theDatabase); len(got) != 1 || got[0] != "shop on laptop" { - t.Fatalf("the waiting provider lists %v as waiting on it; want shop on laptop", got) - } - // Its needs-operator condition open is enough, as its unhealthy one is. - open := []conditions.Condition{{Key: needsOperatorKey("db", "anchor"), Kind: kindNeedsOperator}} - healths["anchor"] = inventory.NodeHealth{Node: "anchor"} - if _, held := holdingOf(open, healths, bound).heldUnder("laptop", "shop", - []inventory.ResourceHealth{failingConsumer("shop")}); !held { - t.Fatal("a consumer is not held under its provider's open needs-operator condition") - } - // A provider healthy holds nothing: what the consumer finds is its own. - healthy := waitingDatabase() - healthy.State, healthy.Waits = link.StateHealthy, nil - healths["anchor"] = inventory.NodeHealth{Node: "anchor", Resources: []inventory.ResourceHealth{healthy}} - if _, held := holdingOf(nil, healths, bound).heldUnder("laptop", "shop", - []inventory.ResourceHealth{failingConsumer("shop")}); held { - t.Fatal("a consumer of a healthy provider is held") - } } -// (1) The consumer raises nothing of its own, and the provider's needs-operator condition says who waits on it. -func TestAWaitingProvidersConditionListsWhoWaitsOnIt(t *testing.T) { - k, _ := withConditionsInMemory(t) - ctx := t.Context() - healths := map[string]inventory.NodeHealth{ - "anchor": {Node: "anchor", Resources: []inventory.ResourceHealth{waitingDatabase()}}, - "laptop": {Node: "laptop", Resources: []inventory.ResourceHealth{failingConsumer("shop")}}, +// (1) A provider whose only part not healthy waits for the operator holds the findings of the consumers of the +// waiting part under it (ADR 0240 rule 5); a consumer of another part, or one whose part cannot be matched, is +// its own (ADR 0283 decision 1: a module that waits says nothing else of it is wrong). +func TestAProviderWaitingForTheOperatorHoldsOnlyTheConsumersOfTheWaitingPart(t *testing.T) { + shop := []inventory.ResourceHealth{failingConsumer("shop")} + unhealthyDB := waitingDatabase() + unhealthyDB.State, unhealthyDB.Waits, unhealthyDB.Reason = link.StateUnhealthy, nil, "its tool check: refused" + healthyDB := waitingDatabase() + healthyDB.State, healthyDB.Waits = link.StateHealthy, nil + byCredential := holdingOf(nil, shopFailingBeside(waitingDatabaseFor(inventory.Wait{Part: "the server", + Secret: "licence", What: "the licence key"})), shopOnTheDatabase) + byCredential.shelf["db"] = catalogue.Manifest{Module: "db", Version: "1", Provides: []catalogue.Offer{{ + Name: "postgres-database", Credential: &catalogue.OfferCredential{Own: "licence"}}}} + for _, c := range []struct { + name string + hold *holding + held bool + onlyWaits bool + uncovered bool + }{ + {"the waiting part is the provision", holdingOf(nil, shopFailingBeside(waitingDatabase()), shopOnTheDatabase), true, true, false}, + {"the wait is for the provision's shared credential", byCredential, true, true, false}, + {"the wait is for another part", holdingOf(nil, shopFailingBeside(waitingDatabaseFor(backupsWait)), shopOnTheDatabase), false, false, true}, + {"only the needs-operator condition, its parts not said", holdingOf( + []conditions.Condition{{Key: needsOperatorKey("db", "anchor"), Kind: kindNeedsOperator}}, + shopFailingBeside(), shopOnTheDatabase), false, false, true}, + {"an unhealthy provider holds as before", holdingOf(nil, shopFailingBeside(unhealthyDB), shopOnTheDatabase), true, false, false}, + {"a healthy provider holds nothing", holdingOf(nil, shopFailingBeside(healthyDB), shopOnTheDatabase), false, false, false}, + } { + by, held := c.hold.heldWith("laptop", "shop", shop) + if held != c.held || (held && by.provider != theDatabase) || (len(by.waits) > 0) != c.onlyWaits { + t.Errorf("%s: held %v under %v with waits %v; want held %v, only waits %v", c.name, held, by.provider, + by.waits, c.held, c.onlyWaits) + } + if _, uncovered := c.hold.waitingUncovered("laptop", "shop", shop); uncovered != c.uncovered { + t.Errorf("%s: said as a provider waiting for another part %v; want %v", c.name, uncovered, c.uncovered) + } } - bound := map[[3]string]catalogue.Chosen{{"laptop", "shop", "postgres-database"}: theDatabase} +} + +// (1) The consumer's first-node gate under a provider that only waits for the operator passes as a wait for a +// person (ADR 0254), carrying the wait (ADR 0283 decision 4); under an unhealthy provider it waits, as before. +func TestAConsumersGateUnderAWaitingProviderPassesCarryingTheWait(t *testing.T) { + now := time.Now() + since := now.Add(-time.Minute) + for _, c := range []struct { + name string + provider inventory.ResourceHealth + want health + says []string + }{ + {"a provider that only waits", waitingDatabase(), healthPerson, + []string{"waits on db on anchor, which waits for you", "the licence key of the database", "nox secret ask anchor db licence"}}, + {"an unhealthy provider", func() inventory.ResourceHealth { + r := waitingDatabase() + r.State, r.Waits, r.Reason = link.StateUnhealthy, nil, "its tool check: refused" + return r + }(), healthWaiting, []string{"waits on db on anchor, which is unhealthy"}}, + } { + healths := shopFailingBeside(c.provider) + for m, h := range healths { + h.HeardAt = now + healths[m] = h + } + f := gateFacts{now: now, health: healths, heldOn: heldReadings(holdingOf(nil, healths, shopOnTheDatabase))} + h, why := moduleHealthWord("shop", "laptop", since, f) + if h != c.want { + t.Errorf("%s: the consumer's gate reads %v %q; want %v", c.name, h, why, c.want) + continue + } + for _, s := range c.says { + if !strings.Contains(why, s) { + t.Errorf("%s: the gate's reading %q does not say %q", c.name, why, s) + } + } + } +} + +// judgeBoth judges the provider's statement and then the consumer's, two looks each, over a record that changes +// as the statements do. +func judgeBoth(t *testing.T, k *conditions.Keeper, provider []inventory.ResourceHealth, + byProvider, byConsumer map[string]inventory.NodeHealth) []conditions.Condition { + t.Helper() + ctx := t.Context() was := readHoldingFor t.Cleanup(func() { readHoldingFor = was }) + healths := byProvider readHoldingFor = func(_ context.Context, _ *inventory.Inventory, open []conditions.Condition) (*holding, error) { - return holdingOf(open, healths, bound), nil + return holdingOf(open, healths, shopOnTheDatabase), nil } for look := 1; look <= 2; look++ { - if err := judgeModuleHealth(ctx, nil, k, "anchor", map[string][]inventory.ResourceHealth{"db": {waitingDatabase()}}, + healths = byProvider + if err := judgeModuleHealth(ctx, nil, k, "anchor", map[string][]inventory.ResourceHealth{"db": provider}, map[string]int{"db": look}, time.Now()); err != nil { t.Fatal(err) } + } + for look := 1; look <= 2; look++ { + healths = byConsumer if err := judgeModuleHealth(ctx, nil, k, "laptop", map[string][]inventory.ResourceHealth{"shop": {failingConsumer("shop")}}, map[string]int{"shop": look}, time.Now()); err != nil { t.Fatal(err) @@ -106,27 +172,55 @@ func TestAWaitingProvidersConditionListsWhoWaitsOnIt(t *testing.T) { if err != nil { t.Fatal(err) } - var keys []string - var provider *conditions.Condition + return open +} + +func conditionOf(open []conditions.Condition, key string) *conditions.Condition { for i, c := range open { - keys = append(keys, c.Key) - if c.Key == needsOperatorKey("db", "anchor") { - provider = &open[i] + if c.Key == key { + return &open[i] } } + return nil +} + +// (1) The consumer raises nothing of its own; the provider's needs-operator condition lists who waits on it and +// stays a warning (ADR 0283 decision 5: never escalated). The consumer's statement arrives after the provider's, +// so it is the consumer's judging (sayWaiters) that lists it at the provider — no store involved. +func TestAWaitingProvidersConditionListsWhoWaitsOnItAndStaysAWarning(t *testing.T) { + k, _ := withConditionsInMemory(t) + open := judgeBoth(t, k, []inventory.ResourceHealth{waitingDatabase()}, + map[string]inventory.NodeHealth{"anchor": {Node: "anchor", Resources: []inventory.ResourceHealth{waitingDatabase()}}}, shopFailingBeside(waitingDatabase())) + provider := conditionOf(open, needsOperatorKey("db", "anchor")) if len(open) != 1 || provider == nil { - t.Fatalf("a provider waiting for the operator and a consumer failing on it raised %v; want the provider's "+ - "needs-operator alone", keys) + t.Fatalf("a provider waiting for the operator and a consumer of its waiting part raised %v; want the "+ + "provider's needs-operator alone", openKeysOf(open)) } if said := provider.Evidence[0].Said; !strings.Contains(said, "shop on laptop") { t.Fatalf("the provider's needs-operator does not list shop on laptop as waiting on it: %s", said) } - // Its words still name the act: it is the operator's, whoever waits on it. + if provider.Severity != conditions.Warning { + t.Fatalf("the provider's needs-operator became %s with a consumer waiting; it stays a warning", provider.Severity) + } if provider.Resolver != conditions.ResolverOperator || !strings.Contains(provider.Needs, "desk prompt") { t.Fatalf("the provider's condition: %+v", provider) } } +// (1) A consumer of another part than the one waiting is raised on its own, and says its provider waits. +func TestAConsumerOfAnotherPartIsRaisedOnItsOwn(t *testing.T) { + k, _ := withConditionsInMemory(t) + backups := waitingDatabaseFor(backupsWait) + open := judgeBoth(t, k, []inventory.ResourceHealth{backups}, shopFailingBeside(backups), shopFailingBeside(backups)) + consumer := conditionOf(open, moduleUnhealthyKey("shop", "laptop")) + if consumer == nil || conditionOf(open, needsOperatorKey("db", "anchor")) == nil { + t.Fatalf("raised %v; want shop's own unhealthy beside db's needs-operator", openKeysOf(open)) + } + if said := consumer.Evidence[0].Said; !strings.Contains(said, "its provider db on anchor waits for the operator") { + t.Fatalf("the consumer's condition does not say its provider waits: %s", said) + } +} + // relogin and userUnit are person_wait_test.go's; mounts is said here with the same account and unit beside its // wait for the password. func reloginBesideAWait() []inventory.ResourceHealth {