Live since the take-over the shadow list was written every 3 s (revision 99270 to 99357 in 90 s, nothing differing): the sealed key read back says [] for the nil left-out a pass composes, so every pass wrote the key again; each write moved the bus node's report of what it loaded, whose revision and time were inputs of the shadow list, and woke the next pass. The bus itself reloads only when the list's bytes change (its log: two configurations alternating with the placement, f8567865 and 473f9ad3), so this loop was load, not the reload.
347 lines
14 KiB
Go
347 lines
14 KiB
Go
package grants
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/asks"
|
|
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
)
|
|
|
|
// **The list the bus node loads from take-over** (novox/hq ADR 0547 clause 7, issue 1945). Each pass composes the bus's
|
|
// user list with the vault's hashes (the accounts block, buscompose.ComposeAccounts, as the bus module's file holds it),
|
|
// seals it to the bus node's key (the key the node says in its have of the bus module) and puts it as one object of the
|
|
// part's object bucket, named by its digest; then writes the key `user-list.sealed` naming it, with the revision, the user
|
|
// count, the users left out for want of a hash, and which list the bus node follows (the switch, clause 8). The object
|
|
// and the key are written in shadow too: the bus node reads the key and follows the controller's list until the key says
|
|
// FollowGrants, so a take-over has a list to load at once. Only the process writes them (Objects); a tool asks it.
|
|
|
|
// KeySealed is the key of the state user-list the bus node reads (delivery.GrantsUserListKey).
|
|
const KeySealed = delivery.GrantsUserListKey
|
|
|
|
// Objects is the part's object bucket of sealed lists: the process's connection; nil in the tools.
|
|
type Objects interface {
|
|
PutObject(name string, data []byte) error
|
|
DeleteObject(name string) error
|
|
}
|
|
|
|
// ComposeList is the bus's user list as the file holds it, with each user's hash from the vault, and the users left out
|
|
// for want of one. Pure.
|
|
func ComposeList(r buscompose.Records, hashes map[string]HeldHash) ([]byte, int, []string, error) {
|
|
principals, err := buscompose.Users(r)
|
|
if err != nil {
|
|
return nil, 0, nil, err
|
|
}
|
|
held := map[string]string{}
|
|
for user, h := range hashes {
|
|
held[user] = h.Hash
|
|
}
|
|
filled, missing := buscompose.WithPasswords(principals, held)
|
|
list, err := buscompose.ComposeAccounts(filled)
|
|
if err != nil {
|
|
return nil, 0, nil, err
|
|
}
|
|
slices.Sort(missing)
|
|
return []byte(list), len(filled), missing, nil
|
|
}
|
|
|
|
// Sealed is the part's key `user-list.sealed` as written, and whether there is one.
|
|
func (p *Part) Sealed() (delivery.GrantsUserList, uint64, bool) {
|
|
raw, rev, found, err := p.Store.Get(StateUserList, KeySealed)
|
|
if err != nil || !found {
|
|
return delivery.GrantsUserList{}, 0, false
|
|
}
|
|
g, err := delivery.DecodeGrantsUserList(raw)
|
|
if err != nil {
|
|
return delivery.GrantsUserList{}, 0, false
|
|
}
|
|
return g, rev, true
|
|
}
|
|
|
|
// followOf is which list the switch says the bus node follows.
|
|
func followOf(state string) string {
|
|
if state == TookOver {
|
|
return delivery.FollowGrants
|
|
}
|
|
return delivery.FollowController
|
|
}
|
|
|
|
// writeSealed puts the list sealed to the bus node and writes the key, each only when what it says changed. It says in
|
|
// res what it wrote, or why it wrote nothing.
|
|
func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut []string, in Inputs, bus []string, lost []string) {
|
|
cur, _, had := p.Sealed()
|
|
next := cur
|
|
next.Follow = followOf(p.SwitchState())
|
|
key := ""
|
|
if in.Loaded != nil {
|
|
key = in.Loaded.Key
|
|
}
|
|
digest := delivery.UserListDigest(list)
|
|
why, held := "", ""
|
|
switch {
|
|
case p.Objects == nil:
|
|
// The tools: the process writes; the follow alone may change here, from a tool's view of the switch, and is not.
|
|
res.Sealed = sealedSays(cur, had, "")
|
|
return
|
|
case in.BusNode == "":
|
|
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, bus, lost) != "":
|
|
// 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, bus, lost)
|
|
held = why
|
|
case cur.Digest != digest || cur.SealedTo != delivery.SealingKeyID(key) || cur.Node != in.BusNode:
|
|
object, err := delivery.SealUserList(list, key)
|
|
if err == nil {
|
|
err = p.Objects.PutObject(delivery.UserListObjectName(digest), object)
|
|
}
|
|
if err != nil {
|
|
res.Errors = append(res.Errors, "the sealed user list could not be put: "+err.Error())
|
|
res.Sealed = sealedSays(cur, had, "")
|
|
return
|
|
}
|
|
if cur.Digest != digest {
|
|
next.Revision++
|
|
}
|
|
next.Digest, next.Object, next.SealedTo, next.Node = digest, delivery.UserListObjectName(digest), delivery.SealingKeyID(key), in.BusNode
|
|
}
|
|
p.sayHeld(held)
|
|
if next.Digest == digest {
|
|
next.Users, next.LeftOut = users, leftOut
|
|
}
|
|
if next.Digest == "" {
|
|
next.Users, next.LeftOut = 0, nil
|
|
}
|
|
if had && sameSealed(cur, next) {
|
|
res.Sealed = sealedSays(cur, had, why)
|
|
return
|
|
}
|
|
next.At = p.now()
|
|
data, err := delivery.EncodeGrantsUserList(next)
|
|
if err == nil {
|
|
_, err = p.Store.Put(StateUserList, KeySealed, data)
|
|
}
|
|
if err != nil {
|
|
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateUserList, KeySealed, err))
|
|
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 {
|
|
res.Errors = append(res.Errors, "the sealed list before could not be taken away: "+err.Error())
|
|
}
|
|
}
|
|
res.Sealed = sealedSays(next, true, why)
|
|
p.say(res, EventUserList, map[string]any{"digest": next.Digest, "revision": next.Revision, "users": next.Users,
|
|
"left_out": len(next.LeftOut), "follow": next.Follow, "sealed_to": next.Node})
|
|
}
|
|
|
|
// sayHeld raises the warning condition mesh-grants.user-list-held while the list in force is kept (why, which words the pass's
|
|
// `held: ...`), and clears it on the first pass that holds nothing back: the list advanced, or the reason went. The refusal
|
|
// condition's way (sayRefused): said once per reason, again when the reason changes, and tried again when a say failed.
|
|
func (p *Part) sayHeld(why string) {
|
|
if p.Say == nil {
|
|
return
|
|
}
|
|
p.holdMu.Lock()
|
|
defer p.holdMu.Unlock()
|
|
if p.heldKnown && why == p.heldSaid {
|
|
return
|
|
}
|
|
c := asks.Condition{Key: HeldCondition, Subject: asks.Scope{Scope: "module", ID: "mesh-grants"}}
|
|
if why == "" {
|
|
c.State = asks.StateCleared
|
|
} else {
|
|
reason := strings.TrimPrefix(why, "held: ")
|
|
c.Severity, c.State = asks.SeverityWarning, asks.StateRaised
|
|
c.Summary, c.Headline, c.Needs = "the user list is held: "+reason, "The user list in force is held back", "a look at why"
|
|
c.Explanation = asks.NeedsYou + "a look at why. The bus node keeps following the list written before, not the one composed now: " + reason
|
|
c.Resolved = "a pass finds nothing to hold back and the list advances"
|
|
}
|
|
if p.Say(c) == nil {
|
|
p.heldSaid, p.heldKnown = why, true
|
|
}
|
|
}
|
|
|
|
// HeldCondition is the key of the warning raised while a list in force is held back after take-over.
|
|
const HeldCondition = "mesh-grants.user-list-held"
|
|
|
|
func sameSealed(a, b delivery.GrantsUserList) bool {
|
|
// Its time is no change, and no user left out is the same written as [] or null: the key read back says [] for the
|
|
// nil a pass composes, which read as a change wrote the key again at every pass, and each write moved the bus node's
|
|
// report and woke the next pass (issue 1126: the shadow list's revision 99270 to 99357 in 90 s, 2026-10-28).
|
|
a.At, b.At = b.At, a.At
|
|
if len(a.LeftOut) == 0 {
|
|
a.LeftOut = nil
|
|
}
|
|
if len(b.LeftOut) == 0 {
|
|
b.LeftOut = nil
|
|
}
|
|
x, _ := json.Marshal(a)
|
|
y, _ := json.Marshal(b)
|
|
return string(x) == string(y)
|
|
}
|
|
|
|
// SealedState is what a pass says of the sealed list: the key as written, and why nothing was written when nothing was.
|
|
type SealedState struct {
|
|
Written *delivery.GrantsUserList `json:"written,omitempty"`
|
|
Why string `json:"why,omitempty"`
|
|
}
|
|
|
|
func sealedSays(g delivery.GrantsUserList, had bool, why string) *SealedState {
|
|
s := &SealedState{Why: why}
|
|
if had {
|
|
s.Written = &g
|
|
}
|
|
return s
|
|
}
|
|
|
|
// syncFollow writes the switch into the key at once, so the bus node turns on the operator's yes, not at the next pass.
|
|
func (p *Part) syncFollow(state string) error {
|
|
cur, _, had := p.Sealed()
|
|
if had && cur.Follow == followOf(state) {
|
|
return nil
|
|
}
|
|
if !had {
|
|
cur = delivery.GrantsUserList{}
|
|
}
|
|
cur.Follow, cur.At = followOf(state), p.now()
|
|
data, err := delivery.EncodeGrantsUserList(cur)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = p.Store.Put(StateUserList, KeySealed, data)
|
|
return err
|
|
}
|
|
|
|
// Effect is where the switch stands on the bus node: "switched" only once the bus node's loaded list is the grants part's
|
|
// written one (its source the part's key, its digest the written digest); otherwise what it waits for.
|
|
func (p *Part) Effect(state string, loaded *delivery.UserListWritten) string {
|
|
g, _, had := p.Sealed()
|
|
fromGrants := loaded != nil && loaded.Source == delivery.GrantsUserListSource
|
|
switch {
|
|
case state == TookOver && had && fromGrants && g.Digest != "" && loaded.Digest == g.Digest && loaded.Error == "":
|
|
return "switched: the bus node loaded the grants part's list " + short(g.Digest) + " (revision " + fmt.Sprint(g.Revision) + ")"
|
|
case state == TookOver && !had || state == TookOver && g.Digest == "":
|
|
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:
|
|
return "recorded, not in effect yet: the bus node loaded " + short(loaded.Digest) + " from " + loaded.Source + ", the grants part wrote " + short(g.Digest)
|
|
case fromGrants:
|
|
return "handed back, not in effect yet: the bus node still follows the grants part's list"
|
|
}
|
|
return "shadow: the bus node follows the controller's list"
|
|
}
|
|
|
|
func short(d string) string {
|
|
d = strings.TrimPrefix(d, "sha256:")
|
|
if len(d) > 12 {
|
|
d = d[:12]
|
|
}
|
|
return d
|
|
}
|
|
|
|
// followReasons is what a take-over waits for on the sealed list itself: a list written for the bus node, of the list
|
|
// composed now, and the loaded list granting the bus node its read (the controller's composer carries ADR 0547 clause 7's
|
|
// grants from the operator's second exception; until its delivery the bus node could not read the first list).
|
|
func (p *Part) followReasons(res Result, in Inputs) []string {
|
|
var out []string
|
|
g, _, had := p.Sealed()
|
|
switch {
|
|
case !had || g.Digest == "":
|
|
why := "no list is written"
|
|
if res.Sealed != nil && res.Sealed.Why != "" {
|
|
why = res.Sealed.Why
|
|
}
|
|
out = append(out, "the grants part has written no sealed list for the bus node: "+why)
|
|
case in.Loaded == nil || in.Loaded.Key == "" || g.SealedTo != delivery.SealingKeyID(in.Loaded.Key):
|
|
out = append(out, "the sealed list is not sealed to the key the bus node says now")
|
|
}
|
|
if in.BusNode != "" {
|
|
node := "node." + in.BusNode
|
|
for _, d := range res.UserList.Compare.Differ {
|
|
if d.User == node && slices.ContainsFunc(d.PublishAdded, func(s string) bool { return strings.Contains(s, delivery.GrantsUserListBucket) }) {
|
|
out = append(out, "the loaded list does not grant "+node+" reading the grants part's sealed list (ADR 0547 clause 7): "+
|
|
"the controller's composer carries it from its delivery of the operator's second exception")
|
|
}
|
|
}
|
|
}
|
|
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, a
|
|
// user of the list in force is left out for want of a hash, or the list would leave a seat the list in force holds with
|
|
// no holder while a placed module still claims it (lost, Holdings.LastHolders: the membership's guard, issue 1945; the
|
|
// loaded list not read holds it too, since that cannot be ruled out). The list in force is the one the part wrote, by the users
|
|
// it kept of it; where it kept none (a list written before this release), the users the bus loaded (bus, nil when
|
|
// they could not be read). Empty when it may.
|
|
func (p *Part) holdsBack(in Inputs, leftOut, bus, lost []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, "; ") + ")"
|
|
}
|
|
if bus == nil {
|
|
return "held: the grants of the list in force cannot be read, so a loss of seats cannot be ruled out"
|
|
}
|
|
if len(lost) > 0 {
|
|
return "held, " + ShadowDifference + "the last holder of " + strings.Join(lost, ", ")
|
|
}
|
|
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
|
|
switch {
|
|
case found:
|
|
_ = json.Unmarshal(raw, &had)
|
|
default:
|
|
had = bus
|
|
}
|
|
var unhashed []string
|
|
for _, u := range leftOut {
|
|
if slices.Contains(had, u) {
|
|
unhashed = append(unhashed, u)
|
|
}
|
|
}
|
|
if len(unhashed) > 0 {
|
|
return "held: the list in force is kept, since " + strings.Join(unhashed, ", ") + " would be left out for want of a hash in the vault"
|
|
}
|
|
return ""
|
|
}
|