551 lines
19 KiB
Go
551 lines
19 KiB
Go
// Package userlist is the bridge of novox/hq ADR 0547 §8: the node agent of the node holding mesh-broker writes the bus's
|
|
// user list alone, with no want, so a new account is in force on the bus without anybody's hand.
|
|
//
|
|
// **Why it exists.** The list reached the bus only through a want of the bus module, and ADR 0539 §15 bars the assigner
|
|
// from writing one: on 2026-10-25 it stood from 11:23 to 20:50 and every new account was refused until the operator put
|
|
// it back by hand (issue 1945). The bus module's entrypoint already reloads the server in place when the file changes;
|
|
// what was missing is a writer of the file that does not wait on a want.
|
|
//
|
|
// **The grants part's list alone** (ADR 0547 clauses 7, 8 and 11, ADR 0571 clause 8; issue 1126: the controller ended by
|
|
// its unassignment and its list is written by nobody). It watches the grants part's one key `user-list.sealed`: the list
|
|
// is the object the key names, read by its name, opened with this node's sealing key and checked against the key's
|
|
// digest, and written alone (mode 0600, apply.WriteAlone) only when the file's digest differs. A key that cannot be read
|
|
// or is deleted is said, the key last heard stands, and the list loaded stays; with no key ever heard nothing is
|
|
// written. The list a composition carries is never written.
|
|
//
|
|
// **Where it writes.** The file is the user-list resource (`bus-users`) of the bus module's composition as this node runs
|
|
// it (Env.Running: the agent's own record of what runs, its own composition or the declaration kept before the switch),
|
|
// never one read from a composition bucket: the controller that wrote `composed-<node>` ended (issue 1126), and the bus
|
|
// module on the bus node runs from what the agent holds. That is what tells the bus node it is one, and the file's
|
|
// place. The have of that module says what was written, which list it follows and this node's key, which the grants
|
|
// part seals to.
|
|
//
|
|
// **It watches, it never polls**: the grants part's key is watched, and what runs is looked at again when the agent says
|
|
// it changed, after every apply of the bus module and when asked (Again). It reads no want and writes none, moves no
|
|
// fingerprint, and touches nothing of the bus module but its user list.
|
|
package userlist
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
|
engcomp "github.com/novox/mesh-host/internal/compositions"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
"git.novox.be/novox/mesh-sdk/go/seal"
|
|
"github.com/novox/mesh-host/internal/agent"
|
|
)
|
|
|
|
// ResourceID is the id the composer gives the bus's user list (mesh-sdk catalogue.BusUsersID); in a composition it may
|
|
// carry its module before it, `<module>.bus-users`.
|
|
const ResourceID = "bus-users"
|
|
|
|
// Written is what the follower last did: the source it took the list from, the list's digest there, the file's digest
|
|
// on the machine after the write, how many users the list holds, when, and why a write failed.
|
|
type Written = nodeengine.UserListWritten
|
|
|
|
// Env is everything the follower needs of the node.
|
|
type Env struct {
|
|
Node string
|
|
// Running is the compositions this node runs now, as its agent holds them (agent.Agent.Everything): where the user
|
|
// list's file is is read from these alone (issue 1126). Nil runs nothing.
|
|
Running func() []engcomp.Composed
|
|
// OnDisk is what the resource's file holds on the machine now; nil when there is none.
|
|
OnDisk func(resource json.RawMessage) ([]byte, error)
|
|
// Write writes the user list's file resource of a module's composition alone, with body as its content, when it
|
|
// differs from the file on the machine, and answers the digest of the file there after it (`sha256:<hex>`) and
|
|
// whether it changed.
|
|
Write func(ctx context.Context, module string, resource json.RawMessage, body []byte) (digest string, changed bool, err error)
|
|
// Report says what was written, in the module's have.
|
|
Report func(ctx context.Context, module string, w Written)
|
|
// Grants watches the grants part's state (its key delivery.GrantsUserListKey); nil writes nothing.
|
|
Grants agent.Watcher
|
|
// Fetch reads an object of the grants part's bucket by its name; Open opens a box sealed to this node's key; Key is
|
|
// this node's sealing key's public half (SPKI PEM), said in the have so the grants part seals to it ("" unknown).
|
|
Fetch func(ctx context.Context, name string) ([]byte, error)
|
|
Open func(seal.Box) ([]byte, error)
|
|
Key func() string
|
|
// Kept is the grants part's key as last heard on this machine and its revision (nil when none), and Keep keeps it:
|
|
// what the follower follows while the key cannot be watched (ADR 0547 clause 8). Backoff is the wait before the
|
|
// try-th watch again.
|
|
Kept func() ([]byte, uint64)
|
|
Keep func(value []byte, rev uint64)
|
|
Backoff func(try int) time.Duration
|
|
Now func() time.Time
|
|
Say func(string)
|
|
}
|
|
|
|
type source struct {
|
|
module string
|
|
resource json.RawMessage
|
|
}
|
|
|
|
// Follower follows the grants part's user list into the file the bus module this node runs declares.
|
|
type Follower struct {
|
|
env Env
|
|
kick chan struct{}
|
|
// looks asks a look at what runs (RunsChanged): written only when the file it names moved.
|
|
looks chan struct{}
|
|
mu sync.Mutex
|
|
srcs map[string]source // by module, of what runs now
|
|
// cur is the source last taken, and written whether its write went through.
|
|
cur source
|
|
written bool
|
|
state Written
|
|
mod string
|
|
said map[string]bool
|
|
// grants is the grants part's key as last heard (nil when none).
|
|
grants *delivery.GrantsUserList
|
|
grantsRev uint64
|
|
// unwatched is why the grants part's key is not watched now; empty while it is, or where it need not be.
|
|
unwatched string
|
|
}
|
|
|
|
// New is a follower for a node.
|
|
func New(env Env) *Follower {
|
|
if env.Now == nil {
|
|
env.Now = time.Now
|
|
}
|
|
if env.Say == nil {
|
|
env.Say = func(string) {}
|
|
}
|
|
if env.Backoff == nil {
|
|
env.Backoff = func(int) time.Duration { return time.Minute }
|
|
}
|
|
f := &Follower{env: env, kick: make(chan struct{}, 1), looks: make(chan struct{}, 1), srcs: map[string]source{},
|
|
said: map[string]bool{}}
|
|
if env.Grants != nil && env.Kept != nil {
|
|
if raw, rev := env.Kept(); raw != nil {
|
|
if g, err := delivery.DecodeGrantsUserList(raw); err == nil {
|
|
f.grants, f.grantsRev = &g, rev
|
|
}
|
|
}
|
|
}
|
|
return f
|
|
}
|
|
|
|
// Again asks the follower to look at what runs and the file again: when what runs changed, after an apply of the bus
|
|
// module, and when a person asks. It never blocks.
|
|
func (f *Follower) Again() {
|
|
select {
|
|
case f.kick <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// RunsChanged asks the follower to look at what this node runs again: the list is written again only when the file the
|
|
// bus module declares moved. It never blocks.
|
|
func (f *Follower) RunsChanged() {
|
|
select {
|
|
case f.looks <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// State is the module whose list the follower writes ("" when nothing this node runs carries one) and what it last did.
|
|
func (f *Follower) State() (string, Written) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.mod, f.state
|
|
}
|
|
|
|
// Run follows the grants part's list until ctx ends.
|
|
func (f *Follower) Run(ctx context.Context) error {
|
|
f.RunsChanged() // the first look at what runs, at once
|
|
// The grants part's key, on the node whose compositions carry the list: the bus node alone is granted it (mesh-sdk
|
|
// buscompose.GrantsUserListFollows). Until its watch caught up nothing is written, so a new follower (one per link)
|
|
// never writes a list older than the key; a watch refused or ended is tried again on the backoff, and meanwhile the
|
|
// key last heard (kept on the machine) holds.
|
|
var grants <-chan agent.Update
|
|
var retry <-chan time.Time
|
|
opened, grantsCaught, tries := false, false, 0
|
|
failed := func(why string) {
|
|
grants, tries = nil, tries+1
|
|
retry = time.After(f.env.Backoff(tries))
|
|
f.mu.Lock()
|
|
f.unwatched = why
|
|
f.mu.Unlock()
|
|
f.sayOnce("the grants part's user-list key is not watched, tried again: " + why)
|
|
}
|
|
open := func() {
|
|
if grants != nil || retry != nil || f.env.Grants == nil || !f.busNode() {
|
|
return
|
|
}
|
|
opened = true
|
|
ch, err := f.env.Grants.Watch(ctx, delivery.GrantsUserListKey)
|
|
if err != nil {
|
|
failed(err.Error())
|
|
return
|
|
}
|
|
grants = ch
|
|
}
|
|
for {
|
|
again := false
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil
|
|
case <-f.kick:
|
|
again = true
|
|
f.look()
|
|
case <-f.looks:
|
|
f.look()
|
|
case <-retry:
|
|
retry = nil
|
|
case u, ok := <-grants:
|
|
if !ok {
|
|
failed("its watch ended")
|
|
break
|
|
}
|
|
if u.Caught {
|
|
grantsCaught, tries = true, 0
|
|
f.mu.Lock()
|
|
f.unwatched = ""
|
|
f.mu.Unlock()
|
|
break
|
|
}
|
|
f.takeGrants(u)
|
|
}
|
|
open()
|
|
if opened && !grantsCaught && grants != nil {
|
|
continue // the switch is not heard yet: nothing is written before it is
|
|
}
|
|
f.mu.Lock()
|
|
if f.unwatched != f.state.Unwatched {
|
|
again = true // the have says whether the key is watched
|
|
}
|
|
f.mu.Unlock()
|
|
f.settle(ctx, again)
|
|
}
|
|
}
|
|
|
|
// busNode says something this node runs carries the bus's user list, or did.
|
|
func (f *Follower) busNode() bool {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.cur.module != "" || len(f.srcs) > 0
|
|
}
|
|
|
|
// look reads what this node runs now for the bus's user list: each module whose composition carries it.
|
|
func (f *Follower) look() {
|
|
var running []engcomp.Composed
|
|
if f.env.Running != nil {
|
|
running = f.env.Running()
|
|
}
|
|
srcs := map[string]source{}
|
|
for _, c := range running {
|
|
if c.Node != "" && c.Node != f.env.Node {
|
|
continue
|
|
}
|
|
raw, _, _, ok := userList(c.Resources)
|
|
if !ok {
|
|
continue
|
|
}
|
|
module := c.Module
|
|
if module == "" {
|
|
module = agent.NodeModule
|
|
}
|
|
srcs[module] = source{module: module, resource: raw}
|
|
}
|
|
f.mu.Lock()
|
|
f.srcs = srcs
|
|
f.mu.Unlock()
|
|
}
|
|
|
|
// takeGrants keeps the grants part's key as heard. A key deleted, or a value it cannot read, is said and changes nothing:
|
|
// the key as last heard stands and the list loaded stays (novox/hq ADR 0571 clause 8, ADR 0547 clause 11; issue 1126).
|
|
func (f *Follower) takeGrants(u agent.Update) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if u.Deleted {
|
|
f.sayOnceLocked("the grants part's user-list key is deleted, so the list loaded is kept")
|
|
return
|
|
}
|
|
g, err := delivery.DecodeGrantsUserList(u.Value)
|
|
if err != nil {
|
|
f.sayOnceLocked("the grants part's user-list key cannot be read, so the list loaded is kept: " + err.Error())
|
|
return
|
|
}
|
|
f.grantsRev = u.Revision
|
|
f.grants = &g
|
|
f.keep(u.Value, u.Revision)
|
|
}
|
|
|
|
// keep keeps the key last heard on the machine (Env.Keep), so a follower that cannot watch it follows what it said.
|
|
func (f *Follower) keep(value []byte, rev uint64) {
|
|
if f.env.Keep != nil {
|
|
f.env.Keep(value, rev)
|
|
}
|
|
}
|
|
|
|
func (f *Follower) unwatchedNow() string {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.unwatched
|
|
}
|
|
|
|
func (f *Follower) key() string {
|
|
if f.env.Key == nil {
|
|
return ""
|
|
}
|
|
return f.env.Key()
|
|
}
|
|
|
|
// settle writes the grants part's list: when its key moved, or when asked.
|
|
func (f *Follower) settle(ctx context.Context, again bool) { f.settleGrants(ctx, again) }
|
|
|
|
// reportable says a have's user list says something new: a look again that found the file as it was says nothing.
|
|
func reportable(prev, w Written) bool {
|
|
return prev.Source != w.Source || prev.Revision != w.Revision || prev.Digest != w.Digest || prev.Error != w.Error ||
|
|
prev.Follows != w.Follows || prev.Key != w.Key || prev.Unwatched != w.Unwatched
|
|
}
|
|
|
|
// settleGrants writes the grants part's list, the object its key names, into the file the bus module's composition
|
|
// declares, made 0600 (ADR 0547 clause 7): when the key moved, or when asked. With no key heard it writes nothing and
|
|
// says why in the have.
|
|
func (f *Follower) settleGrants(ctx context.Context, again bool) {
|
|
f.mu.Lock()
|
|
var g delivery.GrantsUserList
|
|
heard := f.grants != nil
|
|
if heard {
|
|
g = *f.grants
|
|
}
|
|
rev := f.grantsRev
|
|
// What runs now: the module written before while it still carries the list, else the first by name; what was
|
|
// written before when nothing runs that carries it now (a look between two applies).
|
|
file := f.cur
|
|
if s, ok := f.srcs[f.cur.module]; ok {
|
|
file = s
|
|
} else if len(f.srcs) > 0 {
|
|
names := make([]string, 0, len(f.srcs))
|
|
for m := range f.srcs {
|
|
names = append(names, m)
|
|
}
|
|
sort.Strings(names)
|
|
file = f.srcs[names[0]]
|
|
}
|
|
if file.module == "" {
|
|
f.mu.Unlock()
|
|
return // nothing this node runs declares the file: not the bus node
|
|
}
|
|
moved := file.module != f.cur.module || pathOf(file.resource) != pathOf(f.cur.resource)
|
|
f.cur = file
|
|
if !again && !moved && f.state.Source != "" && f.state.Revision == int64(rev) && f.written {
|
|
f.mu.Unlock()
|
|
return
|
|
}
|
|
f.mu.Unlock()
|
|
|
|
w := Written{Source: delivery.GrantsUserListSource, Revision: int64(rev), Wanted: g.Digest, At: f.env.Now().UTC(),
|
|
Follows: delivery.FollowGrants, Key: f.key(), Unwatched: f.unwatchedNow()}
|
|
var err error
|
|
var changed bool
|
|
resource, rerr := mode0600(file.resource)
|
|
switch {
|
|
case rerr != nil:
|
|
err = fmt.Errorf("the bus's user-list file this node's composition declares cannot be read: %w", rerr)
|
|
case !heard:
|
|
err = fmt.Errorf("the grants part's user-list key is not heard yet; the list on the machine is kept")
|
|
case g.Digest == "":
|
|
err = fmt.Errorf("the grants part's key names no list yet; the list on the machine is kept")
|
|
case f.env.Fetch == nil || f.env.Open == nil:
|
|
err = fmt.Errorf("this engine cannot read or open the grants part's list")
|
|
case g.SealedTo != "" && w.Key != "" && g.SealedTo != delivery.SealingKeyID(w.Key):
|
|
err = fmt.Errorf("the grants part's list is sealed to another key than this node's (%s); the list on the machine is kept until it seals to this one", g.SealedTo)
|
|
}
|
|
var body []byte
|
|
if err == nil {
|
|
var object []byte
|
|
if object, err = f.env.Fetch(ctx, g.Object); err == nil {
|
|
body, err = delivery.OpenUserList(object, g.Digest, f.env.Open)
|
|
}
|
|
if err != nil {
|
|
err = fmt.Errorf("the grants part's list %s could not be had: %w", short(g.Digest), err)
|
|
}
|
|
}
|
|
if err == nil {
|
|
w.Users = int64(Users(body))
|
|
if err = f.guard(resource, body); err != nil {
|
|
if disk, derr := f.env.OnDisk(resource); derr == nil && disk != nil {
|
|
w.Digest = Digest(disk)
|
|
}
|
|
}
|
|
}
|
|
if err == nil {
|
|
w.Digest, changed, err = f.env.Write(ctx, file.module, resource, body)
|
|
} else if w.Digest == "" && f.env.OnDisk != nil {
|
|
if disk, derr := f.env.OnDisk(resource); derr == nil && disk != nil {
|
|
w.Digest = Digest(disk)
|
|
}
|
|
}
|
|
if err != nil {
|
|
w.Error = err.Error()
|
|
}
|
|
f.mu.Lock()
|
|
prev := f.state
|
|
// The grants part's list is not handed to the vault: a handing written before stands (clause 9).
|
|
w.Handed = prev.Handed
|
|
f.mod, f.state = file.module, w
|
|
f.written = err == nil
|
|
f.mu.Unlock()
|
|
at := fmt.Sprintf("%s@%d", delivery.GrantsUserListSource, rev)
|
|
if err != nil {
|
|
f.sayOnce(fmt.Sprintf("the bus's user list of %s could not be written: %v", at, err))
|
|
} else if changed {
|
|
f.env.Say(fmt.Sprintf("the bus's user list from the grants part (%s, revision %d) is written alone (%d users, %s); the bus reloads it itself",
|
|
at, g.Revision, w.Users, short(w.Digest)))
|
|
}
|
|
if f.env.Report != nil && (changed || reportable(prev, w)) {
|
|
f.env.Report(ctx, file.module, w)
|
|
}
|
|
}
|
|
|
|
// Hold is c as the apply may write it (C3 of the switch's review): the list a composition carries is never written, so
|
|
// its user-list resource holds the list on the machine instead (made 0600), or is left out when the machine holds none.
|
|
// A list on the machine that cannot be read is an error: left out, the apply would remove the file as an orphan and the
|
|
// bus would refuse every connection at its next reload, so the bus module's apply waits instead. A composition without
|
|
// a user list is c unchanged.
|
|
func (f *Follower) Hold(c engcomp.Composed) (engcomp.Composed, error) {
|
|
out := c
|
|
out.Resources = nil
|
|
for _, raw := range c.Resources {
|
|
if r, _, _, ok := userList([]json.RawMessage{raw}); ok {
|
|
var held json.RawMessage
|
|
body, err := f.env.OnDisk(r)
|
|
if err != nil {
|
|
return c, fmt.Errorf("the user list on the machine could not be read, so the apply waits and the file stays: %w", err)
|
|
}
|
|
if body != nil {
|
|
var m map[string]any
|
|
if json.Unmarshal(r, &m) == nil {
|
|
delete(m, "digest")
|
|
delete(m, "size")
|
|
m["content"], m["mode"] = string(body), "0600"
|
|
held, _ = json.Marshal(m)
|
|
}
|
|
}
|
|
if held != nil {
|
|
out.Resources = append(out.Resources, held)
|
|
}
|
|
continue
|
|
}
|
|
out.Resources = append(out.Resources, raw)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// mode0600 is a user-list resource made mode 0600, whatever its composition said (ADR 0547 clause 7).
|
|
func mode0600(raw json.RawMessage) (json.RawMessage, error) {
|
|
if len(raw) == 0 {
|
|
return nil, fmt.Errorf("no resource")
|
|
}
|
|
var r map[string]any
|
|
if err := json.Unmarshal(raw, &r); err != nil {
|
|
return nil, err
|
|
}
|
|
r["mode"] = "0600"
|
|
return json.Marshal(r)
|
|
}
|
|
|
|
// guard refuses a list that would lock the mesh out of its own bus: one that drops more than half the users the file on
|
|
// the machine holds. A list that shrinks that far is a composer that read half the mesh, not half the mesh gone; it
|
|
// waits for a person, and the bus keeps the list it has. (A list without the controller's user is no longer refused:
|
|
// the controller ended, issue 1126.)
|
|
func (f *Follower) guard(resource json.RawMessage, body []byte) error {
|
|
names := Names(body)
|
|
if f.env.OnDisk == nil {
|
|
return nil
|
|
}
|
|
disk, err := f.env.OnDisk(resource)
|
|
if err != nil || disk == nil {
|
|
return nil
|
|
}
|
|
if had := Users(disk); had > 0 && len(names)*2 < had {
|
|
return fmt.Errorf("refused: the list holds %d users and the one on the machine %d; a list that drops more "+
|
|
"than half is not written, and the list on the machine is kept", len(names), had)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *Follower) sayOnceLocked(line string) {
|
|
if !f.said[line] {
|
|
f.said[line] = true
|
|
f.env.Say(line)
|
|
}
|
|
}
|
|
|
|
func (f *Follower) sayOnce(line string) {
|
|
f.mu.Lock()
|
|
said := f.said[line]
|
|
f.said[line] = true
|
|
f.mu.Unlock()
|
|
if !said {
|
|
f.env.Say(line)
|
|
}
|
|
}
|
|
|
|
// userList is the bus's user list among a composition's resources: the resource, its content, and its digest when it
|
|
// is given by digest rather than content.
|
|
func userList(resources []json.RawMessage) (json.RawMessage, string, string, bool) {
|
|
for _, raw := range resources {
|
|
var head struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Content string `json:"content"`
|
|
Digest string `json:"digest"`
|
|
}
|
|
if json.Unmarshal(raw, &head) != nil || head.Type != "file" {
|
|
continue
|
|
}
|
|
if head.ID == ResourceID || strings.HasSuffix(head.ID, "."+ResourceID) {
|
|
return raw, head.Content, head.Digest, true
|
|
}
|
|
}
|
|
return nil, "", "", false
|
|
}
|
|
|
|
// pathOf is where a file resource says its file is; "" when it says none.
|
|
func pathOf(raw json.RawMessage) string {
|
|
var head struct {
|
|
Path string `json:"path"`
|
|
}
|
|
_ = json.Unmarshal(raw, &head)
|
|
return head.Path
|
|
}
|
|
|
|
// Digest is content's digest as the have says it, `sha256:<hex>`.
|
|
func Digest(content []byte) string {
|
|
sum := sha256.Sum256(content)
|
|
return "sha256:" + hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// userEntry is one user of the list as the composer writes it (and the bus module's own tools read it).
|
|
var userEntry = regexp.MustCompile(`\{\s*user:\s*"([^"]+)"`)
|
|
|
|
// Users is how many users a list holds.
|
|
func Users(content []byte) int { return len(userEntry.FindAllIndex(content, -1)) }
|
|
|
|
// Names is the users a list holds, as it names them.
|
|
func Names(content []byte) []string {
|
|
var out []string
|
|
for _, m := range userEntry.FindAllSubmatch(content, -1) {
|
|
out = append(out, string(m[1]))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func short(d string) string {
|
|
d = strings.TrimPrefix(d, "sha256:")
|
|
if len(d) > 12 {
|
|
d = d[:12]
|
|
}
|
|
return d
|
|
}
|