diff --git a/modules/mesh-conditions/cmd/mesh-conditions/homes.go b/modules/mesh-conditions/cmd/mesh-conditions/homes.go index e98549ae4..96671fb72 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/homes.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/homes.go @@ -99,6 +99,9 @@ var Homes = map[string]Home{ "rename-window-held": {Source: "config", Observer: configModule}, "retirement-held": {Source: "config", Observer: configModule}, "retiring-unanswered": {Source: "config", Observer: configModule}, + // A reader whose owner does not answer its first view past two minutes (ADR 0615 §6): written by each reader in its own + // findings (mesh-config, mesh-vault), so the kind has no one observer. + "first-view-unanswered": {Source: "views", Why: "every reader of the indexer's or the assigner's state writes its own"}, // The kinds that end with the controller and are carried until its lease write (clauses 4, 9). "controller-deaf": {Source: "S4", Controller: true, Carried: true}, "lease-lost": {Source: "S12", Controller: true, Carried: true}, @@ -162,6 +165,7 @@ const ( messenger = "messenger" artifactStore = "artifact-store-tools" configModule = "mesh-config" + vaultModule = "mesh-vault" ) // homeOf is the home of a condition's kind, and whether the table has one. diff --git a/modules/mesh-conditions/cmd/mesh-conditions/keeper.go b/modules/mesh-conditions/cmd/mesh-conditions/keeper.go index 34c4c93c0..ec3719b3a 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/keeper.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/keeper.go @@ -40,7 +40,7 @@ const HistoryKeptFor = 90 * 24 * time.Hour // table of 2026-10-29 for the provisioner's and the router's; ADR 0571 clause 2 for the seat artifact-store's holder, // which observes S25, image-missing). var Observers = []string{health.WatchmanModule, health.SelfCheckModule, health.HealersModule, delivery.AssignerModule, - "mesh-provisioner", "messenger", artifactStore, configModule} + "mesh-provisioner", "messenger", artifactStore, configModule, vaultModule} // The other states it reads, as `.`. const ( diff --git a/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go b/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go index dc33bab1b..b84caac28 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go @@ -294,7 +294,7 @@ func TestEveryControllerKindHasItsHome(t *testing.T) { t.Errorf("the ended probe's kind %s is %+v", kind, h) } } - if len(Homes) != 88 || carried != 37 { // 19 of ADR 0564, the 63 of the table, plan-failed and stalled, and mesh-config's four (design 32) + if len(Homes) != 89 || carried != 37 { // 19 of ADR 0564, the 63 of the table, plan-failed and stalled, mesh-config's four (design 32) and the readers' unanswered first view t.Errorf("the table holds %d kinds, %d carried", len(Homes), carried) } } diff --git a/modules/mesh-conditions/module.json b/modules/mesh-conditions/module.json index e3ce5c361..793b7cdbd 100644 --- a/modules/mesh-conditions/module.json +++ b/modules/mesh-conditions/module.json @@ -22,6 +22,7 @@ "mesh-provisioner.findings", "artifact-store-tools.findings", "mesh-config.findings", + "mesh-vault.findings", "mesh-healers.attempts", "mesh-delivery-assigner.wants", "node-engine.have", diff --git a/modules/mesh-vault/cmd/mesh-vault/main.go b/modules/mesh-vault/cmd/mesh-vault/main.go index b334d3667..1a1345589 100644 --- a/modules/mesh-vault/cmd/mesh-vault/main.go +++ b/modules/mesh-vault/cmd/mesh-vault/main.go @@ -122,7 +122,11 @@ func watchLeavers(env *Env) bool { if node, ok := leaverKey(key); ok { env.Vault.NodeLeft(node) } - }) + }, func(key string, v json.RawMessage) error { + _, err := stdio.State(FindingsState).Put(key, v) + return err + }, + func(key string) error { return stdio.State(FindingsState).Delete(key) }) beginPlacementView(placementView) } return true diff --git a/modules/mesh-vault/cmd/mesh-vault/placementview.go b/modules/mesh-vault/cmd/mesh-vault/placementview.go index 4bcc0f8a6..e14e766a8 100644 --- a/modules/mesh-vault/cmd/mesh-vault/placementview.go +++ b/modules/mesh-vault/cmd/mesh-vault/placementview.go @@ -28,11 +28,15 @@ const ( FirstViewAgainTool = "vault_first_view_again" ) +// FindingsState is the vault's state of findings, which the condition keeper reads (ADR 0564): a finding names no secret. +const FindingsState = "findings" + // placementView is the picture; nil until main begins it. var placementView *views.Follower // newPlacementView makes the follower: ask calls the tool, gone is told of a deleted key. -func newPlacementView(ask func(key string, body any) (json.RawMessage, error), gone func(key string)) *views.Follower { +func newPlacementView(ask func(key string, body any) (json.RawMessage, error), gone func(key string), + put func(key string, value json.RawMessage) error, drop func(key string) error) *views.Follower { return &views.Follower{Owner: delivery.AssignerModule, Tool: PlacementTool, State: "placement", Logf: say, Ask: func(_ context.Context, after string) (views.Answer, error) { body := map[string]any{"values": true} @@ -45,9 +49,21 @@ func newPlacementView(ask func(key string, body any) (json.RawMessage, error), g } return views.DecodeAnswer(unwrapToolAnswer(raw)) }, + // Past its bound the vault's finding names the owner and the tool, and the condition keeper raises it (the + // answer deletes it); the vault keeps asking meanwhile. Raise: func(s views.Status) { say("the vault has no first view of the assigner's placement: %s has not answered since %s (%s); it keeps asking", s.Tool, s.Asked.Format(time.RFC3339), s.Error) + if k, v, err := views.UnansweredFinding(Seat, s, time.Now()); err == nil { + if err := put(k, v); err != nil { + say("the finding %s is not written: %v", k, err) + } + } + }, + Clear: func(s views.Status) { + if k, _, err := views.UnansweredFinding(Seat, s, time.Now()); err == nil { + _ = drop(k) + } }, Changed: func(key string, _ json.RawMessage, deleted bool, _ time.Time) { if deleted { diff --git a/modules/mesh-vault/cmd/mesh-vault/placementview_test.go b/modules/mesh-vault/cmd/mesh-vault/placementview_test.go new file mode 100644 index 000000000..2195d623a --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/placementview_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "sync" + "testing" + "time" + + "git.novox.be/novox/mesh-delivery/sdk/views" +) + +// ADR 0615 §6: the vault started while the assigner is down keeps asking, never reads its bucket, and past the bound +// writes a finding naming the owner and the tool for the condition keeper; the answer deletes it. Before the answer a read +// of the placement is an error. +func TestAnUnansweredFirstViewOfThePlacementIsAFindingTheAnswerClears(t *testing.T) { + var mu sync.Mutex + up := false + held := map[string]json.RawMessage{} + ask := func(string, any) (json.RawMessage, error) { + mu.Lock() + defer mu.Unlock() + if !up { + return nil, errors.New("nats: no responders available for request") + } + raw, _ := json.Marshal(views.Answer{V: 1, Owner: "mesh-delivery-assigner", State: "placement", Revision: 1, Created: time.Now(), + Values: map[string]json.RawMessage{"g14.node": json.RawMessage(`{}`)}}) + return raw, nil + } + put := func(k string, v json.RawMessage) error { mu.Lock(); held[k] = v; mu.Unlock(); return nil } + drop := func(k string) error { mu.Lock(); delete(held, k); mu.Unlock(); return nil } + f := newPlacementView(ask, func(string) {}, put, drop) + f.Bound, f.Wait = 30*time.Millisecond, 10*time.Millisecond + reader := viewPlacement(func() *views.Follower { return f }) + if _, err := reader.Keys(); err == nil { + t.Fatal("the placement was read before its first view") + } + done := make(chan error, 1) + go func() { done <- f.Start(context.Background()) }() + count := func() int { mu.Lock(); defer mu.Unlock(); return len(held) } + for end := time.Now().Add(5 * time.Second); count() == 0 && time.Now().Before(end); time.Sleep(10 * time.Millisecond) { + } + if count() != 1 { + t.Fatalf("past its bound the vault wrote %d findings, want one", count()) + } + mu.Lock() + up = true + mu.Unlock() + if err := <-done; err != nil { + t.Fatal(err) + } + if count() != 0 { + t.Errorf("the answer left %d findings", count()) + } + if keys, err := reader.Keys(); err != nil || len(keys) != 1 || keys[0] != "g14.node" { + t.Errorf("keys %v, %v", keys, err) + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/vault_test.go b/modules/mesh-vault/cmd/mesh-vault/vault_test.go index f06e1b9dc..d5b8eca9d 100644 --- a/modules/mesh-vault/cmd/mesh-vault/vault_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/vault_test.go @@ -146,7 +146,7 @@ func TestNoBucketHoldsASecret(t *testing.T) { if err := json.Unmarshal(raw, &manifest); err != nil { t.Fatal(err) } - if s, _ := json.Marshal(manifest["state"]); string(s) != `[{"history":2,"name":"bus-accounts"},"signers","bound","node-logins"]` { + if s, _ := json.Marshal(manifest["state"]); string(s) != `[{"history":2,"name":"bus-accounts"},"signers","bound","node-logins","findings"]` { t.Errorf("the manifest declares buckets %s: a secret is in no bucket, sealed or not (ADR 0201 §13), and the "+ "buckets are the accounts' hashes (ADR 0547 clause 9), the signing keys' public halves (ADR 0550 §2) and the "+ "credentials' outcomes, names and generations alone (ADR 0570 clauses 3, 6) and each node's login in use (ADR 0583)", s) @@ -195,11 +195,9 @@ func TestNoBucketHoldsASecret(t *testing.T) { code = []byte(strings.Replace(string(code), "stdio.State(HaveRead).Keys(", "", 1)) code = []byte(strings.Replace(string(code), "stdio.State(WantedRead).Watch(", "", 1)) code = []byte(strings.Replace(string(code), "stdio.State(WantedRead).Get(", "", 1)) - // And the seat's placement, where modules stand (placed.go): its keys and one key, read. - code = []byte(strings.Replace(string(code), "stdio.State(PlacementRead).Keys(", "", 1)) - code = []byte(strings.Replace(string(code), "stdio.State(PlacementRead).Get(", "", 1)) - // And one watch of its nodes' keys, for a node that leaves: its node-logins key goes with it (noderotation.go). - code = []byte(strings.Replace(string(code), "stdio.State(PlacementRead).Watch(", "", 1)) + // And its findings (ADR 0615 §6): the finding of a first view unanswered, written and deleted, never a secret. + code = []byte(strings.Replace(string(code), "stdio.State(FindingsState).Put(", "", 1)) + code = []byte(strings.Replace(string(code), "stdio.State(FindingsState).Delete(", "", 1)) // And its outcome entries (novox/hq ADR 0570 clauses 3, 6): a value's name and generation, never a value; and // one read of the seat's provisions, which hold none. for _, call := range []string{"Get(", "Put(", "Delete(", "Keys("} { diff --git a/modules/mesh-vault/module.json b/modules/mesh-vault/module.json index 0a5752139..1312f04c1 100644 --- a/modules/mesh-vault/module.json +++ b/modules/mesh-vault/module.json @@ -44,7 +44,8 @@ }, "signers", "bound", - "node-logins" + "node-logins", + "findings" ], "receives": { "secret": "${dir:grants}/mesh.json"