diff --git a/modules/mesh-vault/cmd/mesh-vault/licencebindings.go b/modules/mesh-vault/cmd/mesh-vault/licencebindings.go index f852d9e63..781394040 100644 --- a/modules/mesh-vault/cmd/mesh-vault/licencebindings.go +++ b/modules/mesh-vault/cmd/mesh-vault/licencebindings.go @@ -16,7 +16,6 @@ import ( "sync" "time" - "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/views" @@ -184,10 +183,11 @@ func licenceBindingsAnswer(raw []byte) (views.Answer, error) { // licenceBindingsView is the first view's follower; nil until main begins it. var licenceBindingsView *views.Follower -// newLicenceBindingsView makes the follower of the first view: asked until it answers, and each answer, at the start or -// asked again by hand, written into the state. +// newLicenceBindingsView makes the follower of the first view, adopted by a views.Reader holding the vault's writer (design +// 47 G A-28, A-134), so the reader writes and clears its findings: asked until it answers, and each answer, at the start or +// asked again by hand, written into the state; each binding.changed. is lb.Heard. func newLicenceBindingsView(ask func(subject string, body any) (json.RawMessage, error), lb LicenceBindings, - findings views.FindingWriter) *views.Follower { + findings views.WireWriter) (*views.Follower, *views.Reader, error) { var mu sync.Mutex var taken time.Time var f *views.Follower @@ -199,13 +199,6 @@ func newLicenceBindingsView(ask func(subject string, body any) (json.RawMessage, } return licenceBindingsAnswer(raw) }, - Raise: func(s views.Status) { - fd := views.UnansweredFinding(Seat, s, time.Now()) - if err := findings.Put(fd); err != nil { - say("the finding %s is not written: %v", fd.ID, err) - } - }, - Clear: func(s views.Status) { _ = findings.Delete(views.UnansweredFinding(Seat, s, time.Now())) }, // Each answer, once: its picture is the manager's bindings when it answered. Said: func(s views.Status) { mu.Lock() @@ -229,31 +222,32 @@ func newLicenceBindingsView(ask func(subject string, body any) (json.RawMessage, sort.Strings(consumers) say("the licence bindings of %d consumer(s) are kept from the first view: %v", len(consumers), consumers) }} - return f + r := &views.Reader{Module: Seat, Findings: findings, Logf: say} + manager, event, _ := strings.Cut(BindingChangedEvent, ".") + if err := r.Adopt(f, manager, event, func(_ context.Context, body []byte) error { return lb.Heard(body) }); err != nil { + return nil, nil, err + } + return f, r, nil } // watchLicenceBindings begins the first view and hears `binding.changed`. It answers whether it began; one that did not // is begun again at the next minute pass. func watchLicenceBindings(lb LicenceBindings) bool { - err := stdio.Subscribe(BindingChangedEvent, func(e stdio.Envelope) error { - if err := lb.Heard(e.Body); err != nil { - say("%v", err) - } - return nil - }) + if licenceBindingsView != nil { + return true + } + w, err := vaultFindings() if err != nil { - say("does not hear %s yet (%v); asked again at the next pass", BindingChangedEvent, err) + say("the vault's finding writer does not start yet (%v); the licence bindings are begun at the next pass", err) return false } - if licenceBindingsView == nil { - licenceBindingsView = newLicenceBindingsView(stdio.Ask, lb, - meshconditionsclient.FindingWriter{State: stdio.State(FindingsState)}) - go func() { - if err := licenceBindingsView.Start(context.Background()); err != nil { - say("the first view of the licence bindings ended unanswered: %v", err) - } - }() + f, r, err := newLicenceBindingsView(stdio.Ask, lb, w) + if err != nil { + say("the licence bindings are not followed: %v", err) + return false } + licenceBindingsView = f + r.Start(context.Background()) return true } diff --git a/modules/mesh-vault/cmd/mesh-vault/licencebindings_test.go b/modules/mesh-vault/cmd/mesh-vault/licencebindings_test.go index 1297538ce..403c03d58 100644 --- a/modules/mesh-vault/cmd/mesh-vault/licencebindings_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/licencebindings_test.go @@ -4,9 +4,11 @@ import ( "context" "encoding/json" "errors" + "fmt" "os" "slices" "sort" + "sync" "testing" "time" @@ -184,3 +186,55 @@ func TestTheFirstViewToolsCoverTheLicenceBindings(t *testing.T) { } } } + +// The licence bindings' follower is adopted by a reader holding the vault's writer (design 47 G A-28, A-134): past its bound +// the reader writes its unanswered finding through that writer, the answer clears it, and the follower is the reader's. +func TestTheLicenceBindingsFollowerIsAdoptedAndItsFindingGoesThroughTheVaultsWriter(t *testing.T) { + var mu sync.Mutex + up := false + ask := func(string, any) (json.RawMessage, error) { + mu.Lock() + defer mu.Unlock() + if !up { + return nil, errors.New("nats: no responders available for request") + } + return json.RawMessage(`[]`), nil + } + written := make(chan string, 4) + lb, _ := memLicenceBindings() + f, r, err := newLicenceBindingsView(ask, lb, signalledFindings{written}) + if err != nil { + t.Fatal(err) + } + if !r.Follows("claude-licence-manager.bindings") { + t.Fatal("the reader does not follow the licence bindings") + } + f.Bound, f.Wait = 30*time.Millisecond, 10*time.Millisecond + done := make(chan error, 1) + go func() { done <- f.Start(context.Background()) }() + if got := <-written; got != "write first-view-unanswered" { + t.Fatalf("past its bound the writer was told %q, want the unanswered finding written", got) + } + mu.Lock() + up = true + mu.Unlock() + if err := <-done; err != nil { + t.Fatal(err) + } + if got := <-written; got != "clear first-view-unanswered" { + t.Errorf("the answer told the writer %q, want the finding cleared", got) + } +} + +// signalledFindings says each write and clear of a finding, by its kind. +type signalledFindings struct{ said chan<- string } + +func (s signalledFindings) WriteWire(_ context.Context, f map[string]any) error { + s.said <- fmt.Sprint("write ", f["kind"]) + return nil +} +func (s signalledFindings) Clear(_ context.Context, _, _, kind, _ string) error { + s.said <- "clear " + kind + return nil +} +func (s signalledFindings) ModuleNode() (string, string) { return Seat, "" } diff --git a/modules/mesh-vault/cmd/mesh-vault/main.go b/modules/mesh-vault/cmd/mesh-vault/main.go index 6a7ff4aeb..a52660395 100644 --- a/modules/mesh-vault/cmd/mesh-vault/main.go +++ b/modules/mesh-vault/cmd/mesh-vault/main.go @@ -17,6 +17,7 @@ import ( "fmt" "os" "strings" + "sync" "time" stdio "git.novox.be/novox/mesh-sdk/go" @@ -120,16 +121,7 @@ func watchLeavers(env *Env) bool { return true } if assignmentsView == nil { - // The vault's finding writer (design 47 G §689, A-16): it states the assignments view's findings and observes none of - // its own, and names no secret. - w, err := views.NewStdioWriter(views.WriterConfig[meshconditions.Finding, meshconditions.FindingCleared, meshconditions.FindingsStated, meshconditions.FindingsAskedEvent]{ - Asked: meshconditions.SubjectFindingsAsked, Module: Seat, State: stdio.State(FindingsState), - Observe: func(context.Context) (views.Observed[meshconditions.Finding], error) { - return views.Observed[meshconditions.Finding]{}, nil - }}) - if err == nil { - err = w.Start(context.Background()) - } + w, err := vaultFindings() if err != nil { return false } @@ -144,6 +136,34 @@ func watchLeavers(env *Env) bool { return true } +// The vault's one finding writer (design 47 G §689, A-16, A-134): it states the findings of both its followers, the +// assignments view's and the licence bindings', observes none of its own, and names no secret. Made and started once. +var ( + findingsMu sync.Mutex + findingsWriter views.WireWriter +) + +func vaultFindings() (views.WireWriter, error) { + findingsMu.Lock() + defer findingsMu.Unlock() + if findingsWriter != nil { + return findingsWriter, nil + } + w, err := views.NewStdioWriter(views.WriterConfig[meshconditions.Finding, meshconditions.FindingCleared, meshconditions.FindingsStated, meshconditions.FindingsAskedEvent]{ + Asked: meshconditions.SubjectFindingsAsked, Module: Seat, State: stdio.State(FindingsState), + Observe: func(context.Context) (views.Observed[meshconditions.Finding], error) { + return views.Observed[meshconditions.Finding]{}, nil + }}) + if err == nil { + err = w.Start(context.Background()) + } + if err != nil { + return nil, err + } + findingsWriter = w + return w, nil +} + // assigned is the assignments mirror as the vault's watch keeps it. var assigned *WatchedAssignments diff --git a/modules/mesh-vault/cmd/mesh-vault/vault_test.go b/modules/mesh-vault/cmd/mesh-vault/vault_test.go index 7088a111b..234f988f4 100644 --- a/modules/mesh-vault/cmd/mesh-vault/vault_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/vault_test.go @@ -187,7 +187,6 @@ func TestNoBucketHoldsASecret(t *testing.T) { // consumer, never a secret (design 39 bus-and-switch §17, B25). if f == "licencebindings.go" { code = []byte(strings.Replace(string(code), "st := stdio.State(LicenceBindingsState)", "", 1)) - code = []byte(strings.Replace(string(code), "meshconditionsclient.FindingWriter{State: stdio.State(FindingsState)}", "", 1)) } // The signing keys' public halves, in signing.go alone: one read and one write of its own bucket (ADR 0550 §2). if f == "signing.go" {