mesh-grants: after take-over the list in force is not replaced by one composed past a failed read or one dropping its users for want of a hash; the switch's effect says when the bus node does not watch the key (review of the switch, issue 1945). Pins mesh-sdk c2a410bb.

This commit is contained in:
2026-10-26 12:31:31 +01:00
parent 29cfb8c177
commit f55f150896
5 changed files with 115 additions and 8 deletions
+1 -1
View File
@@ -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
)
+2
View File
@@ -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=
+24
View File
@@ -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")
}
}
})
}
+27 -6
View File
@@ -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
}
+61 -1
View File
@@ -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 ""
}