Hold consumers under a provider waiting for the operator, and say a wait beside another condition (issue 405)

A provider whose only part not healthy waits for the operator's secret or
setting let its consumers raise their own unhealthy conditions, and a
wait beside a relogin, a directory used as found or a fault was not said
until the other cleared.
This commit is contained in:
2026-10-11 02:51:11 +02:00
parent 5bddb16514
commit 1dc9961c43
3 changed files with 327 additions and 27 deletions
+75 -22
View File
@@ -153,11 +153,9 @@ func judgeModuleHealth(ctx context.Context, inv *inventory.Inventory, k *conditi
standing[c.Key] = c
}
}
var hold *holding
if inv != nil {
if hold, err = readHolding(ctx, inv, open); err != nil {
return err
}
hold, err := readHoldingFor(ctx, inv, open)
if err != nil {
return err
}
var problems []string
modules := make([]string, 0, len(unhealthy))
@@ -176,6 +174,11 @@ func judgeModuleHealth(ctx context.Context, inv *inventory.Inventory, k *conditi
// the operator's, never urgent, its words naming the act.
if waits, waiting := operatorWait(m, unhealthy[m]); waiting {
o := needsOperatorObservation(m, node, waits, unhealthy[m])
// **A provider waiting for the operator says who waits on it** (novox/hq issue 405), as one waiting for a
// login does: its consumers are held under it.
if hold != nil {
sayWaitingOn(&o, hold.waitersOn(catalogue.Chosen{Node: node, Module: m}))
}
seen[o.Key()] = true
became[m] = kindNeedsOperator
if _, isOpen := standing[o.Key()]; streaks[m] < moduleUnhealthyAfter && !isOpen {
@@ -186,6 +189,20 @@ func judgeModuleHealth(ctx context.Context, inv *inventory.Inventory, k *conditi
}
continue
}
// **A wait for the operator beside anything else is said too** (novox/hq issue 405): a module with a checked
// wait beside a new login owed, a directory used as found or a fault of its own is said as that, and the
// operator's secret or setting it waits for was not mentioned until the other cleared. Its needs-operator
// condition stands beside the other, on the same looks; what follows judges the rest without the wait.
if waits, rest := besideAWait(m, unhealthy[m]); len(waits) > 0 {
o := needsOperatorObservation(m, node, waits, waitingOf(m, unhealthy[m]))
seen[o.Key()] = true
if _, isOpen := standing[o.Key()]; streaks[m] >= moduleUnhealthyAfter || isOpen {
if _, err := k.Observe(ctx, o); err != nil {
problems = append(problems, err.Error())
}
}
unhealthy[m] = rest
}
// **A directory used as found is said as that** (novox/hq issue 339): the operator's to hand over at the
// machine, never urgent — nothing is broken by the wait that a person was not told of — and its own kind,
// so the gate never reads it as a fault of the build that happened to be sent beside it.
@@ -259,7 +276,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 unhealthy: held under its condition", module, node, on)
why = fmt.Sprintf("what %s finds on %s waits on %s, which is not healthy: 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.
@@ -298,36 +315,72 @@ func judgeModuleHealth(ctx context.Context, inv *inventory.Inventory, k *conditi
// sayWaiters observes a provider's open condition again, with who waits on it, from its machine's newest
// statement. Nothing when its condition is not open: it is raised by its own statements, on its own looks.
func sayWaiters(ctx context.Context, k *conditions.Keeper, hold *holding, p catalogue.Chosen, now time.Time) error {
var raisedAt *conditions.Condition
for i, c := range hold.open {
if c.Key == moduleUnhealthyKey(p.Module, p.Node) || c.Key == reloginKey(p.Module, p.Node) {
raisedAt = &hold.open[i]
}
}
if raisedAt == nil {
return nil
}
var rs []inventory.ResourceHealth
for _, r := range hold.healths[p.Node].Resources {
if r.Module == p.Module && r.State == link.StateUnhealthy {
if r.Module == p.Module && (r.State == link.StateUnhealthy || r.State == link.StateWaiting) {
rs = append(rs, r)
}
}
if len(rs) == 0 {
return nil
}
o := moduleUnhealthyObservation(p.Module, p.Node, rs)
if said, waits := personWait(p.Module, p.Node, rs); waits {
o = reloginObservation(p.Module, p.Node, said, operatorOn(ctx, hold.inv, p.Node), rs)
// The condition its own statement says it under: needs-operator while it only waits for the operator (novox/hq
// issue 405), else that for the rest of it, a wait beside it said on its own.
var o conditions.Observation
if waits, waiting := operatorWait(p.Module, rs); waiting {
o = needsOperatorObservation(p.Module, p.Node, waits, rs)
} else {
_, rest := besideAWait(p.Module, rs)
o = moduleUnhealthyObservation(p.Module, p.Node, rest)
if said, waits := personWait(p.Module, p.Node, rest); waits {
o = reloginObservation(p.Module, p.Node, said, operatorOn(ctx, hold.inv, p.Node), rest)
}
}
if o.Key() != raisedAt.Key {
return nil // its own statement says it next
raised := false
for _, c := range hold.open {
raised = raised || c.Key == o.Key()
}
if !raised {
return nil // not open yet, or open as another kind: its own statement says it next
}
sayWaitingOn(&o, hold.waitersOn(p))
_, err := k.Observe(ctx, o)
return err
}
// besideAWait is, for a module with something not healthy beside a part waiting for the operator, the waits (checked
// already) and the rest without the waiting parts (novox/hq issue 405); no waits when nothing waits, or when the
// module only waits (operatorWait says that whole). Pure.
func besideAWait(module string, rs []inventory.ResourceHealth) ([]inventory.Wait, []inventory.ResourceHealth) {
if _, only := operatorWait(module, rs); only {
return nil, rs
}
var waits []inventory.Wait
var rest []inventory.ResourceHealth
for _, r := range rs {
if r.Module == module && r.State == link.StateWaiting {
waits = append(waits, r.Waits...)
continue
}
rest = append(rest, r)
}
if len(waits) == 0 {
return nil, rs
}
return waits, rest
}
// waitingOf is a module's waiting resources: the evidence of its wait.
func waitingOf(module string, rs []inventory.ResourceHealth) []inventory.ResourceHealth {
var out []inventory.ResourceHealth
for _, r := range rs {
if r.Module == module && r.State == link.StateWaiting {
out = append(out, r)
}
}
return out
}
// reloginKey is a module's relogin-needed condition on a machine.
func reloginKey(module, node string) string {
return conditions.Key(conditions.ScopeModule, module+"."+node, kindReloginNeeded)
@@ -591,7 +644,7 @@ 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 unhealthy", r.Kind,
return healthWaiting, fmt.Sprintf("its %s %s on %s waits on %s, which is not healthy", r.Kind,
r.Resource, machine, on)
}
return healthNotYet, fmt.Sprintf("its %s %s on %s %s", r.Kind, r.Resource, machine, reasonWords(r))
+18 -5
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"fmt"
"slices"
"sort"
"strings"
@@ -53,6 +54,15 @@ func readHolding(ctx context.Context, inv *inventory.Inventory, open []condition
return &holding{ctx: ctx, inv: inv, healths: healths, open: open, providers: map[string]providerLookup{}}, nil
}
// readHoldingFor is how a judging reads its holding: nothing without a store. A variable so a test can hand a
// judging the record it holds under (novox/hq issue 405).
var readHoldingFor = func(ctx context.Context, inv *inventory.Inventory, open []conditions.Condition) (*holding, error) {
if inv == nil {
return nil, nil
}
return readHolding(ctx, inv, open)
}
// heldFinding says a resource's state is a finding of its declared check that names a provision: what
// may be held. Down and restarting are liveness, the resource's own.
func heldFinding(r inventory.ResourceHealth) bool {
@@ -106,17 +116,20 @@ func (h *holding) lookUpProvider(machine, consumer, provision string) (catalogue
return catalogue.Chosen{}, false
}
// unhealthy says a provider is unhealthy on the record: its condition is open, or its machine's newest
// statement says a resource of it is unhealthy.
// 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 {
key := moduleUnhealthyKey(p.Module, p.Node)
keys := []string{moduleUnhealthyKey(p.Module, p.Node), needsOperatorKey(p.Module, p.Node)}
for _, c := range h.open {
if c.Key == key {
if slices.Contains(keys, c.Key) {
return true
}
}
for _, r := range h.healths[p.Node].Resources {
if r.Module == p.Module && r.State == link.StateUnhealthy {
if r.Module == p.Module && (r.State == link.StateUnhealthy || r.State == link.StateWaiting) {
return true
}
}
@@ -0,0 +1,234 @@
package main
import (
"context"
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/conditions"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// A provider waiting for the operator holds its consumers, and a wait beside another wait is said (novox/hq
// issue 405, found in the review of ADR 0283's controller change).
//
// The shapes are those of issue 386 (mounts waiting for smb-password-games: waitingResource, passwordWait) and of
// the openrazer wait on the workstation of 2026-10-11 (relogin, userUnit): an account in its group whose session
// 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).
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"}}}
}
// 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.
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{}}
for k, p := range bound {
h.providers[k[0]+"\x00"+k[1]+"\x00"+k[2]] = providerLookup{p, true}
}
return h
}
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()}},
"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")}},
}
bound := map[[3]string]catalogue.Chosen{{"laptop", "shop", "postgres-database"}: theDatabase}
was := readHoldingFor
t.Cleanup(func() { readHoldingFor = was })
readHoldingFor = func(_ context.Context, _ *inventory.Inventory, open []conditions.Condition) (*holding, error) {
return holdingOf(open, healths, bound), nil
}
for look := 1; look <= 2; look++ {
if err := judgeModuleHealth(ctx, nil, k, "anchor", map[string][]inventory.ResourceHealth{"db": {waitingDatabase()}},
map[string]int{"db": look}, time.Now()); err != nil {
t.Fatal(err)
}
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)
}
}
open, err := k.Open(ctx)
if err != nil {
t.Fatal(err)
}
var keys []string
var provider *conditions.Condition
for i, c := range open {
keys = append(keys, c.Key)
if c.Key == needsOperatorKey("db", "anchor") {
provider = &open[i]
}
}
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)
}
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.Resolver != conditions.ResolverOperator || !strings.Contains(provider.Needs, "desk prompt") {
t.Fatalf("the provider's condition: %+v", provider)
}
}
// 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 {
return []inventory.ResourceHealth{relogin("mounts", "operator"), userUnit("mounts", "operator"),
waitingResource(passwordWait)}
}
// (2) A module with a checked wait beside a relogin-needed account says both: the new login, and the act the
// operator owes it.
func TestAWaitBesideAReloginIsSaid(t *testing.T) {
k, _ := withConditionsInMemory(t)
ctx := t.Context()
for look := 1; look <= 2; look++ {
if err := judgeModuleHealth(ctx, nil, k, "workstation", map[string][]inventory.ResourceHealth{"mounts": reloginBesideAWait()},
map[string]int{"mounts": look}, time.Now()); err != nil {
t.Fatal(err)
}
}
got, open := needsOperatorOpen(t, k)
if got == nil {
t.Fatalf("a wait for the operator beside a relogin is not said: %v", openKeysOf(open))
}
if !strings.Contains(got.Summary, "smb-password-games") || got.Severity != conditions.Warning {
t.Fatalf("the needs-operator beside the relogin: %+v", got)
}
relogged := false
for _, c := range open {
relogged = relogged || c.Key == reloginKey("mounts", "workstation")
}
if !relogged {
t.Fatalf("the relogin is no longer said beside the wait: %v", openKeysOf(open))
}
// The login done, the wait stays said and the relogin clears.
if err := judgeModuleHealth(ctx, nil, k, "workstation", map[string][]inventory.ResourceHealth{"mounts": {waitingResource(passwordWait)}},
map[string]int{"mounts": 3}, time.Now()); err != nil {
t.Fatal(err)
}
got, open = needsOperatorOpen(t, k)
if got == nil || len(open) != 1 {
t.Fatalf("after the new login: %v", openKeysOf(open))
}
}
// (2) The same beside a directory used as found.
func TestAWaitBesideADirectoryUsedAsFoundIsSaid(t *testing.T) {
k, _ := withConditionsInMemory(t)
ctx := t.Context()
found := foundDirectory("mounts", time.Now().Add(-time.Hour))
for look := 1; look <= 2; look++ {
if err := judgeModuleHealth(ctx, nil, k, "workstation", map[string][]inventory.ResourceHealth{
"mounts": {found, waitingResource(passwordWait)}}, map[string]int{"mounts": look}, time.Now()); err != nil {
t.Fatal(err)
}
}
got, open := needsOperatorOpen(t, k)
if got == nil {
t.Fatalf("a wait for the operator beside a directory used as found is not said: %v", openKeysOf(open))
}
asFound := false
for _, c := range open {
asFound = asFound || c.Key == usedAsFoundKey("mounts", "workstation")
}
if !asFound {
t.Fatalf("the directory used as found is no longer said beside the wait: %v", openKeysOf(open))
}
}
// (2) Beside a fault of its own, the wait is said too, and the fault's words do not count the waiting part among
// what fails.
func TestAWaitBesideAFaultIsSaidAndTheFaultIsTheFaultAlone(t *testing.T) {
k, _ := withConditionsInMemory(t)
ctx := t.Context()
down := inventory.ResourceHealth{Module: "mounts", Resource: "mounts.apply", Kind: "process",
Target: "mesh-mounts-apply.service", State: link.StateUnhealthy, Reason: "down"}
for look := 1; look <= 2; look++ {
if err := judgeModuleHealth(ctx, nil, k, "workstation", map[string][]inventory.ResourceHealth{
"mounts": {down, waitingResource(passwordWait)}}, map[string]int{"mounts": look}, time.Now()); err != nil {
t.Fatal(err)
}
}
got, open := needsOperatorOpen(t, k)
if got == nil {
t.Fatalf("a wait for the operator beside a fault is not said: %v", openKeysOf(open))
}
var fault *conditions.Condition
for i, c := range open {
if c.Key == moduleUnhealthyKey("mounts", "workstation") {
fault = &open[i]
}
}
if fault == nil {
t.Fatalf("the fault is not said: %v", openKeysOf(open))
}
if strings.Contains(fault.Summary, "mounts.watch") {
t.Fatalf("the fault's summary counts the waiting part as failing: %q", fault.Summary)
}
}
func openKeysOf(cs []conditions.Condition) []string {
var out []string
for _, c := range cs {
out = append(out, c.Key)
}
return out
}