diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index a4c2805..fbcbd74 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -699,6 +699,28 @@ func TestGrants(t *testing.T) { } }) + t.Run("compare excuses a retired verb and a multiply claimed mesh seat's added subjects, nothing else", func(t *testing.T) { + const retired, store, storeInfo = "mesh.seat.mesh-delivery.tool.mark-ready", "mesh.seat.mesh-store.tool.databases", "$SRV.INFO.mesh-store.>" + for _, row := range []struct { + name string + shadow, bus []string // the node-tools user's subscribe in the shadow list and in the loaded list + multi []string + differs bool + excusedCount int + }{ + {"a retired verb the shadow dropped", []string{"x"}, []string{"x", retired}, nil, false, 1}, + {"a verb of a live seat the shadow lost", []string{"x"}, []string{"x", store}, []string{"mesh-store"}, true, 0}, + {"a seat claimed on two machines, added", []string{"x", store, storeInfo}, []string{"x"}, []string{"mesh-store"}, false, 2}, + {"the same added for a seat claimed once", []string{"x", store}, []string{"x"}, nil, true, 0}, + } { + c := grants.Compare([]grants.User{{User: "a.node-tools", Subscribe: row.shadow}}, []grants.User{{User: "a.node-tools", Subscribe: row.bus}}, nil). + Excusing(grants.ExcusedBy(row.multi)) + if c.Equal() == row.differs || len(c.Excused) != row.excusedCount { + t.Errorf("%s: equal %v, excused %v", row.name, c.Equal(), c.Excused) + } + } + }) + t.Run("tools", func(t *testing.T) { b := fixture() b.loaded = shadowOf(t, b) diff --git a/modules/mesh-grants/internal/grants/part.go b/modules/mesh-grants/internal/grants/part.go index eef23e7..6fbf8da 100644 --- a/modules/mesh-grants/internal/grants/part.go +++ b/modules/mesh-grants/internal/grants/part.go @@ -190,7 +190,7 @@ func (p *Part) Compose() Result { users = WithHashes(users, in.Hashes) st.Digest, st.Users = Digest(users), len(users) loaded, err := LoadedUsers(p.Call) - st.Compare = Compare(users, loaded, Placed(in)) + st.Compare = Compare(users, loaded, Placed(in)).Excusing(ExcusedBy(MultiHeld(recs))) if err != nil { st.Compare.Unread = err.Error() } diff --git a/modules/mesh-grants/internal/grants/userlist.go b/modules/mesh-grants/internal/grants/userlist.go index 0ccb33c..a9bd151 100644 --- a/modules/mesh-grants/internal/grants/userlist.go +++ b/modules/mesh-grants/internal/grants/userlist.go @@ -5,11 +5,13 @@ import ( "encoding/hex" "encoding/json" "fmt" + "regexp" "slices" "sort" "strings" "git.novox.be/novox/mesh-sdk/go/buscompose" + "git.novox.be/novox/mesh-sdk/go/catalogue" ) // User is one bus user and its grants, as the bus module's nats_users answers them: never a password hash. @@ -234,7 +236,9 @@ type Comparison struct { Dropped []string `json:"dropped"` Gone []string `json:"gone,omitempty"` Differ []UserDiff `json:"differ"` - Same int `json:"same"` + // Excused are the subjects the comparison does not count, each with why (Comparison.Excusing). + Excused []Excuse `json:"excused,omitempty"` + Same int `json:"same"` // Unread says the loaded list could not be read, and why: nothing is compared then. Unread string `json:"unread,omitempty"` } @@ -325,3 +329,93 @@ func Placed(in Inputs) func(user string) bool { return slices.Contains(in.Nodes, parts[0]) && slices.Contains(in.Assigned[parts[0]], parts[1]) } } + +// Excuse is one subject the comparison does not count as a difference between the lists, and why. +type Excuse struct { + User string `json:"user"` + Subject string `json:"subject"` + Why string `json:"why"` +} + +// Excusing removes from the differences the subjects f excuses (added: the shadow grants it and the loaded list does not), +// naming each in Excused; a user left with no difference counts as the same. Pure. +func (c Comparison) Excusing(f func(subject string, added bool) string) Comparison { + var differ []UserDiff + for _, d := range c.Differ { + keep := func(list []string, added bool) []string { + var out []string + for _, s := range list { + if why := f(s, added); why != "" { + c.Excused = append(c.Excused, Excuse{User: d.User, Subject: s, Why: why}) + continue + } + out = append(out, s) + } + return out + } + d.PublishAdded, d.PublishDropped = keep(d.PublishAdded, true), keep(d.PublishDropped, false) + d.SubscribeAdded, d.SubscribeDropped = keep(d.SubscribeAdded, true), keep(d.SubscribeDropped, false) + if len(d.PublishAdded)+len(d.PublishDropped)+len(d.SubscribeAdded)+len(d.SubscribeDropped) == 0 && d.Responses == "" { + c.Same++ + continue + } + differ = append(differ, d) + } + if differ == nil { + differ = []UserDiff{} + } + c.Differ = differ + return c +} + +var seatSubject = regexp.MustCompile(`^mesh\.seat\.([^.]+)\.(?:call|tool)\.([^.]+)(?:\.\*)?$`) +var seatService = regexp.MustCompile(`^\$SRV\.(?:INFO|PING|STATS)\.([^.]+)(?:\.>)?$`) + +// ExcusedBy is the excuse of a comparison until placement lives in the catalogue (the operator, 2026-10-26): a verb the +// seat catalogue retired on purpose, which the shadow no longer grants, is no difference; and what the shadow grants more +// for a mesh seat that is claimed on several machines (the standing gap seat holdings: it composes the seat as held on each) +// is named and not counted, since a subscription to a seat's verbs on a machine whose holder does not answer them harms +// nothing and is gone when holdings follow the catalogue. Nothing else is excused: a grant the shadow gives for any other +// reason, or the loss of any other subject, keeps the lists apart. +func ExcusedBy(multiHeld []string) func(subject string, added bool) string { + return func(subject string, added bool) string { + if m := seatSubject.FindStringSubmatch(subject); m != nil { + if !added && retiredVerb(m[1], m[2]) { + return "a verb the seat catalogue retired" + } + if added && slices.Contains(multiHeld, m[1]) { + return "seat " + m[1] + " is claimed on several machines: composed as held on each until holdings follow the catalogue" + } + } + if m := seatService.FindStringSubmatch(subject); m != nil && added && slices.Contains(multiHeld, m[1]) { + return "seat " + m[1] + " is claimed on several machines: composed as held on each until holdings follow the catalogue" + } + return "" + } +} + +// MultiHeld are the mesh seats (held once for the mesh) that more than one machine's module claims, sorted. +func MultiHeld(r buscompose.Records) []string { + nodes := map[string]map[string]bool{} + for node, declared := range r.Assigned { + for _, d := range declared { + for _, s := range d.Holds { + if s.Scope != "" && s.Scope != catalogue.ScopeMesh { + continue + } + if nodes[s.Name] == nil { + nodes[s.Name] = map[string]bool{} + } + nodes[s.Name][node] = true + } + } + } + var out []string + for seat, ns := range nodes { + if len(ns) > 1 { + out = append(out, seat) + } + } + sort.Strings(out) + return out +}