mesh-grants: publish a membership that drops only a retired seat verb, and say every one held back
The guard that holds back a membership losing anything held back five since 01:24: the frozen controller's memberships still serve the delivery seat's mark-ready, which mesh-sdk d506f21 retired on purpose (ADR 0421), so every membership composed with today's seat catalogue looked like a loss. A seat verb the catalogue defines no longer is now dropped from the comparison and named in the pass (retired); any other loss, a module's own seat's verbs included, is still refused. The log named only the first held membership: it now says each one, with what it would lose, whenever the set changes, and grants_membership with neither node nor module lists them. Issue: 1945
This commit is contained in:
@@ -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"))
|
||||
}},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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, `<seat>
|
||||
// <verb>`: 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, `<seat> <verb>`, 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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 `<node>.<module>` 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()
|
||||
|
||||
Reference in New Issue
Block a user