Files
mesh-delivery/modules/mesh-grants/internal/grants/tools.go
T
jschoubben 7ec6767418 mesh-grants holds the seat mesh-grants and writes the controller's lease on the operator's warrant
ADR 0582: lease-write is refused, naming each check, until the lease check at phase write and every condition
of clause 2 read green (design 47's earlier issues resolved, every node trusting the vault's key, mesh-config's
compare empty from the indexer, the upgrade compare empty, the keeper's take-over done, the vault minting with
nothing held back, the bus module listing its expiry and removal tools). It asks the operator once; the yes
binds the act, the holder, the ask and the checks' digest, and the checks are read again on the warrant (one
that reads otherwise refuses it, named). Then, each step read back: node-service-manager.stop on the
controller's node, the list composed with Records.LeaseCut and waited on until the bus node's have says it
loaded it, the expiry removed through nats_bucket_expiry, the key holder written (with the ask and the time),
lease-handed said, the act recorded through mesh-hand-acts.warranted, and removal-due written with a day to
live. lease-altered is said once when the key or the expiry differs; lease-hand-back reverses it for a day
and is refused once the removal ran; removal-due's marker alone starts the removal of the controller's
consumer and the stream ASSIGNMENTS, recorded as hand acts, and a failure says removal-failed and writes the
key again with a day (grants_removal_due is the recovery for a missed marker). From the write memberships are
written to the state alone (ADR 0571 clause 10), the user-list key says lease-handed, and the user list's
hand-back answers gone (ADR 0547 §11). Every step waits on an event, never a timer.

Bent rule: S14-S21 are one commit, not eight: the steps are one state machine in leasewrite.go, and splitting
it by hunks would review the same file eight times for nothing it protects.

Tests: TestTheLeaseWrite (new, one function with a subtest per subtask), paid by the deletion of
TestTheKeepListNamesItsRecordsAndThreeToTheWrite (moved to mesh-sdk, S4/S30) and the assigner's two deletions
(S25). Fixtures shaped from each writer's code: systemd's Manager.Status, nats-tools' BucketExpirySet,
ConsumerRemoved and StreamRemoved, mesh-issues' Brief, mesh-delivery-view's TrustsAnswer, mesh-config's
Comparison, the assigner's upgrade compare, mesh-vault's vault_status.

(issue 774, 774/S14, 774/S15, 774/S16, 774/S17, 774/S18, 774/S19, 774/S20, 774/S21)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 00:31:35 +01:00

320 lines
12 KiB
Go

package grants
import (
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/membership"
)
// The part's tools, as its manifest lists them. Module tools in this milestone: a `mesh-*` seat must be in mesh-sdk's own
// seat list, which the running controller's composer does not know, so the part claims no seat until take-over.
const (
ToolGrants = "grants_grants"
ToolMembership = "grants_membership"
ToolUserList = "grants_user_list"
ToolComposeNow = "grants_compose_now"
ToolAssert = "grants_assert"
ToolTakeOver = "grants_take_over"
ToolTakeOverState = "grants_take_over_state"
ToolHandBack = "grants_hand_back"
ToolAccounts = "grants_accounts"
ToolConsumerMatch = "grants_consumer_match"
ToolAcceptHashes = "grants_accept_hashes"
)
// Tools is every tool, in the manifest's order.
var Tools = []string{ToolGrants, ToolMembership, ToolUserList, ToolComposeNow, ToolAssert, ToolTakeOver, ToolHandBack, ToolTakeOverState,
ToolAccounts, ToolConsumerMatch, ToolAcceptHashes, ToolLeaseCheck, ToolRemovalDue}
// SeatTool is the tool serving one of the seat mesh-grants' verbs, `mesh-grants.<verb>`.
func SeatTool(verb string) string { return catalogue.GrantsSeat + "." + verb }
// WritesGrants is what grants says of the part's own grants: written once the list the bus loaded carries a build's
// accounts, and the one key the assigner reads (ADR 0571 clause 9, which changes ADR 0547 clause 5).
const WritesGrants = "the grants part writes grants.<module>.<fp> into its state grants, once the list the bus loaded carries that build's accounts; it is the one key the assigner reads (ADR 0571 clause 9)"
// Grants answers the tool grants: the grants the part wrote, one module's or all.
func (p *Part) Grants(module string) (any, error) {
out := map[string]any{"writes": WritesGrants}
read := func(get func() ([]string, func(string) ([]byte, bool, error), error)) (map[string]delivery.ControllerGrant, error) {
keys, one, err := get()
if err != nil {
return nil, err
}
m := map[string]delivery.ControllerGrant{}
for _, k := range keys {
mod, _, err := delivery.ParseControllerGrantKey(k)
if err != nil || (module != "" && mod != module) {
continue
}
raw, found, err := one(k)
if err != nil {
return nil, err
}
if !found {
continue
}
g, err := delivery.DecodeControllerGrant(raw)
if err != nil {
return nil, fmt.Errorf("%s: %w", k, err)
}
m[k] = g
}
return m, nil
}
mine, perr := read(func() ([]string, func(string) ([]byte, bool, error), error) {
keys, err := p.Store.Keys(StateGrants)
return keys, func(k string) ([]byte, bool, error) {
raw, _, found, err := p.Store.Get(StateGrants, k)
return raw, found, err
}, err
})
type grant struct {
Key string `json:"key"`
Module string `json:"module"`
Accounts []string `json:"accounts,omitempty"`
}
list := func(m map[string]delivery.ControllerGrant) []grant {
out := []grant{}
for _, k := range sortedKeys(m) {
mod, _, _ := delivery.ParseControllerGrantKey(k)
out = append(out, grant{Key: k, Module: mod, Accounts: m[k].Accounts})
}
return out
}
if perr != nil {
out["written_unread"] = perr.Error()
} else {
out["written"], out["count"] = list(mine), len(mine)
}
if last := p.Last(); last.Grants != nil {
out["last_pass"] = last.Grants
}
return out, nil
}
// MembershipOf answers the tool membership: one module on one node as composed now, and as last published by the
// part's process (its state memberships says what became of each at the last pass).
func (p *Part) MembershipOf(node, module string) (any, error) {
if node == "" && module == "" {
return p.Held()
}
if node == "" || module == "" {
return nil, errors.New("membership names a node and a module, or neither for every membership held back")
}
in := ReadInputs(p.Read, &p.cache)
recs, left := Records(in)
notFree := WithRootFree(&recs, p.Call)
m, ok := Memberships(recs)[node+"."+module]
out := map[string]any{"node": node, "module": module, "gaps": MembershipGaps, "root_free": recs.RootFree, "root_not_free": notFree}
if ok {
out["composed"] = m
} else {
why := fmt.Sprintf("%s is not assigned to %s on the bus", module, node)
for _, g := range left {
if g.Input == module+" on "+node {
why = g.Why
}
}
out["composed"] = why
}
// The state holds the bare membership last published (mesh-sdk's codec, issue 774); what became of it at the last
// pass is the pass's, kept in user-list at KeyPass.
key := node + "." + module
if raw, rev, found, err := p.Store.Get(StateMemberships, key); err != nil {
out["published"] = "not readable: " + err.Error()
} else if !found {
out["published"] = "no pass of the part's process has published it yet"
} else if was, err := membership.DecodeGrantsMembership(raw); err != nil {
out["published"] = fmt.Sprintf("revision %d is not readable: %v", rev, err)
} else {
out["published"] = map[string]any{"revision": rev, "digest": digestOf(was), "membership": was}
}
out["last_pass"] = p.passOf(key)
return out, nil
}
// passOf is what the last pass the part's process kept says of one membership: published, unchanged or why not, and
// the seat verbs it dropped as retired.
func (p *Part) passOf(key string) any {
raw, rev, found, err := p.Store.Get(StateUserList, KeyPass)
if err != nil {
return "not readable: " + err.Error()
}
if !found {
return "no pass of the part's process has been kept yet"
}
var res Result
if err := json.Unmarshal(raw, &res); err != nil {
return "not readable: " + err.Error()
}
said := MembershipUnchanged
if slices.Contains(res.Published, key) {
said = MembershipPublished
}
for _, n := range res.NotPublished {
if k, why, _ := strings.Cut(n, ": "); k == key {
said = why
}
}
out := map[string]any{"revision": rev, "published": said}
if r := res.Retired[key]; len(r) > 0 {
out["retired"] = r
}
return out
}
// Held answers every membership the part's process held back at its last pass, each `<node>.<module>` with why (what
// publishing would lose), from the pass it keeps (issue 1945).
func (p *Part) Held() (any, error) {
raw, rev, found, err := p.Store.Get(StateUserList, KeyPass)
if err != nil {
return nil, fmt.Errorf("the last pass cannot be read: %w", err)
}
if !found {
return map[string]any{"held": []any{}, "says": "no pass of the part's process has been kept yet"}, nil
}
var res Result
if err := json.Unmarshal(raw, &res); err != nil {
return nil, fmt.Errorf("the last pass cannot be read: %w", err)
}
type held struct {
Membership string `json:"membership"`
Why string `json:"why"`
}
out := []held{}
for _, n := range res.NotPublished {
key, why, _ := strings.Cut(n, ": ")
out = append(out, held{Membership: key, Why: why})
}
refused := res.Refused
if refused == nil {
refused = []string{}
}
return map[string]any{"held": out, "count": len(out), "pass": rev, "published": res.Published, "refused": refused}, nil
}
// Pass composes and keeps the result as the last pass, for the tools to read.
func (p *Part) Pass() Result {
res := p.Compose()
data, _ := json.Marshal(res)
if _, err := p.Store.Put(StateUserList, KeyPass, data); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateUserList, KeyPass, err))
}
return res
}
// NoGrantAgain is what compose-now says of the controller's grants: the controller serves no verb that writes a module's
// grant again (its issue replaces the account, which is not that), so a grant write it lost stays lost until it composes
// again; the part writes grants only from ADR 0547's third milestone.
const NoGrantAgain = "missing: a verb to ask the controller to write a module's grant again (mesh-controller serves none; its issue replaces the bus account, which is not that), so compose-now cannot ask it"
// AskCompose is the tool compose-now: it asks the part's process to compose and publish now (only the process holds the
// grant to publish a membership), and answers the last pass, whose revision moves when the process answered.
func (p *Part) AskCompose(caller string) (any, error) {
ask, _ := json.Marshal(Ask{By: caller, At: p.now()})
rev, err := p.Store.Put(StateUserList, KeyAsked, ask)
if err != nil {
return nil, fmt.Errorf("the ask could not be written: %w", err)
}
out := map[string]any{"asked": rev, "says": "the part's process composes and publishes at this ask; grants_user_list's last pass says what it published",
"controller_grants": NoGrantAgain}
if raw, prev, found, err := p.Store.Get(StateUserList, KeyPass); err == nil && found {
out["last_pass"] = map[string]any{"revision": prev, "result": json.RawMessage(raw)}
}
return out, nil
}
// UserList answers the tool user-list: the shadow list as last written, beside what the bus node says it loaded now.
func (p *Part) UserList() (any, error) {
out := map[string]any{"mode": Shadow}
raw, rev, found, err := p.Store.Get(StateUserList, KeyList)
switch {
case err != nil:
out["written"] = "not readable: " + err.Error()
case !found:
out["written"] = "nothing composed yet: grants_compose_now composes it"
default:
var st UserListState
if err := json.Unmarshal(raw, &st); err != nil {
return nil, fmt.Errorf("its own user list cannot be read: %w", err)
}
out["revision"], out["written"] = rev, st
if raw, prev, found, err := p.Store.Get(StateUserList, KeyPass); err == nil && found {
out["last_pass"] = map[string]any{"revision": prev, "result": json.RawMessage(raw)}
}
if st.BusNode != "" {
if raw, found, err := p.Read.Get(ReadHave, st.BusNode+"."+BusModule); err == nil && found {
if h, err := delivery.DecodeHave(raw); err == nil {
out["loaded_now"] = h.UserList
}
}
}
}
return out, nil
}
// AssertOne answers the tool assert: one module's buckets, logs and streams as composed from its manifest at the
// indexer's trunk, beside what the bus holds, and asks the part's process (which alone holds the grant to create) to
// create what the bus lacks now, even one it created at this manifest and that is gone since (issue 1790). It changes no
// stream the bus holds. Here, in the tools, it creates nothing itself: the answer names the ask.
func (p *Part) AssertOne(module string) (any, error) {
if module == "" {
return nil, errors.New("assert names a module")
}
raw, found, err := p.Read.Get(ReadModules, module)
if err != nil {
return nil, fmt.Errorf("the indexer's %s cannot be read: %w", module, err)
}
if !found {
return nil, fmt.Errorf("the indexer holds no module %s", module)
}
v, err := delivery.DecodeModule(raw)
if err != nil || v.Manifest == "" {
return nil, fmt.Errorf("the indexer names no manifest of %s", module)
}
m, err := p.cache.get(p.Read, v.Manifest)
if err != nil {
return nil, err
}
held, heldErr := HeldStreams(p.Call)
a := Assert(module, v.Manifest, m, held, heldErr)
out := map[string]any{"assertion": a}
missing := 0
for _, it := range a.Items {
if it.Held == nil {
missing++
}
}
if missing == 0 {
out["says"] = "the bus holds every bucket, log and stream the manifest declares: nothing to create; a difference is only said"
return out, nil
}
ask, _ := json.Marshal(Ask{By: "grants_assert", At: p.now(), Assert: module})
rev, err := p.Store.Put(StateUserList, KeyAsked, ask)
if err != nil {
return nil, fmt.Errorf("the ask to create could not be written: %w", err)
}
out["asked"] = rev
out["says"] = fmt.Sprintf("%d the bus lacks: the part's process creates them at this ask if %s is placed (its grant names only placed "+
"modules' streams) and says asserted; grants_assert again shows what the bus holds then", missing, module)
return out, nil
}
// Answer is a tool's arguments read as text.
func Answer(args map[string]any, name string) string {
switch v := args[name].(type) {
case string:
return strings.TrimSpace(v)
case bool:
return fmt.Sprint(v)
}
return ""
}