diff --git a/modules/mesh-grants/go.mod b/modules/mesh-grants/go.mod index c1e165d..3d6dc66 100644 --- a/modules/mesh-grants/go.mod +++ b/modules/mesh-grants/go.mod @@ -3,7 +3,7 @@ module mesh-grants go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026110029-83ee7769d8ee + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026112527-c2a410bb6376 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-grants/go.sum b/modules/mesh-grants/go.sum index 20e4653..8f26273 100644 --- a/modules/mesh-grants/go.sum +++ b/modules/mesh-grants/go.sum @@ -4,6 +4,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026105311-175a5567b8bd h1:ef6y09cs git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026105311-175a5567b8bd/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026110029-83ee7769d8ee h1:ZdBMkF4WYJi82jb3xYi9Z2lXN/iMTn9r3SFC122ROY4= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026110029-83ee7769d8ee/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026112527-c2a410bb6376 h1:by2zBASLb8h5jA1IiYJWykXwLnHYpuZh/zFNa4NsbOU= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026112527-c2a410bb6376/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index e2f8a60..44e582a 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -955,6 +955,30 @@ func TestGrants(t *testing.T) { t.Errorf("effect once the bus node loaded the written list = %s", e) } withKey(loadedBefore) + // After take-over a list composed past a failed read, or one dropping a user of the list in force for + // want of a hash, is not sealed: the list in force stays, and the pass says why. + b.refused[grants.ReadAccounts] = true + if res := p.Compose(); res.Sealed == nil || !strings.Contains(res.Sealed.Why, "a read of this pass failed") { + t.Errorf("a pass past a failed read = %+v", res.Sealed) + } + b.refused[grants.ReadAccounts] = false + if now, _, _ := p.Sealed(); now.Digest != g.Digest || now.Object != g.Object { + t.Errorf("the list in force moved past a failed read: %s, was %s", now.Digest, g.Digest) + } + b.mu.Lock() + hash := b.states[grants.ReadAccounts]["a.mod-x"] + delete(b.states[grants.ReadAccounts], "a.mod-x") + b.mu.Unlock() + if res := p.Compose(); res.Sealed == nil || !strings.Contains(res.Sealed.Why, "a.mod-x would be left out") { + t.Errorf("a pass that drops a.mod-x's hash = %+v", res.Sealed) + } + if now, _, _ := p.Sealed(); now.Digest != g.Digest { + t.Errorf("the list in force moved without a.mod-x: %s", now.Digest) + } + b.put(grants.ReadAccounts, "a.mod-x", json.RawMessage(hash)) + if p.Compose(); func() bool { now, _, _ := p.Sealed(); return now.Digest != g.Digest }() { + t.Errorf("the list in force moved with every read and hash back") + } } }) } diff --git a/modules/mesh-grants/internal/grants/inputs.go b/modules/mesh-grants/internal/grants/inputs.go index ac46c88..fdb6c03 100644 --- a/modules/mesh-grants/internal/grants/inputs.go +++ b/modules/mesh-grants/internal/grants/inputs.go @@ -66,6 +66,9 @@ type Caller interface { type Gap struct { Input string `json:"input"` Why string `json:"why"` + // Failed says the gap is a read that failed this pass, not one that stands (ADR 0547 clause 7: after take-over a + // list composed past a failed read is not sealed). + Failed bool `json:"failed,omitempty"` } // Standing gaps: what the controller composes from its own store, which no bus value carries in this milestone. @@ -193,14 +196,14 @@ func (c *Manifests) get(r Reader, digest string) (catalogue.Manifest, error) { func each(r Reader, state string, gaps *[]Gap) map[string][]byte { keys, err := r.Keys(state) if err != nil { - *gaps = append(*gaps, Gap{Input: state, Why: "refused or absent: " + err.Error()}) + *gaps = append(*gaps, Gap{Input: state, Why: "refused or absent: " + err.Error(), Failed: true}) return map[string][]byte{} } out := map[string][]byte{} for _, k := range keys { v, found, err := r.Get(state, k) if err != nil { - *gaps = append(*gaps, Gap{Input: state + " " + k, Why: err.Error()}) + *gaps = append(*gaps, Gap{Input: state + " " + k, Why: err.Error(), Failed: true}) continue } if found { @@ -229,7 +232,7 @@ func ReadInputs(r Reader, cache *Manifests) Inputs { } haveKeys, err := r.Keys(ReadHave) if err != nil { - in.Gaps = append(in.Gaps, Gap{Input: ReadHave, Why: "refused or absent: " + err.Error()}) + in.Gaps = append(in.Gaps, Gap{Input: ReadHave, Why: "refused or absent: " + err.Error(), Failed: true}) } for _, k := range haveKeys { if node, _, isNode, err := delivery.ParseHaveKey(k); err == nil && isNode { @@ -243,7 +246,7 @@ func ReadInputs(r Reader, cache *Manifests) Inputs { sort.Strings(in.Nodes) if in.BusNode != "" { if raw, found, err := r.Get(ReadHave, in.BusNode+"."+BusModule); err != nil { - in.Gaps = append(in.Gaps, Gap{Input: "the loaded list's have", Why: err.Error()}) + in.Gaps = append(in.Gaps, Gap{Input: "the loaded list's have", Why: err.Error(), Failed: true}) } else if found { if h, err := delivery.DecodeHave(raw); err == nil { in.Loaded = h.UserList @@ -266,11 +269,15 @@ func ReadInputs(r Reader, cache *Manifests) Inputs { // assigned ones, since a seat is declared by one module and held by another. indexed, err := r.Keys(ReadModules) if err != nil { - in.Gaps = append(in.Gaps, Gap{Input: ReadModules, Why: "refused or absent: " + err.Error()}) + in.Gaps = append(in.Gaps, Gap{Input: ReadModules, Why: "refused or absent: " + err.Error(), Failed: true}) } for _, module := range indexed { raw, found, err := r.Get(ReadModules, module) - if err != nil || !found { + if err != nil { + in.Gaps = append(in.Gaps, Gap{Input: ReadModules + " " + module, Why: err.Error(), Failed: true}) + continue + } + if !found { continue } v, err := delivery.DecodeModule(raw) @@ -388,3 +395,17 @@ func gapNamed(gaps []Gap, input string) bool { func sortGaps(g []Gap) { sort.SliceStable(g, func(i, j int) bool { return strings.Compare(g[i].Input, g[j].Input) < 0 }) } + +// ReadFailures names every read this pass could not make and every manifest refused: what a list composed now may lack. +func (in Inputs) ReadFailures() []string { + var out []string + for _, g := range in.Gaps { + if g.Failed { + out = append(out, g.Input+": "+g.Why) + } + } + for _, g := range in.Refused { + out = append(out, g.Input+": "+g.Why) + } + return out +} diff --git a/modules/mesh-grants/internal/grants/sealed.go b/modules/mesh-grants/internal/grants/sealed.go index 82c047a..8038135 100644 --- a/modules/mesh-grants/internal/grants/sealed.go +++ b/modules/mesh-grants/internal/grants/sealed.go @@ -3,6 +3,7 @@ package grants import ( "encoding/json" "fmt" + "regexp" "slices" "strings" @@ -89,6 +90,10 @@ func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut []string why = "no node runs the bus's module, so the list is sealed to nobody" case key == "": why = "the bus node's have of the bus module names no key yet (its node-engine says it from this release)" + case next.Follow == delivery.FollowGrants && cur.Digest != digest && p.holdsBack(in, leftOut) != "": + // After take-over the list in force is never replaced by one composed past a failed read, or one that drops a user + // of it for want of a hash: that user's module would be locked out of the bus (review of the switch). + why = p.holdsBack(in, leftOut) case cur.Digest != digest || cur.SealedTo != delivery.SealingKeyID(key) || cur.Node != in.BusNode: object, err := delivery.SealUserList(list, key) if err == nil { @@ -104,7 +109,9 @@ func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut []string } next.Digest, next.Object, next.SealedTo, next.Node = digest, delivery.UserListObjectName(digest), delivery.SealingKeyID(key), in.BusNode } - next.Users, next.LeftOut = users, leftOut + if next.Digest == digest { + next.Users, next.LeftOut = users, leftOut + } if next.Digest == "" { next.Users, next.LeftOut = 0, nil } @@ -122,6 +129,9 @@ func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut []string res.Sealed = sealedSays(cur, had, "") return } + if next.Digest == digest && cur.Digest != digest { + p.keepWrittenUsers(res, list) + } // The object the key named before is taken away once the key names the new one: the bucket keeps the list in force. if had && cur.Object != "" && cur.Object != next.Object { if err := p.Objects.DeleteObject(cur.Object); err != nil { @@ -184,6 +194,8 @@ func (p *Part) Effect(state string, loaded *delivery.UserListWritten) string { return "recorded, not in effect: the grants part has written no sealed list for the bus node yet" case state == TookOver && loaded == nil: return "recorded, not in effect: the bus module's have names no loaded list" + case state == TookOver && loaded.Unwatched != "" && !fromGrants: + return "recorded, not in effect: the bus node does not watch the grants part's key: " + loaded.Unwatched case state == TookOver && loaded.Error != "": return "recorded, not in effect: the bus node could not load the grants part's list: " + loaded.Error case state == TookOver: @@ -229,3 +241,51 @@ func (p *Part) followReasons(res Result, in Inputs) []string { } return out } + +// KeyWrittenUsers is the key of the state user-list naming the users of the list the key `sealed` names: what a list +// composed after take-over must keep. +const KeyWrittenUsers = "sealed-users" + +func (p *Part) keepWrittenUsers(res *Result, list []byte) { + data, _ := json.Marshal(listUsers(list)) + if _, err := p.Store.Put(StateUserList, KeyWrittenUsers, data); err != nil { + res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateUserList, KeyWrittenUsers, err)) + } +} + +var listUser = regexp.MustCompile(`\{\s*user:\s*"([^"]+)"`) + +// listUsers names the users of a list as the composer writes it. +func listUsers(list []byte) []string { + out := []string{} + for _, m := range listUser.FindAllSubmatch(list, -1) { + out = append(out, string(m[1])) + } + return out +} + +// holdsBack says why a list composed now may not replace the one in force after take-over: a read failed this pass, or +// a user of the list in force is left out for want of a hash. Empty when it may. +func (p *Part) holdsBack(in Inputs, leftOut []string) string { + if f := in.ReadFailures(); len(f) > 0 { + return "held: the list in force is kept while a read of this pass failed (" + strings.Join(f, "; ") + ")" + } + raw, _, found, err := p.Store.Get(StateUserList, KeyWrittenUsers) + if err != nil { + return "held: the users of the list in force cannot be read: " + err.Error() + } + var had []string + if found { + _ = json.Unmarshal(raw, &had) + } + var lost []string + for _, u := range leftOut { + if slices.Contains(had, u) { + lost = append(lost, u) + } + } + if len(lost) > 0 { + return "held: the list in force is kept, since " + strings.Join(lost, ", ") + " would be left out for want of a hash in the vault" + } + return "" +}