Files
mesh-delivery/modules/mesh-grants/internal/grants/sealed.go
T
jschoubben ce600df98a
mesh/delivery merged: source.main moved to novox/mesh-delivery@1d416827157d
mesh/check being carried to ce600df98afc on 1d416827157d
Say the held-list state on a part's first pass, so a warning raised before a restart is cleared
2026-10-26 13:37:31 +01:00

332 lines
13 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) {
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) != "":
// 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)
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 {
a.At, b.At = b.At, a.At
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, or
// a user of the list in force is left out for want of a hash. 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 []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
switch {
case found:
_ = json.Unmarshal(raw, &had)
case bus == nil:
return "held: the users of the list in force are not known: the part kept none, and the loaded list could not be read"
default:
had = bus
}
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 ""
}