diff --git a/modules/mesh-grants/cmd/mesh-grants/tools.go b/modules/mesh-grants/cmd/mesh-grants/tools.go index 4a0af90..51a662b 100644 --- a/modules/mesh-grants/cmd/mesh-grants/tools.go +++ b/modules/mesh-grants/cmd/mesh-grants/tools.go @@ -67,9 +67,10 @@ func toolsOf(p *grants.Part) []stdio.Tool { Run: func(a map[string]any) (any, error) { return p.Grants(grants.Answer(a, "module")) }}, {Name: grants.ToolMembership, Description: "One module's membership on one node (ADR 0547 clause 6): as composed now from the " + "bus's records with the bus composer, and what the part's process published of it at its last pass (published, " + - "unchanged, or why not: a shadow difference names what publishing would lose), with what the composition cannot " + - "read. Read-only.", - Input: schema(map[string]string{"node": "the machine", "module": "the module"}, "node", "module"), + "unchanged, or why not: a shadow difference names what publishing would lose; verbs the seat catalogue retired are " + + "dropped and named), with what the composition cannot read. With neither node nor module: every membership the last " + + "pass held back, each with what it would lose. Read-only.", + Input: schema(map[string]string{"node": "the machine (with module)", "module": "the module (with node)"}), Run: func(a map[string]any) (any, error) { return p.MembershipOf(grants.Answer(a, "node"), grants.Answer(a, "module")) }}, diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index f4d4315..17d4053 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -639,6 +639,17 @@ func TestGrants(t *testing.T) { m["serves"] = append(m["serves"].([]any), map[string]any{"subject": "mesh.mod.mod-x.tool.gone"}) return m }, false, "tool.gone"}, + // A verb the seat catalogue retired on purpose (mark-ready, mesh-sdk d506f21, ADR 0421) is dropped and named; a + // verb of a seat the catalogue does not define is still a loss (issue 1945). + {"a change that drops only a retired seat verb is published, the verb named", func(m map[string]any) map[string]any { + m["seats"] = []any{map[string]any{"seat": "mesh-delivery", "verb": "mark-ready", "subject": "mesh.seat.mesh-delivery.tool.mark-ready"}} + return m + }, true, "mesh-delivery mark-ready"}, + {"a retired verb and a module seat's verb dropped: not published, the module seat's named", func(m map[string]any) map[string]any { + m["seats"] = []any{map[string]any{"seat": "mesh-delivery", "verb": "mark-ready", "subject": "mesh.seat.mesh-delivery.tool.mark-ready"}, + map[string]any{"seat": "telegram-sender", "verb": "send", "subject": "mesh.seat.telegram-sender.tool.send"}} + return m + }, false, "telegram-sender"}, } { t.Run(row.name, func(t *testing.T) { b := fixture() @@ -653,9 +664,12 @@ func TestGrants(t *testing.T) { t.Fatalf("published %v, not published %v", res.Published, res.NotPublished) } if row.published { - if row.last != nil && !strings.Contains(string(b.stream["mesh.assignment."+key]), "10.0.0.1") { + if row.names == "" && row.last != nil && !strings.Contains(string(b.stream["mesh.assignment."+key]), "10.0.0.1") { t.Errorf("the private network of the last membership is not carried: %s", b.stream["mesh.assignment."+key]) } + if raw, _, _, _ := b.ownGet(grants.StateMemberships, key); !strings.Contains(string(raw), row.names) { + t.Errorf("the pass does not name %q: %s", row.names, raw) + } return } said := strings.Join(res.NotPublished, "\n") @@ -707,6 +721,49 @@ func TestGrants(t *testing.T) { }) }) + t.Run("memberships held back: every one is said and listed", func(t *testing.T) { + b := fixture() + b.loaded = shadowOf(t, b) + recs, _ := grants.Records(grants.ReadInputs(b, &grants.Manifests{})) + b.stream, b.seqs = map[string][]byte{}, map[string]uint64{} + for i, key := range []string{"a.mod-x", "b.mod-y"} { + raw, _ := json.Marshal(grants.Memberships(recs)[key].Membership) + var m map[string]any + _ = json.Unmarshal(raw, &m) + m["serves"] = append(m["serves"].([]any), map[string]any{"subject": "mesh.mod.lost.tool.gone"}) + raw, _ = json.Marshal(m) + subject := "mesh.assignment." + key + b.stream[subject], b.seqs[subject] = raw, uint64(i+1) + } + var logged []string + var lmu sync.Mutex + w := &fakeWatcher{opened: make(chan string, 16)} + f := &grants.Follower{Part: partOn(b), Watch: w, Logf: func(format string, args ...any) { + lmu.Lock() + logged = append(logged, fmt.Sprintf(format, args...)) + lmu.Unlock() + }} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { f.Run(ctx); close(done) }() + waitFor(t, func() bool { + lmu.Lock() + defer lmu.Unlock() + all := strings.Join(logged, "\n") + return strings.Contains(all, "held a.mod-x: ") && strings.Contains(all, "held b.mod-y: ") + }) + cancel() + <-done + held, err := partOn(b).MembershipOf("", "") + h, _ := held.(map[string]any) + if err != nil || h["count"] != 2 || !strings.Contains(fmt.Sprint(h["held"]), "tool.gone") { + t.Errorf("grants_membership with neither node nor module = %v, %v", held, err) + } + if _, err := partOn(b).MembershipOf("a", ""); err == nil { + t.Error("a node without a module was answered") + } + }) + t.Run("a refused read or write never stops it", func(t *testing.T) { b := fixture() b.loaded = shadowOf(t, b) diff --git a/modules/mesh-grants/internal/grants/follow.go b/modules/mesh-grants/internal/grants/follow.go index 62362ca..b8a33de 100644 --- a/modules/mesh-grants/internal/grants/follow.go +++ b/modules/mesh-grants/internal/grants/follow.go @@ -3,6 +3,7 @@ package grants import ( "context" "encoding/json" + "strings" "sync" "git.novox.be/novox/mesh-sdk/go/delivery" @@ -35,6 +36,8 @@ type Follower struct { watched map[string]bool refused map[string]string have map[string]string + // held is what the last pass held back, said again only when it changes. + held string } func (f *Follower) init() { @@ -142,6 +145,23 @@ func (f *Follower) open() { } } +// said logs every membership the pass held back, each with what it would lose (issue 1945), when the set changes from +// the last pass's. +func (f *Follower) said(res Result) { + if held := strings.Join(res.NotPublished, "\n"); held != f.held { + f.held = held + if len(res.NotPublished) == 0 { + f.logf("no membership held back") + } else { + f.logf("%d memberships not published, tried again at the next event (grants_membership with no node and module lists them):", + len(res.NotPublished)) + for _, n := range res.NotPublished { + f.logf("held %s", n) + } + } + } +} + // Run composes at start and after every change until ctx ends. It returns only when ctx ends. func (f *Follower) Run(ctx context.Context) { f.init() @@ -159,9 +179,7 @@ func (f *Follower) Run(ctx context.Context) { if len(res.Published) > 0 { f.logf("published %d memberships: %v", len(res.Published), res.Published) } - if len(res.NotPublished) > 0 { - f.logf("%d memberships not published, tried again at the next event; the first: %s", len(res.NotPublished), res.NotPublished[0]) - } + f.said(res) if res.Written { f.logf("the shadow list moved to revision %d: %d users, %d differing from the loaded list", res.Revision, res.UserList.Users, len(res.UserList.Compare.Differing())) diff --git a/modules/mesh-grants/internal/grants/memberships.go b/modules/mesh-grants/internal/grants/memberships.go index 6291542..d617ff8 100644 --- a/modules/mesh-grants/internal/grants/memberships.go +++ b/modules/mesh-grants/internal/grants/memberships.go @@ -11,6 +11,7 @@ import ( "strings" "git.novox.be/novox/mesh-sdk/go/buscompose" + "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/membership" ) @@ -41,6 +42,9 @@ type Membership struct { // Published says what became of it at the last pass: "published", "unchanged" (equal to the one last published), // or why it was not. Published string `json:"published"` + // Retired are the seat verbs the last membership served that mesh-sdk's seat catalogue no longer has, ` + // `: dropped on purpose, so their loss does not hold the membership back (issue 1945). + Retired []string `json:"retired,omitempty"` } // Memberships composes every assignment's membership from the records (buscompose.MembershipFor, as the controller's @@ -101,7 +105,9 @@ func publish(p Publisher, m Membership) (Membership, bool) { // composed with no root-free judgement, a kind's verified-sender would be dropped, and the operator's approvals by // a channel would no longer be honoured. Any loss stays a shadow difference, named. if found { - if lost := Losses(was, body); len(lost) > 0 { + kept, retired := WithoutRetired(was) + m.Retired = retired + if lost := Losses(kept, body); len(lost) > 0 { m.Published = ShadowDifference + strings.Join(lost, "; ") return m, false } @@ -114,6 +120,61 @@ func publish(p Publisher, m Membership) (Membership, bool) { return m, true } +// WithoutRetired is a membership without the seat verbs it serves that mesh-sdk's seat catalogue no longer has (a verb +// retired on purpose, such as the delivery seat's mark-ready, ADR 0421), and those verbs, ` `, once each. A +// seat the catalogue does not define (a module's own) keeps every verb, so its loss is still refused. Pure. +func WithoutRetired(last []byte) ([]byte, []string) { + var m map[string]any + if json.Unmarshal(last, &m) != nil { + return last, nil + } + seats, ok := m["seats"].([]any) + if !ok { + return last, nil + } + var kept []any + var retired []string + for _, e := range seats { + served, _ := e.(map[string]any) + seat, _ := served["seat"].(string) + verb, _ := served["verb"].(string) + if retiredVerb(seat, verb) { + if r := seat + " " + verb; !slices.Contains(retired, r) { + retired = append(retired, r) + } + continue + } + kept = append(kept, e) + } + if len(retired) == 0 { + return last, nil + } + if len(kept) == 0 { + delete(m, "seats") // as an empty list is written: not at all + } else { + m["seats"] = kept + } + out, err := json.Marshal(m) + if err != nil { + return last, nil + } + sort.Strings(retired) + return out, retired +} + +// retiredVerb says the seat catalogue defines seat with its verbs and no longer has verb among them. A seat the catalogue +// defines without verbs (its holder declares them) retires none. +func retiredVerb(seat, verb string) bool { + if seat == "" || verb == "" { + return false + } + s, ok := catalogue.SeatNamed(seat) + if !ok || len(s.Serves) == 0 { + return false + } + return !slices.ContainsFunc(s.Serves, func(v catalogue.Verb) bool { return v.Name == verb }) +} + // sameMembership says two memberships say the same, whatever the order of their fields. Pure. func sameMembership(was, now []byte) bool { var a, b any diff --git a/modules/mesh-grants/internal/grants/part.go b/modules/mesh-grants/internal/grants/part.go index 31d2520..d1b5d3e 100644 --- a/modules/mesh-grants/internal/grants/part.go +++ b/modules/mesh-grants/internal/grants/part.go @@ -97,7 +97,7 @@ type Result struct { } // NothingTakesEffect is what every composition says it did on the bus beyond its own state and the memberships. -const NothingTakesEffect = "memberships only (on the assignments stream, a first one or one that only adds to the last published; a loss stays a shadow difference); the user list, assertions and grants stay in shadow until take-over" +const NothingTakesEffect = "memberships only (on the assignments stream, a first one or one that only adds to the last published or drops only a verb the seat catalogue retired; any other loss stays a shadow difference); the user list, assertions and grants stay in shadow until take-over" // Part is the grants part: it reads, calls and writes through these, and nothing else. type Part struct { diff --git a/modules/mesh-grants/internal/grants/tools.go b/modules/mesh-grants/internal/grants/tools.go index adee678..16505da 100644 --- a/modules/mesh-grants/internal/grants/tools.go +++ b/modules/mesh-grants/internal/grants/tools.go @@ -62,8 +62,11 @@ func (p *Part) Grants(module string) (any, error) { // MembershipOf answers the tool membership: one module on one node as composed now, and as last published by the // part's process (its state memberships says what became of each at the last pass). func (p *Part) MembershipOf(node, module string) (any, error) { + if node == "" && module == "" { + return p.Held() + } if node == "" || module == "" { - return nil, errors.New("membership names a node and a module") + return nil, errors.New("membership names a node and a module, or neither for every membership held back") } in := ReadInputs(p.Read, &p.cache) recs, left := Records(in) @@ -90,6 +93,32 @@ func (p *Part) MembershipOf(node, module string) (any, error) { return out, nil } +// Held answers every membership the part's process held back at its last pass, each `.` with why (what +// publishing would lose), from the pass it keeps (issue 1945). +func (p *Part) Held() (any, error) { + raw, rev, found, err := p.Store.Get(StateUserList, KeyPass) + if err != nil { + return nil, fmt.Errorf("the last pass cannot be read: %w", err) + } + if !found { + return map[string]any{"held": []any{}, "says": "no pass of the part's process has been kept yet"}, nil + } + var res Result + if err := json.Unmarshal(raw, &res); err != nil { + return nil, fmt.Errorf("the last pass cannot be read: %w", err) + } + type held struct { + Membership string `json:"membership"` + Why string `json:"why"` + } + out := []held{} + for _, n := range res.NotPublished { + key, why, _ := strings.Cut(n, ": ") + out = append(out, held{Membership: key, Why: why}) + } + return map[string]any{"held": out, "count": len(out), "pass": rev, "published": res.Published}, nil +} + // Pass composes and keeps the result as the last pass, for the tools to read. func (p *Part) Pass() Result { res := p.Compose()