The bus module on the bus node runs from what its agent holds (its own composition, or the declaration kept before the switch: every novox have read 2026-11-02 names its own composition or none, and nats names a build alone), while composed-<node> was written by the controller, which ended (issue 1126). The follower now looks at the agent's record of what runs whenever it changed, writes again only when the file it names moved, and trusts no composition signer. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
183 lines
6.2 KiB
Go
183 lines
6.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/declaration"
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
"git.novox.be/novox/mesh-sdk/go/seal"
|
|
"github.com/novox/mesh-host/internal/apply"
|
|
"github.com/novox/mesh-host/internal/identity"
|
|
"github.com/novox/mesh-host/internal/link"
|
|
"github.com/novox/mesh-host/internal/store"
|
|
"github.com/novox/mesh-host/internal/system"
|
|
"github.com/novox/mesh-host/internal/userlist"
|
|
)
|
|
|
|
// The bridge of novox/hq ADR 0547 §8, wired: the bus node's engine writes the grants part's user list alone into the file
|
|
// the bus module's composition declares, under the apply's two locks as a filled file is written (filling.go), and says what
|
|
// it wrote in the bus module's have.
|
|
|
|
// userListFollower is the follower of the bus's user list for one link: the node's composition bucket, the keys the
|
|
// agent trusts, the write alone, and the have.
|
|
func userListFollower(opts options, session *link.Session, running func() []delivery.Composed,
|
|
say link.Announce, wrote func()) *userlist.Follower {
|
|
return userlist.New(userlist.Env{
|
|
Node: session.Membership.Node,
|
|
Running: running,
|
|
OnDisk: userListOnDisk,
|
|
Write: func(ctx context.Context, module string, raw json.RawMessage, body []byte) (string, bool, error) {
|
|
digest, changed, err := writeUserListAlone(session.WithLink(ctx), opts, raw, body, say)
|
|
if changed && wrote != nil {
|
|
wrote()
|
|
}
|
|
return digest, changed, err
|
|
},
|
|
Report: func(ctx context.Context, module string, w userlist.Written) {
|
|
session.NoteUserList(ctx, module, w)
|
|
},
|
|
// From take-over, the grants part's list (ADR 0547 clauses 7, 8): its one key watched, its object read by name and
|
|
// opened with this machine's sealing key, the key's public half said in the have so the grants part seals to it.
|
|
Grants: bucketWatch{session, delivery.GrantsUserListBucket},
|
|
Fetch: func(ctx context.Context, name string) ([]byte, error) {
|
|
return session.GrantsObject(ctx, name, grantsListLimit)
|
|
},
|
|
Open: func(b seal.Box) ([]byte, error) {
|
|
key, err := identity.LoadSealingKey(identity.SealingKeyPath(opts.state))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return key.OpenVaultBox(b)
|
|
},
|
|
Key: func() string {
|
|
key, err := identity.LoadSealingKey(identity.SealingKeyPath(opts.state))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
pem, err := key.PublicPEM()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return pem
|
|
},
|
|
// The grants part's key as last heard, kept beside the node's state, followed while it cannot be watched; its
|
|
// watch tried again as a passing failure of the bus is (ADR 0547 clause 8).
|
|
Kept: func() ([]byte, uint64) { return keptGrantsKey(opts) },
|
|
Keep: func(value []byte, rev uint64) { keepGrantsKey(opts, value, rev, say) },
|
|
Backoff: link.Backoff,
|
|
Say: announceOr(say),
|
|
})
|
|
}
|
|
|
|
// grantsListLimit is the largest sealed list the bus node reads: the live list is about a hundred kilobytes.
|
|
const grantsListLimit = 16 << 20
|
|
|
|
// userListFile is a user-list resource as the file it declares.
|
|
func userListFile(raw []byte) (*declaration.File, error) {
|
|
r, err := declaration.ParseResource(raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the user list is not a resource: %w", err)
|
|
}
|
|
f, ok := r.(*declaration.File)
|
|
if !ok {
|
|
return nil, fmt.Errorf("the user list is a %s, not a file", r.Kind())
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
// userListOnDisk is what the user list's file holds on this machine now; nil when there is none.
|
|
func userListOnDisk(raw json.RawMessage) ([]byte, error) {
|
|
f, err := userListFile(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
body, err := os.ReadFile(f.Path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, nil
|
|
}
|
|
return body, err
|
|
}
|
|
|
|
// writeUserListAlone writes one user-list resource alone (apply.WriteAlone) with body as its content, the node's state
|
|
// read and saved under the apply's locks, and answers the digest of the file on the machine after.
|
|
func writeUserListAlone(ctx context.Context, opts options, raw []byte, body []byte, say link.Announce) (string, bool, error) {
|
|
declared, err := userListFile(raw)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
file := *declared
|
|
file.Content, file.Digest, file.Size = string(body), "", 0
|
|
|
|
applying.Lock()
|
|
defer applying.Unlock()
|
|
unlock, err := store.Lock(opts.state, nil)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
defer unlock()
|
|
known, err := store.Load(opts.state)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
built, err := system.For(builtFor)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
out, updated, writeErr := apply.WriteAlone(ctx, built, &file, known, apply.ExecRunner, announceOr(say))
|
|
// Saved whichever way it went, as an apply's: what was written is on the machine either way.
|
|
if err := store.Save(opts.state, updated); err != nil {
|
|
writeErr = errors.Join(writeErr, errors.New("written, and the node's state could not be saved: "+err.Error()))
|
|
}
|
|
digest := ""
|
|
if body, err := os.ReadFile(file.Path); err == nil {
|
|
digest = userlist.Digest(body)
|
|
}
|
|
return digest, writeErr == nil && out.Action != "unchanged", writeErr
|
|
}
|
|
|
|
// grantsKeyPath is where the grants part's user-list key last heard is kept, beside the node's state.
|
|
func grantsKeyPath(opts options) string {
|
|
return filepath.Join(filepath.Dir(opts.state), "grants-user-list.json")
|
|
}
|
|
|
|
type keptKey struct {
|
|
Revision uint64 `json:"revision"`
|
|
Value json.RawMessage `json:"value"`
|
|
}
|
|
|
|
func keptGrantsKey(opts options) ([]byte, uint64) {
|
|
raw, err := os.ReadFile(grantsKeyPath(opts))
|
|
if err != nil {
|
|
return nil, 0
|
|
}
|
|
var k keptKey
|
|
if json.Unmarshal(raw, &k) != nil || len(k.Value) == 0 {
|
|
return nil, 0
|
|
}
|
|
return k.Value, k.Revision
|
|
}
|
|
|
|
func keepGrantsKey(opts options, value []byte, rev uint64, say link.Announce) {
|
|
path := grantsKeyPath(opts)
|
|
if value == nil {
|
|
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
announceOr(say)("the grants part's user-list key kept on this machine could not be taken away: " + err.Error())
|
|
}
|
|
return
|
|
}
|
|
raw, _ := json.Marshal(keptKey{Revision: rev, Value: value})
|
|
tmp := path + ".new"
|
|
err := os.WriteFile(tmp, raw, 0o600)
|
|
if err == nil {
|
|
err = os.Rename(tmp, path)
|
|
}
|
|
if err != nil {
|
|
announceOr(say)("the grants part's user-list key could not be kept on this machine: " + err.Error())
|
|
}
|
|
}
|