Revert L3 (mesh-delivery, merge d827420): its node-engine removed every held module on novox, the bus server among them, at 08:54 CET on 2026-10-29 (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-delivery@c1b4533201c8
mesh/delivery-group group revert/774-l3 merged: every member is merged
mesh/check being carried to 6771db22ae69 on d8274204fbed | from mesh-catalog 1555241393fe | novox/mesh-delivery#109 in group revert/774-l3

L3 lands again with two fixes: a held module is never removed, and the grants cover L3's new subjects.
This commit is contained in:
2026-10-29 09:42:29 +01:00
parent d8274204fb
commit 6771db22ae
74 changed files with 1574 additions and 2550 deletions
@@ -214,10 +214,6 @@ func (s ownStore) Put(state, key string, value []byte) (uint64, error) {
return s.c.State(state).Put(key, json.RawMessage(value))
}
func (s ownStore) Keys(state string) ([]string, error) { return s.c.State(state).Keys() }
func (s ownStore) Delete(state, key string) error { return s.c.State(state).Delete(key) }
// emitting is the process connection as the part's emitter: its events, with no options.
type emitting struct{ c *process.Conn }
@@ -18,41 +18,6 @@ type creating struct {
js jetstream.JetStream
}
// CreateConsumer creates one durable consumer an account needs, by its stream and name (ADR 0561 §6): the create alone;
// one the bus holds answers ErrHeld and is left as it is.
func (c creating) CreateConsumer(stream string, cfg jetstream.ConsumerConfig) error {
ctx, cancel := context.WithTimeout(c.ctx, requestWithin)
defer cancel()
if _, err := c.js.Consumer(ctx, stream, cfg.Durable); err == nil {
return grants.ErrHeld
}
_, err := c.js.CreateConsumer(ctx, stream, cfg)
switch {
case err == nil:
return nil
case errors.Is(err, jetstream.ErrConsumerExists):
return grants.ErrHeld
case errors.Is(err, context.DeadlineExceeded):
return fmt.Errorf("no answer in %s: a create the bus refuses is answered by none (%w)", requestWithin, err)
}
return err
}
// MatchConsumer brings a held consumer to cfg on a person's ask (grants_consumer_match), keeping where it delivers from:
// the server refuses a changed deliver policy, and a consumer moved would replay or lose what it holds.
func (c creating) MatchConsumer(stream string, cfg jetstream.ConsumerConfig) error {
ctx, cancel := context.WithTimeout(c.ctx, requestWithin)
defer cancel()
held, err := c.js.Consumer(ctx, stream, cfg.Durable)
if err != nil {
return err
}
have := held.CachedInfo().Config
cfg.DeliverPolicy, cfg.OptStartSeq, cfg.OptStartTime = have.DeliverPolicy, have.OptStartSeq, have.OptStartTime
_, err = c.js.UpdateConsumer(ctx, stream, cfg)
return err
}
func (c creating) CreateDeclared(cfg jetstream.StreamConfig) error {
ctx, cancel := context.WithTimeout(c.ctx, requestWithin)
defer cancel()
+12 -9
View File
@@ -62,9 +62,14 @@ func main() {
}
}
// keptStates is what the part keeps (ADR 0495 §2-§3): each readable, for the view and a session, but the wanted accounts,
// by the vault alone (ADR 0561 §2).
func keptStates() []announce.State { return grants.Announced() }
// keptStates is what the part keeps, each readable (ADR 0495 §2-§3): the view and a session read them.
func keptStates() []announce.State {
var out []announce.State
for _, s := range grants.States {
out = append(out, announce.State{Name: s, Readable: true})
}
return out
}
// run is the process: it returns when ctx ends, or when it has no credential or cannot dial the bus at all. A bucket or
// read the bus refuses never ends it.
@@ -112,9 +117,8 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
}
bus := newBusReader(ctx, js)
f.Part = &grants.Part{Read: bus, Call: conn, Store: ownStore{conn}, Emit: emitting{conn},
Publish: memberships{ctx: ctx, nc: nc, js: js}, Create: creating{ctx: ctx, js: js}, Consumers: creating{ctx: ctx, js: js},
Objects: sealedObjects{ctx: ctx, js: js}, Logf: logger.Printf,
Bus: grants.BusAddress{URL: account.URL, Fingerprint: account.Fingerprint}}
Publish: memberships{ctx: ctx, nc: nc, js: js}, Create: creating{ctx: ctx, js: js},
Objects: sealedObjects{ctx: ctx, js: js}, Logf: logger.Printf}
f.Watch = bus
// The operator's word on take-over and hand-back is their yes on their channel: the part asks it itself, and acts on
// the warrant once (mesh-sdk asker), after its refusals.
@@ -134,9 +138,8 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
})
}
// The controller makes this module's durable consumer on EVENTS from the manifest's consumes (filtered on the two
// condition subjects and the vault's account-issued; ADR 0561 §6 gives that to the part from the lease write); it is
// pulled and every message acknowledged, so its pending never grows. Where it is not there yet the pass says so and
// hears again at the next change. The vault's bus-accounts is watched beside it, so a hash is composed either way.
// condition subjects); it is pulled and every message acknowledged, so its pending never grows. Where it is not there
// yet the pass says so and hears again at the next change.
f.Hear = func(handle func(string, []byte)) error {
cons, err := js.Consumer(ctx, "EVENTS", account.Node+"_"+account.Module)
if err != nil {
@@ -2,7 +2,6 @@ package main
import (
"encoding/json"
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
@@ -40,10 +39,6 @@ func (runtimeStore) Put(state, key string, value []byte) (uint64, error) {
return stdio.State(state).Put(key, json.RawMessage(value))
}
func (runtimeStore) Keys(state string) ([]string, error) { return stdio.State(state).Keys() }
func (runtimeStore) Delete(state, key string) error { return stdio.State(state).Delete(key) }
// runtimeBus calls tools and says events through the runtime.
type runtimeBus struct{}
@@ -118,43 +113,6 @@ func toolsOf(p *grants.Part) []stdio.Tool {
Run: func(a map[string]any) (any, error) {
return p.HandBack(stdio.CallerOf(a), grants.Answer(a, "why"))
}},
{Name: grants.ToolAccounts, Description: "The bus accounts the mesh wants (novox/hq ADR 0561 §2, §9): every account the vault " +
"mints that placement composes (a module's, a process's, a machine's provisioner client's), as the grants part last " +
"wrote it to its state accounts, which the vault alone reads: user, kind, node, module, process, the bus's address and " +
"fingerprint, a module's claims with the verbs each seat promises, and when the key was written; one user's or all; " +
"what the last pass wrote, deleted or kept; and the durable consumers the accounts need as the last pass found them " +
"(missing, differing and why, created; whether the lease is handed away). Never a password. Read-only: " +
"grants_compose_now writes them again.",
Input: schema(map[string]string{"user": "one bus user, e.g. ace.mesh-build-go"}),
Run: func(a map[string]any) (any, error) { return p.Accounts(grants.Answer(a, "user")) }},
{Name: grants.ToolConsumerMatch, Description: "Bring one durable consumer an account needs (a module's on EVENTS, a process's on " +
"its work queue; novox/hq ADR 0561 §6) to the configuration the bus composer composes, by hand: the recovery when " +
"the condition mesh-grants.consumer-differs names it. Asks the part's process, which alone may, and keeps where the " +
"consumer delivers from. Refused while the controller holds its lease (the consumers are the controller's then) and " +
"for a consumer the records do not compose. grants_user_list's last pass says what became of it.",
Input: schema(map[string]string{"stream": "the stream, e.g. EVENTS", "consumer": "the consumer, e.g. ace_mesh-build-go"}, "stream", "consumer"),
Run: func(a map[string]any) (any, error) {
return p.MatchConsumer(grants.Answer(a, "stream"), grants.Answer(a, "consumer"))
}},
{Name: grants.ToolAcceptHashes, Description: "Release the hold of a user list that changes several loaded users' hashes at " +
"once (novox/hq ADR 0561 §7; the condition mesh-grants.user-list-held names them), by a person's act with why: names " +
"exactly the users held, bound to the held list's digest, so the part's process writes that list once at its next pass " +
"and says hashes-accepted. It never releases another hold (a failed read, a user dropped for want of a hash, a lost " +
"right), and a list composed otherwise since is held again for a new acceptance.",
Input: map[string]any{"type": "object", "properties": map[string]any{
"users": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "the users held, as the condition names them"},
"why": map[string]any{"type": "string", "description": "why their hashes change"}}, "required": []string{"users", "why"}},
Run: func(a map[string]any) (any, error) {
var users []string
if list, ok := a["users"].([]any); ok {
for _, u := range list {
if s, ok := u.(string); ok {
users = append(users, strings.TrimSpace(s))
}
}
}
return p.AcceptHashes(stdio.CallerOf(a), users, grants.Answer(a, "why"))
}},
{Name: grants.ToolTakeOverState, Description: "Where the switch stands: the list the bus node follows, the ask open now, the request, and " +
"every act with what became of it (asked, switched, declined, expired, refused and why).",
Input: schema(nil),
+1 -1
View File
@@ -3,7 +3,7 @@ module mesh-grants
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028205404-8eafc0c5ce3c
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028191251-5228a5782d5c
github.com/nats-io/nats.go v1.54.0
)
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028205404-8eafc0c5ce3c h1:hcqOCd5q3M0ZiEzB6MhwxT/AOKkm9gaskKqPcpkAAg8=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028205404-8eafc0c5ce3c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028191251-5228a5782d5c h1:ngcItoLuh2uP24toZvkCXGA/3+TBrLTwTbE28XpYDWY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028191251-5228a5782d5c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+3 -554
View File
@@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"io/fs"
"maps"
"os"
"path/filepath"
"slices"
@@ -58,45 +57,6 @@ type fakeBus struct {
refuseUsers bool
// rootFree answers the controller's root-free verb: nil is no answerer at all (no responders).
rootFree func(machines []string) (any, error)
// consumers are the durable consumers the bus holds, by stream, as nats_streams answers them for one stream; and
// consumerCreates each consumer the part asked to create, `<stream>.<name>`.
consumers map[string][]map[string]any
consumerCreates []string
}
// MatchConsumer is the bus's update of a held durable consumer to the configuration given.
func (b *fakeBus) MatchConsumer(stream string, cfg jetstream.ConsumerConfig) error {
b.mu.Lock()
defer b.mu.Unlock()
b.consumerCreates = append(b.consumerCreates, "match "+stream+"."+cfg.Durable)
for _, c := range b.consumers[stream] {
if c["name"] == cfg.Durable {
c["max_deliver"] = cfg.MaxDeliver
return nil
}
}
return errors.New("consumer not found")
}
// CreateConsumer is the bus's create of a durable consumer: held when the bus holds one of that name, else held from now.
func (b *fakeBus) CreateConsumer(stream string, cfg jetstream.ConsumerConfig) error {
b.mu.Lock()
defer b.mu.Unlock()
b.consumerCreates = append(b.consumerCreates, stream+"."+cfg.Durable)
for _, c := range b.consumers[stream] {
if c["name"] == cfg.Durable {
return grants.ErrHeld
}
}
filters := append([]string{}, cfg.FilterSubjects...)
if cfg.FilterSubject != "" {
filters = []string{cfg.FilterSubject}
}
if b.consumers == nil {
b.consumers = map[string][]map[string]any{}
}
b.consumers[stream] = append(b.consumers[stream], map[string]any{"name": cfg.Durable, "filters": filters, "max_deliver": cfg.MaxDeliver})
return nil
}
func (b *fakeBus) LastMembership(node, module string) ([]byte, uint64, bool, error) {
@@ -217,9 +177,6 @@ func (b *fakeBus) Call(key string, body any) (json.RawMessage, error) {
}
return json.Marshal(map[string]any{"users": out})
case grants.CallStreams:
if stream, _ := args["stream"].(string); stream != "" {
return json.Marshal(map[string]any{"detail": []map[string]any{{"name": stream, "consumer_detail": b.consumers[stream]}}})
}
return json.Marshal(map[string]any{"detail": b.streams})
case grants.CallRootFree:
if b.rootFree == nil {
@@ -260,27 +217,6 @@ func (s ownStore) Put(state, key string, value []byte) (uint64, error) {
return b.revs[state+"/"+key], nil
}
func (s ownStore) Keys(state string) ([]string, error) {
b := s.b
b.mu.Lock()
defer b.mu.Unlock()
var out []string
for k := range b.own[state] {
out = append(out, k)
}
slices.Sort(out)
return out, nil
}
func (s ownStore) Delete(state, key string) error {
b := s.b
b.mu.Lock()
defer b.mu.Unlock()
b.writes = append(b.writes, state+" delete")
delete(b.own[state], key)
return nil
}
func (b *fakeBus) Emit(event string, _ any) error {
b.mu.Lock()
defer b.mu.Unlock()
@@ -1208,7 +1144,7 @@ func TestGrants(t *testing.T) {
t.Errorf("membership = %v", ms)
}
gr, _ := p.Grants("mod-x")
if g, _ := gr.(map[string]any); g["writes"] != grants.WritesGrants || g["controller_count"] != 1 {
if g, _ := gr.(map[string]any); g["writes"] != grants.WritesNoGrant || g["count"] != 1 {
t.Errorf("grants = %v", gr)
}
@@ -1435,12 +1371,7 @@ func TestGrants(t *testing.T) {
var fresh []asks.Condition
fp := partOn(b)
fp.Objects = p.Objects
fp.Asker, fp.Say = fa, func(c asks.Condition) error {
if c.Key != grants.GrantMissingCondition { // its own row below
fresh = append(fresh, c)
}
return nil
}
fp.Asker, fp.Say = fa, func(c asks.Condition) error { fresh = append(fresh, c); return nil }
fp.Compose()
if len(fresh) == 0 || fresh[0].Key != grants.HeldCondition || fresh[0].State != asks.StateCleared {
t.Errorf("a fresh part's first pass does not say the held warning is cleared: %+v", fresh)
@@ -1573,7 +1504,7 @@ func TestGrants(t *testing.T) {
}
}
for _, w := range b.writes {
if !slices.Contains(grants.States, strings.TrimSuffix(w, " delete")) {
if !slices.Contains(grants.States, w) {
t.Errorf("wrote %s, which is not the part's own state", w)
}
}
@@ -1783,31 +1714,6 @@ func TestGrants(t *testing.T) {
}
})
// 774 (ADR 0563 §2, §3): the node-engine's manifest (mesh-host) is placed on every node though no node is assigned it,
// so its declared state acts is created as a placed module's, and composed for the user list's create right.
t.Run("assertions: the node-engine's declared state is created though no node is assigned it", func(t *testing.T) {
b := fixture()
b.manifestOf(buscompose.EngineModule, `{"module":"mesh-host","version":"1","state":[{"name":"acts"}]}`)
b.loaded = shadowOf(t, b)
p := partOn(b)
p.Create = b
p.Compose()
if !slices.Contains(b.creates, "KV_"+buscompose.EngineActsBucket) {
t.Fatalf("the node-engine's acts were not created: %v", b.creates)
}
recs, _ := grants.Records(grants.ReadInputs(b, &grants.Manifests{}))
if recs.Engine == nil || len(recs.Engine.State) != 1 || recs.Engine.State[0].Bucket() != buscompose.EngineActsBucket {
t.Fatalf("the records carry the node-engine as %+v", recs.Engine)
}
for node, mods := range recs.Assigned {
for _, d := range mods {
if d.Module == buscompose.EngineModule {
t.Errorf("the node-engine is a module of %s, which would give it a module's account", node)
}
}
}
})
t.Run("assertions: a refused create is said, never stops it, and is tried at the next event", func(t *testing.T) {
b := fixture()
b.loaded = shadowOf(t, b)
@@ -1917,463 +1823,6 @@ func TestGrants(t *testing.T) {
t.Errorf("the refusal was not said: %v", logged)
}
})
// 774/L3a (novox/hq ADR 0547 §5, ADR 0561 §2): the part writes grants.<module>.<fp> in shadow, once each and only for a
// module whose accounts the vault holds a hash for, says grant-written for each, deletes one only when a module has no
// account with a hash and every read was made, and the tool says every difference from the controller's.
t.Run("grants: written in shadow once each, only for hashed accounts, deleted only when read whole, compared with the controller's", func(t *testing.T) {
b := fixture()
b.loaded = shadowOf(t, b)
p := partOn(b)
fp := "src2:" + strings.Repeat("ab", 32)
key := func(module string) string { k, _ := delivery.PartGrantKey(module, fp); return k }
hash := func(user string) {
b.put(grants.ReadAccounts, user, map[string]any{"user": user, "hash": "$2a$10$x", "generation": 1})
}
held := func() []string { return slices.Sorted(maps.Keys(b.own[grants.StateGrants])) }
// The controller's grant for this fingerprint, beside the stale one the fixture holds for another.
delete(b.states[grants.ReadControllerGrant], "mod-x.0123456789ab0123456789ab0123456789ab0123456789ab0123456789ab0123")
b.put(grants.ReadControllerGrant, key("mod-x"), delivery.ControllerGrant{V: delivery.Version, Accounts: []string{"a.mod-x"}})
hash("a.mod-x")
hash("a.nats")
res := p.Compose()
if res.Grants == nil || !slices.Equal(held(), []string{key("mod-x"), key("nats")}) || len(res.Grants.Written) != 2 {
t.Fatalf("grants of the hashed modules: held %v, %+v", held(), res.Grants)
}
if g, err := delivery.DecodeControllerGrant(b.own[grants.StateGrants][key("mod-x")]); err != nil || !slices.Equal(g.Accounts, []string{"a.mod-x"}) {
t.Errorf("the grant of mod-x is %+v, %v", g, err)
}
said := func() int {
n := 0
for _, e := range b.events {
if e == grants.EventGrantWritten {
n++
}
}
return n
}
if said() != 2 {
t.Errorf("grant-written said %d times for two grants: %v", said(), b.events)
}
if res := p.Compose(); len(res.Grants.Written) != 0 || said() != 2 {
t.Errorf("a second pass wrote %v and said %d", res.Grants.Written, said())
}
// The tool: equal on mod-x, and nats is the part's alone (the controller's fixture holds none).
got, err := p.Grants("")
gm, _ := got.(map[string]any)
diff, _ := gm["differences"].([]grants.GrantDifference)
if err != nil || gm["equal"] != false || len(diff) != 1 || diff[0].Key != key("nats") || diff[0].Kind != "only the part's" {
t.Errorf("grants = %+v, %v", got, err)
}
// A user with a hash later: written once. Without it again: deleted. Unread hashes: nothing deleted.
hash("b.mod-y")
if res := p.Compose(); !slices.Equal(res.Grants.Written, []string{key("mod-y")}) {
t.Errorf("mod-y was granted %v when its account got a hash", res.Grants.Written)
}
b.refused[grants.ReadAccounts] = true
if res := p.Compose(); res.Grants == nil || len(res.Grants.Deleted)+len(res.Grants.Written) != 0 || len(res.Grants.Kept) != 1 || len(held()) != 3 {
t.Errorf("a pass that could not read the hashes changed grants: %+v, held %v", res.Grants, held())
}
delete(b.refused, grants.ReadAccounts)
delete(b.states[grants.ReadAccounts], "b.mod-y")
if res := p.Compose(); !slices.Equal(res.Grants.Deleted, []string{key("mod-y")}) || len(held()) != 2 {
t.Errorf("a module with no hashed account kept its grant: %+v, held %v", res.Grants, held())
}
})
// Issue 1126 (C5): a module whose source commit has not moved since a build of the old form, with a done key of the
// new form at that commit (link2pay, snake, f1-circuits, txt-game), is granted at the indexer's fingerprint, never
// judged by its commit; and a placed module left without a grant is said, never passed over: in the pass, and as the
// condition mesh-grants.grant-missing once the part's grants are the ones read, cleared when it has one.
t.Run("grants: at the indexer's fingerprint whatever the commit, and a placed module without one is said", func(t *testing.T) {
b := fixture()
b.loaded = shadowOf(t, b)
p := partOn(b)
var said []asks.Condition
p.Say = func(c asks.Condition) error {
if c.Key == grants.GrantMissingCondition {
said = append(said, c)
}
return nil
}
b.put(grants.ReadLease, grants.LeaseKey, map[string]any{"handed-to": "the operator's hand-over"})
fp := "src2:d4afe7a1abab5ccdbef6d8629b2245006a2aec60290f430f7b4756e2930ed60c"
b.put(grants.ReadModules, "mod-x", delivery.Module{V: delivery.Version, FP2: fp, Manifest: delivery.ManifestDigest([]byte(xManifest)),
Repository: "novox/Link2Pay", Commit: "5d20e8bfbc953c51e8ebe3c671fe883df9c36e15"})
b.put(grants.ReadModules, "mod-y", delivery.Module{V: delivery.Version, None2: "its builder's version is not read yet",
Manifest: delivery.ManifestDigest([]byte(yManifest)), Repository: "novox/y", Commit: "c"})
for _, u := range []string{"a.mod-x", "a.nats", "b.mod-y"} {
b.put(grants.ReadAccounts, u, map[string]any{"user": u, "hash": "$2a$10$x", "generation": 1})
}
res := p.Compose()
key, _ := delivery.PartGrantKey("mod-x", fp)
if _, held := b.own[grants.StateGrants][key]; !held {
t.Errorf("mod-x has no grant at the indexer's fingerprint %s: %+v", fp, res.Grants)
}
if res.Grants == nil || len(res.Grants.Missing) != 1 || !strings.HasPrefix(res.Grants.Missing[0],
"mod-y: no fingerprint of the new form: its builder's version is not read yet") {
t.Fatalf("missing = %+v", res.Grants)
}
if len(said) != 1 || said[0].State != asks.StateRaised || !strings.Contains(said[0].Explanation, "mod-y") {
t.Fatalf("said %+v", said)
}
if p.Compose(); len(said) != 1 {
t.Errorf("the same missing grant was said again: %+v", said)
}
b.put(grants.ReadModules, "mod-y", delivery.Module{V: delivery.Version, FP2: fp, Manifest: delivery.ManifestDigest([]byte(yManifest)),
Repository: "novox/y", Commit: "c"})
if res := p.Compose(); len(res.Grants.Missing) != 0 || len(said) != 2 || said[1].State != asks.StateCleared {
t.Errorf("once mod-y has a grant: missing %v, said %+v", res.Grants.Missing, said)
}
})
// The guard of ADR 0549 §6 for this repository, as mesh-catalog's checks/placement is for the catalogue: no module.json
// here reads the controller's assignments unless ../assignments-readers names it with why, and that list only shrinks.
t.Run("assignments readers: only the named modules read the controller's assignments, and the list only shrinks", func(t *testing.T) {
raw, err := os.ReadFile("../assignments-readers")
if err != nil {
t.Fatal(err)
}
allowed := map[string]bool{}
for _, line := range strings.Split(string(raw), "\n") {
if f := strings.Fields(line); len(f) > 1 && !strings.HasPrefix(f[0], "#") {
allowed[f[0]] = true
} else if len(f) == 1 {
t.Errorf("../assignments-readers names %s with no why", f[0])
}
}
paths, _ := filepath.Glob("../*/module.json")
reads := map[string]bool{}
for _, path := range paths {
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var m struct {
Module string `json:"module"`
Reads []string `json:"reads"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("%s: %v", path, err)
}
if slices.Contains(m.Reads, grants.ReadAssignments) {
reads[m.Module] = true
if !allowed[m.Module] {
t.Errorf("%s reads %s: read where modules run from the seat mesh-placement's placement (ADR 0549 §3); the lease write "+
"is refused while a module reads the assignments (ADR 0549 §6)", m.Module, grants.ReadAssignments)
}
}
}
for m := range allowed {
if !reads[m] {
t.Errorf("../assignments-readers names %s, which no longer reads %s: take it off the list", m, grants.ReadAssignments)
}
}
})
// 774 (the L3 review, ADR 0549 §3, §6): once the controller's lease is handed away the assignments are frozen and place
// nothing; the seat's placement alone does. A module left only in the stale assignments loses its account and its
// grant names no account of it; before the lease write the two are still unioned.
t.Run("placement alone once the lease is handed away: a module only in the stale assignments has no account or grant of it", func(t *testing.T) {
b := fixture()
b.loaded = shadowOf(t, b)
p := partOn(b)
p.Bus = grants.BusAddress{URL: "nats://a.mesh:4222", Fingerprint: "ab"}
pl, _ := delivery.EncodePlacement(delivery.Placement{Node: "a", Modules: []delivery.PlacedModule{{Module: "nats"}, {Module: "mod-x"}}})
b.states[grants.ReadPlacement] = map[string][]byte{"placement.a": pl}
for _, u := range []string{"a.mod-x", "a.nats", "b.mod-y"} {
b.put(grants.ReadAccounts, u, map[string]any{"user": u, "hash": "$2a$10$x", "generation": 1})
}
fp := "src2:" + strings.Repeat("ab", 32)
key, _ := delivery.PartGrantKey("mod-y", fp)
grantOf := func() []string {
g, _ := delivery.DecodeControllerGrant(b.own[grants.StateGrants][key])
return g.Accounts
}
b.put(grants.ReadLease, grants.LeaseKey, map[string]any{"instance": "novox/1/2026-10-28", "epoch": 60})
p.Compose()
if b.own[grants.StateAccounts]["b.mod-y"] == nil || !slices.Equal(grantOf(), []string{"b.mod-y"}) {
t.Fatalf("under the controller's lease the assignments no longer count: account %v, grant %v",
b.own[grants.StateAccounts]["b.mod-y"] != nil, grantOf())
}
b.put(grants.ReadLease, grants.LeaseKey, map[string]any{"handed-to": "the operator's hand-over"})
in := grants.ReadInputs(b, &grants.Manifests{})
if len(in.Assigned["b"]) != 0 || !slices.Equal(in.Assigned["a"], []string{"mod-x", "nats"}) || in.BusNode != "a" {
t.Errorf("after the lease write the stale assignments still place: %v, bus %q", in.Assigned, in.BusNode)
}
res := p.Compose()
if res.Accounts == nil || !slices.Equal(res.Accounts.Deleted, []string{"b.mod-y"}) || slices.Contains(grantOf(), "b.mod-y") {
t.Fatalf("a module only in the stale assignments kept its account or grant: %+v, grant %v", res.Accounts, grantOf())
}
})
// 774/AC4 (novox/hq ADR 0561 §2): every account the vault mints that the records compose is written to the state
// accounts once, with the bus's address and a module's claims, read by the vault alone; a key is deleted when placement
// no longer names its user, and never in a pass whose reads failed.
t.Run("wanted accounts: written once each, read by the vault alone, retired only when placement drops them", func(t *testing.T) {
b := fixture()
b.loaded = shadowOf(t, b)
p := partOn(b)
p.Bus = grants.BusAddress{URL: "nats://a.mesh:4222", Fingerprint: "ab"}
p.Compose()
p.Compose()
wrote := 0
for _, w := range b.writes {
if w == grants.StateAccounts {
wrote++
}
}
held := slices.Sorted(maps.Keys(b.own[grants.StateAccounts]))
if !slices.Equal(held, []string{"a.mod-x", "a.nats", "b.mod-y"}) || wrote != 3 {
t.Fatalf("accounts %v written %d times in two passes, want the three modules' once each", held, wrote)
}
nats, err := buscompose.ReadWantedAccount("a.nats", b.own[grants.StateAccounts]["a.nats"])
if err != nil || nats.URL != "nats://a.mesh:4222" || nats.Fingerprint != "ab" || len(nats.Claims) != 1 ||
nats.Claims[0].Seat != catalogue.BrokerSeat || nats.At.IsZero() {
t.Errorf("a.nats is wanted as %+v (%v)", nats, err)
}
var readers []string
for _, s := range grants.Announced() {
if s.Name == grants.StateAccounts {
if s.Readable {
t.Error("the wanted accounts are announced readable by anyone")
}
readers = s.Readers
}
}
if !slices.Equal(readers, []string{"mesh-vault"}) || buscompose.ReaderMay("mesh-delivery-view", buscompose.GrantsAccounts) {
t.Errorf("the wanted accounts are read by %v", readers)
}
// A read that fails retires nothing; once read, the user placement dropped is retired.
delete(b.states[grants.ReadAssignments], "b.mod-y")
b.refused[grants.ReadResolved] = true
if res := p.Compose(); res.Accounts == nil || len(res.Accounts.Deleted) != 0 || b.own[grants.StateAccounts]["b.mod-y"] == nil {
t.Fatalf("a pass whose read failed deleted %+v", res.Accounts)
}
delete(b.refused, grants.ReadResolved)
if res := p.Compose(); res.Accounts == nil || !slices.Equal(res.Accounts.Deleted, []string{"b.mod-y"}) {
t.Fatalf("the user placement dropped is not retired: %+v", res.Accounts)
}
// The tool reads them, one and all, each with when it was written.
one, err := p.Accounts("a.nats")
all, err2 := p.Accounts("")
if err != nil || err2 != nil || one.(map[string]any)["count"] != 1 || all.(map[string]any)["count"] != 2 ||
one.(map[string]any)["accounts"].([]buscompose.WantedAccount)[0].At.IsZero() {
t.Errorf("the tool reads %v and %v (%v %v)", one, all, err, err2)
}
if _, err := p.Accounts("b.mod-y"); err == nil {
t.Error("the tool reads an account no longer wanted")
}
p.Pass()
if all, _ := p.Accounts(""); all.(map[string]any)["consumers"] == nil {
t.Errorf("the tool does not say the account consumers in shadow: %v", all)
}
// With no bus address no account is written, and the pass says why.
bare := partOn(fixture())
if res := bare.Compose(); res.Accounts != nil || !strings.Contains(strings.Join(res.Errors, " "), "no bus address") {
t.Errorf("a part with no bus address wrote %+v: %v", res.Accounts, res.Errors)
}
})
// 774/AC5 (novox/hq ADR 0561 §1): the vault's account-issued wakes a pass, so the next list carries the new user's hash
// within one pass; an event of another kind does not.
t.Run("the vault's account-issued composes the list again", func(t *testing.T) {
b := fixture()
var handle func(string, []byte)
p := partOn(b)
f := &grants.Follower{Part: p, Watch: &fakeWatcher{opened: make(chan string, 16)}, Logf: func(string, ...any) {},
Hear: func(h func(string, []byte)) error { handle = h; return nil }}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { f.Run(ctx); close(done) }()
defer func() { cancel(); <-done }()
waitFor(t, func() bool { return b.count(grants.CallUsers) > 0 && handle != nil })
held := func() bool {
for _, c := range p.Last().UserList.Credentials.Users {
if c.User == "a.mod-x" {
return c.HashHeld
}
}
return false
}
if held() {
t.Fatal("a.mod-x has a hash before the vault made one")
}
b.mu.Lock()
b.states[grants.ReadAccounts] = map[string][]byte{"a.mod-x": []byte(`{"user":"a.mod-x","hash":"$2a$10$x","generation":1}`)}
b.mu.Unlock()
handle(buscompose.AccountIssuedSubject, []byte(`{"user":"a.mod-x","kind":"module","node":"a","module":"mod-x","generation":1}`))
waitFor(t, held)
if !slices.Contains(grants.Consumes, grants.EventAccountIssued) || !grants.IsAccountIssued(grants.EventAccountIssued) ||
grants.IsAccountIssued("mesh.mod.mesh-vault.event.secret-changed") {
t.Errorf("the part hears %v", grants.Consumes)
}
})
// 774/AC6 (novox/hq ADR 0561 §6): the durable consumers an account needs, with the controller's configuration, are
// compared in shadow while the controller holds the lease, and created, only where the bus lacks them, from the write
// that hands the lease away; one the bus holds is never changed.
t.Run("account consumers: compared while the controller holds the lease, created once it is handed away", func(t *testing.T) {
const cManifest = `{"module":"mod-c","version":"1","own-secrets":{"broker":"/var/lib/c/broker"},"consumes":["mod-y.done"]}`
b := fixture()
b.manifestOf("mod-c", cManifest)
b.assign("b", "mod-c")
b.loaded = shadowOf(t, b)
b.put(grants.ReadLease, grants.LeaseKey, map[string]any{"instance": "novox/1/2026-10-27", "epoch": 57})
p := partOn(b)
p.Consumers = b
res := p.Compose()
if res.Consumers == nil || res.Consumers.On || !slices.Contains(res.Consumers.Missing, "EVENTS.b_mod-c") || len(b.consumerCreates) != 0 {
t.Fatalf("while the controller holds the lease: %+v, created %v", res.Consumers, b.consumerCreates)
}
b.put(grants.ReadLease, grants.LeaseKey, map[string]any{"handed-to": "the operator's hand-over"})
b.consumers = map[string][]map[string]any{"EVENTS": {{"name": "b_other", "filters": []string{"x"}, "max_deliver": 5}}}
res = p.Compose()
if !res.Consumers.On || !slices.Equal(res.Consumers.Created, []string{"EVENTS.b_mod-c"}) {
t.Fatalf("once handed away: %+v", res.Consumers)
}
var made map[string]any
for _, c := range b.consumers["EVENTS"] {
if c["name"] == "b_mod-c" {
made = c
}
}
if made == nil || made["max_deliver"] != 5 || !slices.Contains(made["filters"].([]string), "mesh.mod.mod-y.event.done") {
t.Errorf("b_mod-c was made as %v, not as the controller makes it", made)
}
res = p.Compose()
if len(res.Consumers.Created) != 0 || len(res.Consumers.Missing) != 0 || len(b.consumerCreates) != 1 {
t.Errorf("a consumer the bus holds was asked again: %+v, %v", res.Consumers, b.consumerCreates)
}
// A held consumer that differs is never changed by a pass: it is raised as a condition naming it, and brought to
// the composed configuration only by hand (grants_consumer_match), never while the controller holds the lease.
var said []asks.Condition
p.Say = func(c asks.Condition) error {
if c.Key != grants.GrantMissingCondition { // its own row
said = append(said, c)
}
return nil
}
made["max_deliver"] = 9
res = p.Compose()
if len(res.Consumers.Differ) != 1 || !strings.Contains(res.Consumers.Differ[0], "EVENTS.b_mod-c: max deliver 9") ||
len(said) != 1 || said[0].Key != grants.ConsumerDiffersCondition || said[0].State != asks.StateRaised ||
!strings.Contains(said[0].Explanation, "EVENTS.b_mod-c") {
t.Fatalf("a differing consumer: %+v, said %+v", res.Consumers, said)
}
b.put(grants.ReadLease, grants.LeaseKey, map[string]any{"instance": "novox/2/2026-10-27", "epoch": 58})
if _, err := p.MatchConsumer("EVENTS", "b_mod-c"); err == nil || !strings.Contains(err.Error(), "controller holds the lease") {
t.Errorf("matched by hand under the controller's lease: %v", err)
}
b.put(grants.ReadLease, grants.LeaseKey, map[string]any{"handed-to": "the operator's hand-over"})
if _, err := p.MatchConsumer("EVENTS", "b_nothing"); err == nil {
t.Error("a consumer the records do not compose was asked to match")
}
if _, err := p.MatchConsumer("EVENTS", "b_mod-c"); err != nil {
t.Fatal(err)
}
res = p.Compose()
if !slices.Contains(b.consumerCreates, "match EVENTS.b_mod-c") || made["max_deliver"] != 5 || len(res.Consumers.Matched) != 1 {
t.Errorf("the ask to match: %+v, %v", res.Consumers, b.consumerCreates)
}
res = p.Compose()
if len(res.Consumers.Differ) != 0 || said[len(said)-1].State != asks.StateCleared {
t.Errorf("once matched: %+v, said %+v", res.Consumers, said)
}
})
// 774/AC13 (novox/hq ADR 0561 §7): after take-over a list in which more than one loaded user's hash changes, with no
// rotation by the vault naming each, is held: the list in force stays, user-list-held is raised naming those users. One
// user's change, or changes the vault's rotation names, are written.
t.Run("a list changing several loaded users' hashes is held unless the vault's rotation names each", func(t *testing.T) {
b := fixture()
b.loaded = shadowOf(t, b)
hash := func(user, h string, generation int, rotating bool) {
v := map[string]any{"user": user, "hash": h, "generation": generation, "fingerprint": fmt.Sprintf("sha256:%d", generation)}
if rotating {
v["rotating"] = map[string]any{"login": user + "--b", "generation": generation, "revision": 7}
}
b.put(grants.ReadAccounts, user, v)
}
for _, u := range shadowOf(t, b) {
hash(u.User, "$2a$10$first", 1, false)
}
busKey, err := seal.GenerateKeyPair()
if err != nil {
t.Fatal(err)
}
b.put(grants.ReadHave, "a.nats", delivery.Have{V: delivery.Version, Phase: delivery.PhaseRunning, Health: delivery.HealthOK,
UserList: &delivery.UserListWritten{Source: "a.nats.src2", Revision: 7, Digest: "sha256:aa", Users: 6, Key: busKey.PublicKey}})
p := partOn(b)
p.Objects = &fakeObjects{}
var said []asks.Condition
p.Say = func(c asks.Condition) error { said = append(said, c); return nil }
sw, _ := json.Marshal(grants.Switch{V: 1, State: grants.TookOver, Acts: []grants.Act{}})
if _, err := (ownStore{b}).Put(grants.StateTakeover, grants.KeySwitch, sw); err != nil {
t.Fatal(err)
}
p.Compose()
inForce, _, _ := p.Sealed()
if inForce.Digest == "" {
t.Fatal("no list written after take-over")
}
// One user's hash changes: written.
hash("a.mod-x", "$2a$10$second", 2, false)
p.Compose()
if now, _, _ := p.Sealed(); now.Digest == inForce.Digest {
t.Fatal("a list changing one loaded user's hash was not written")
}
inForce, _, _ = p.Sealed()
// Two change at once, no rotation names them: held, said naming both.
hash("a.mod-x", "$2a$10$third", 3, false)
hash("a.nats", "$2a$10$third", 2, false)
said = nil
res := p.Compose()
if now, _, _ := p.Sealed(); now.Digest != inForce.Digest || res.Sealed == nil || !strings.Contains(res.Sealed.Why, "a.mod-x, a.nats") {
t.Fatalf("a list changing two loaded users' hashes: %+v", res.Sealed)
}
if len(said) != 1 || said[0].Key != grants.HeldCondition || said[0].State != asks.StateRaised || !strings.Contains(said[0].Summary, "a.mod-x, a.nats") {
t.Errorf("the hold was said as %+v", said)
}
// The hold is its own guard, beside take-over's refusals (ADR 0562): the switch stays taken over.
if p.SwitchState() != grants.TookOver {
t.Errorf("the hold moved the switch to %s", p.SwitchState())
}
// A person accepts exactly the named users' changes of the held list, with why: refused for another set, with no
// why, or once the list changed since; accepted, the next pass writes it once, said as a hand act.
if _, err := p.AcceptHashes("person.jochen", []string{"a.mod-x"}, "rotated by hand"); err == nil || !strings.Contains(err.Error(), "a.mod-x, a.nats") {
t.Errorf("an acceptance of another set than the held one: %v", err)
}
if _, err := p.AcceptHashes("person.jochen", []string{"a.mod-x", "a.nats"}, ""); err == nil {
t.Error("an acceptance with no why")
}
if _, err := p.AcceptHashes("person.jochen", []string{"a.nats", "a.mod-x"}, "the vault's store was restored"); err != nil {
t.Fatal(err)
}
// The list changed since the acceptance (a.mod-x's hash again): the acceptance is bound to the held list, refused.
hash("a.mod-x", "$2a$10$fourth", 4, false)
res = p.Compose()
if now, _, _ := p.Sealed(); now.Digest != inForce.Digest || res.Sealed == nil || !strings.Contains(res.Sealed.Why, "held") {
t.Fatalf("an acceptance of a list that changed since released it: %+v", res.Sealed)
}
if _, err := p.AcceptHashes("person.jochen", []string{"a.mod-x", "a.nats"}, "the vault's store was restored"); err != nil {
t.Fatal(err)
}
b.events = nil
p.Compose()
if now, _, _ := p.Sealed(); now.Digest == inForce.Digest || said[len(said)-1].State != asks.StateCleared ||
!slices.Contains(b.events, grants.EventHashesAccepted) {
t.Fatalf("an accepted hold was not released once and said: %+v, events %v", said, b.events)
}
inForce, _, _ = p.Sealed()
// The vault's rotation names each: written, the warning cleared.
hash("a.mod-x", "$2a$10$fifth", 5, true)
hash("a.nats", "$2a$10$fifth", 3, true)
p.Compose()
if now, _, _ := p.Sealed(); now.Digest == inForce.Digest || said[len(said)-1].State != asks.StateCleared {
t.Errorf("changes the vault's rotation names were held: %+v", said)
}
})
}
type fakeAsker struct {
@@ -1,217 +0,0 @@
package grants
import (
"encoding/json"
"fmt"
"slices"
"sort"
"git.novox.be/novox/mesh-sdk/go/announce"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// **The accounts the mesh wants, said to the vault** (novox/hq ADR 0561 §2, issue 774). The bus composer derives every
// user from placement, the manifests and the wants; for each account the vault mints (a module's, a process's, a
// machine's provisioner client's), the part writes one key of its state `accounts`, keyed by the bus user, with the
// fields the controller's credential carries beside the password. A key is written only when it differs, and deleted
// only when placement no longer names its user and every input of the pass was read: a refused read never retires an
// account. The vault alone reads the state (buscompose.NamedReaders); it mints each user it holds no value for.
// StateAccounts is the part's state of wanted accounts.
const StateAccounts = buscompose.AccountsState
// AccountsReaders are the only modules that may read StateAccounts.
var AccountsReaders = buscompose.NamedReaders[buscompose.GrantsAccounts]
// Announced is what the part says it keeps: every state readable by any reader, but the wanted accounts, by the vault
// alone (ADR 0495 §2-§3, ADR 0561 §2).
func Announced() []announce.State {
var out []announce.State
for _, s := range States {
if s == StateAccounts {
out = append(out, announce.State{Name: s, Readers: slices.Clone(AccountsReaders)})
continue
}
out = append(out, announce.State{Name: s, Readable: true})
}
return out
}
// BusAddress is where every node on the private network reaches the bus, and its certificate's fingerprint: the part's
// own credential says both, since the controller gave every node's account the bus holder's overlay name (its
// brokerReachableAt).
type BusAddress struct {
URL string
Fingerprint string
}
// WantedAccounts is every account the vault mints that the records compose, keyed by user, with the bus's address. A
// node, the controller, an enrolment user or a person is never one (ADR 0561 §4, §5). Pure.
func WantedAccounts(in Inputs, recs buscompose.Records, bus BusAddress) (map[string]buscompose.WantedAccount, error) {
principals, err := buscompose.Users(recs)
if err != nil {
return nil, err
}
out := map[string]buscompose.WantedAccount{}
for _, p := range principals {
w := buscompose.WantedAccount{User: p.Username(), Kind: p.Kind, Node: p.Node, Module: p.Module, Process: p.Process,
URL: bus.URL, Fingerprint: bus.Fingerprint}
switch p.Kind {
case buscompose.KindModule, buscompose.KindNodeTools:
if m, ok := manifestOn(in, p.Node, p.Module); ok {
w.Claims = ClaimsOf(m)
}
case buscompose.KindProcess, buscompose.KindProvisioner:
default:
continue
}
out[w.User] = w
}
return out, nil
}
// manifestOn is the manifest a node runs of module: the build its want names, else the trunk's.
func manifestOn(in Inputs, node, module string) (catalogue.Manifest, bool) {
if m, ok := in.Running[node+"."+module]; ok {
return m, true
}
m, ok := in.Manifests[module]
return m, ok
}
// ClaimsOf is a module's claims as its credential carries them: the controller's claimsFor, over the mesh's own seats
// (its seat table is seeded from them): each claim's seat and scope, and the verbs the runtime serves for it, the claim's
// own or every verb the seat promises; a seat the mesh does not define keeps the claim's scope and no verbs.
func ClaimsOf(m catalogue.Manifest) []buscompose.ClaimedSeat {
if len(m.Claims) == 0 {
return nil
}
byName := map[string]catalogue.Seat{}
for _, s := range catalogue.DefaultSeats() {
byName[s.Name] = s
}
var out []buscompose.ClaimedSeat
for _, c := range m.Claims {
claimed := buscompose.ClaimedSeat{Seat: c.Name, Scope: c.At()}
if s, known := byName[c.Name]; known {
claimed.Scope = s.Scope
claimed.Serves = c.ServesFor(catalogue.Manifest{Tools: catalogue.VerbNames(s.Serves)})
}
out = append(out, claimed)
}
return out
}
// Accounts answers the tool accounts (ADR 0561 §9): the wanted accounts as the part last wrote them, one user's or all,
// each with when it was written; and what the last pass did to them. Never a password: the state holds none.
func (p *Part) Accounts(user string) (any, error) {
users := []string{user}
if user == "" {
keys, err := p.Store.Keys(StateAccounts)
if err != nil {
return nil, fmt.Errorf("its own %s cannot be listed: %w", StateAccounts, err)
}
users = keys
}
out := []buscompose.WantedAccount{}
for _, u := range users {
raw, _, found, err := p.Store.Get(StateAccounts, u)
if err != nil {
return nil, fmt.Errorf("its own %s %s cannot be read: %w", StateAccounts, u, err)
}
if !found {
if user != "" {
return nil, fmt.Errorf("no account %s is wanted: placement composes no module, process or provisioner client of that user", user)
}
continue
}
w, err := buscompose.ReadWantedAccount(u, raw)
if err != nil {
return nil, err
}
out = append(out, w)
}
answer := map[string]any{"accounts": out, "count": len(out), "read_by": AccountsReaders}
if raw, _, found, err := p.Store.Get(StateUserList, KeyPass); err == nil && found {
var res Result
if json.Unmarshal(raw, &res) == nil && res.Accounts != nil {
answer["last_pass"] = res.Accounts
}
// And the durable consumers the accounts need, as the last pass found them: missing and differing ones are read
// here in shadow too, where no condition is raised (ADR 0561 §6, §9).
if res.Consumers != nil {
answer["consumers"] = res.Consumers
}
}
return answer, nil
}
// AccountsWritten is what one pass did to the state of wanted accounts.
type AccountsWritten struct {
Wanted int `json:"wanted"`
Written []string `json:"written,omitempty"`
Deleted []string `json:"deleted,omitempty"`
// Kept are keys placement no longer names that this pass did not delete, with why.
Kept []string `json:"kept,omitempty"`
}
// writeAccounts writes each wanted account whose key differs, and deletes the key of a user no longer placed, but only
// when every input of the pass was read.
func (p *Part) writeAccounts(res *Result, in Inputs, recs buscompose.Records) {
if p.Bus.URL == "" {
res.Errors = append(res.Errors, "the wanted accounts are not written: the part knows no bus address to give them (its own credential names none)")
return
}
wanted, err := WantedAccounts(in, recs, p.Bus)
if err != nil {
res.Errors = append(res.Errors, "the wanted accounts could not be composed: "+err.Error())
return
}
done := &AccountsWritten{Wanted: len(wanted)}
res.Accounts = done
for _, user := range sortedKeys(wanted) {
w := wanted[user]
if raw, _, found, err := p.Store.Get(StateAccounts, user); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be read: %v", StateAccounts, user, err))
continue
} else if found {
if was, err := buscompose.ReadWantedAccount(user, raw); err == nil && was.Same(w) {
continue
}
}
w.At = p.now()
data, _ := json.Marshal(w)
if _, err := p.Store.Put(StateAccounts, user, data); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateAccounts, user, err))
continue
}
done.Written = append(done.Written, user)
}
held, err := p.Store.Keys(StateAccounts)
if err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s cannot be listed, so no key of a user gone is deleted: %v", StateAccounts, err))
return
}
failed := in.ReadFailures()
placed := Placed(in)
for _, user := range held {
if _, still := wanted[user]; still {
continue
}
switch {
case placed(user):
done.Kept = append(done.Kept, user+": placement still names it (composed with no account this pass)")
continue
case len(failed) > 0:
done.Kept = append(done.Kept, user+": a read of this pass failed, so no account is retired")
continue
}
if err := p.Store.Delete(StateAccounts, user); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be deleted: %v", StateAccounts, user, err))
continue
}
done.Deleted = append(done.Deleted, user)
}
sort.Strings(done.Kept)
}
@@ -1,242 +0,0 @@
package grants
import (
"encoding/json"
"errors"
"fmt"
"slices"
"sort"
"strings"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/buscompose"
)
// **The durable consumers an account needs** (novox/hq ADR 0561 §6, issue 774). The controller creates a module's
// consumer on EVENTS and each process's on its work queue when it issues the account. From the write that hands its
// lease away the grants part creates them, with the configuration the bus composer derives (buscompose.AccountConsumers),
// only where the bus lacks one; one the bus holds is never changed or deleted, a difference is only said. Before that
// write they are compared in shadow alone, so two parts never create one consumer.
// ReadLease is the controller's lease as the part reads it (buscompose.NamedReaders names the vault and the part), and
// LeaseKey its one key.
const (
ReadLease = buscompose.ControllerLease
LeaseKey = buscompose.ControllerLeaseKey
)
// ConsumerCreator creates one durable consumer the bus does not hold: the part's process alone, whose account is granted
// the create of each account's consumer by stream and name. One the bus holds answers ErrHeld.
type ConsumerCreator interface {
CreateConsumer(stream string, cfg jetstream.ConsumerConfig) error
// MatchConsumer brings a held consumer to cfg, keeping where it delivers from: only on a person's ask
// (grants_consumer_match), never by a pass.
MatchConsumer(stream string, cfg jetstream.ConsumerConfig) error
}
// ConsumersState is what one pass found and did of the account consumers.
type ConsumersState struct {
// On says the lease is handed away, so the part creates what the bus lacks; Why says why it is on or off.
On bool `json:"on"`
Why string `json:"why"`
// Wanted is how many the records compose; Missing those the bus lacks (`<stream>.<name>`), Differ those it holds
// otherwise, each with how.
Wanted int `json:"wanted"`
Missing []string `json:"missing,omitempty"`
Differ []string `json:"differ,omitempty"`
Created []string `json:"created,omitempty"`
// Matched is a consumer brought to the composed configuration on a person's ask, or why not.
Matched []string `json:"matched,omitempty"`
NotCreated []string `json:"not_created,omitempty"`
// Unread is a stream whose consumers could not be read: nothing on it is compared or created.
Unread []string `json:"unread,omitempty"`
}
// heldConsumer is one consumer as the bus module's nats_streams reads it for one stream.
type heldConsumer struct {
Name string `json:"name"`
Filters []string `json:"filters"`
MaxDeliver int `json:"max_deliver"`
}
// heldConsumers reads the consumers the bus holds on stream, by name.
func heldConsumers(c Caller, stream string) (map[string]heldConsumer, error) {
raw, err := c.Call(CallStreams, map[string]any{"stream": stream})
if err != nil {
return nil, fmt.Errorf("%s %s: %w", CallStreams, stream, err)
}
var got struct {
Detail []struct {
Name string `json:"name"`
Consumers []heldConsumer `json:"consumer_detail"`
} `json:"detail"`
}
if err := json.Unmarshal(unwrap(raw), &got); err != nil {
return nil, fmt.Errorf("%s %s answered no consumers: %w", CallStreams, stream, err)
}
out := map[string]heldConsumer{}
for _, d := range got.Detail {
if d.Name != stream {
continue
}
for _, c := range d.Consumers {
out[c.Name] = c
}
}
return out, nil
}
// leaseHandedAway reads the lease as the part's reads give it.
func leaseHandedAway(r Reader) (bool, string) {
raw, found, err := r.Get(ReadLease, LeaseKey)
return buscompose.LeaseHandedAway(raw, found, err)
}
// consumers compares the account consumers the records compose with those the bus holds, and creates the missing ones
// once the lease is handed away and the part holds a creator.
func (p *Part) consumers(res *Result, recs buscompose.Records, match string) {
users, err := buscompose.Users(recs)
if err != nil {
res.Errors = append(res.Errors, "the account consumers could not be composed: "+err.Error())
return
}
st := &ConsumersState{}
st.On, st.Why = leaseHandedAway(p.Read)
res.Consumers = st
wanted := buscompose.AccountConsumers(users)
st.Wanted = len(wanted)
byStream := map[string][]buscompose.Consumer{}
for _, mc := range wanted {
byStream[mc.Consumer.Stream] = append(byStream[mc.Consumer.Stream], mc.Consumer)
}
for _, stream := range sortedKeys(byStream) {
held, err := heldConsumers(p.Call, stream)
if err != nil {
st.Unread = append(st.Unread, stream+": "+err.Error())
continue
}
for _, c := range byStream[stream] {
key := stream + "." + c.Name
h, ok := held[c.Name]
if ok {
why := consumerDiffers(c, h)
if why != "" && key == match && st.On && p.Consumers != nil {
if err := p.Consumers.MatchConsumer(stream, buscompose.ConsumerConfig(c)); err != nil {
st.Matched = append(st.Matched, key+": not matched: "+err.Error())
} else {
st.Matched = append(st.Matched, key+": matched, asked by hand")
continue
}
}
if why != "" {
st.Differ = append(st.Differ, key+": "+why)
}
continue
}
if !st.On || p.Consumers == nil {
st.Missing = append(st.Missing, key)
continue
}
switch err := p.Consumers.CreateConsumer(stream, buscompose.ConsumerConfig(c)); {
case err == nil:
st.Created = append(st.Created, key)
p.say(res, EventAsserted, map[string]any{"consumer": c.Name, "stream": stream})
case errors.Is(err, ErrHeld):
default:
st.Missing = append(st.Missing, key)
st.NotCreated = append(st.NotCreated, key+": "+err.Error())
}
}
}
p.sayDiffer(st)
}
// sayDiffer raises the warning condition mesh-grants.consumer-differs while an account consumer the part owns (from the
// lease write) differs from its composed configuration, naming each with the fields that differ, and clears it when they
// match. Said once per set of differences, again when it changes. Before the lease write the consumers are the
// controller's, and a difference is only said in the pass.
func (p *Part) sayDiffer(st *ConsumersState) {
if p.Say == nil {
return
}
why := ""
if st.On && len(st.Differ) > 0 {
why = strings.Join(st.Differ, "; ")
}
p.holdMu.Lock()
defer p.holdMu.Unlock()
if p.differKnown && why == p.differSaid {
return
}
c := asks.Condition{Key: ConsumerDiffersCondition, Subject: asks.Scope{Scope: "module", ID: "mesh-grants"}}
if why == "" {
c.State = asks.StateCleared
} else {
c.Severity, c.State = asks.SeverityWarning, asks.StateRaised
c.Summary = fmt.Sprintf("%d durable consumers differ from their composed configuration", len(st.Differ))
c.Headline, c.Needs = "A module's consumer is not as composed", "a look, and grants_consumer_match for each"
c.Explanation = asks.NeedsYou + "a look. The bus holds these consumers otherwise than the bus composer composes them, " +
"and the grants part never changes a held one by itself (its delivery position would be lost): " + why
c.Resolved = "every account consumer matches, by grants_consumer_match or otherwise"
}
if p.Say(c) == nil {
p.differSaid, p.differKnown = why, true
}
}
// ConsumerDiffersCondition is the key of the warning raised while an account consumer differs (ADR 0561 §6).
const ConsumerDiffersCondition = "mesh-grants.consumer-differs"
// MatchConsumer is the tool consumer-match (ADR 0561 §6, §9): it asks the part's process to bring one account consumer
// the records compose to its composed configuration, keeping where it delivers from. Refused while the controller holds
// the lease (the consumers are the controller's then), and for a consumer the records do not compose.
func (p *Part) MatchConsumer(stream, name string) (any, error) {
if stream == "" || name == "" {
return nil, errors.New("consumer-match names a stream and a consumer")
}
if on, why := leaseHandedAway(p.Read); !on {
return nil, fmt.Errorf("not asked: %s; the controller makes and matches the consumers while it holds the lease", why)
}
in := ReadInputs(p.Read, &p.cache)
recs, _ := Records(in)
users, err := buscompose.Users(recs)
if err != nil {
return nil, err
}
found := false
for _, mc := range buscompose.AccountConsumers(users) {
if mc.Consumer.Stream == stream && mc.Consumer.Name == name {
found = true
}
}
if !found {
return nil, fmt.Errorf("the records compose no account consumer %s on %s: the part brings only those to their configuration", name, stream)
}
ask, _ := json.Marshal(Ask{By: "grants_consumer_match", At: p.now(), Consumer: stream + "." + name})
rev, err := p.Store.Put(StateUserList, KeyAsked, ask)
if err != nil {
return nil, fmt.Errorf("the ask could not be written: %w", err)
}
return map[string]any{"asked": rev, "says": "the part's process brings " + stream + "." + name + " to its composed " +
"configuration at this ask, keeping where it delivers from; the last pass's consumers.matched says what became of it"}, nil
}
// consumerDiffers says how a held consumer differs from the composed one in what nats_streams reads back, or "".
func consumerDiffers(c buscompose.Consumer, h heldConsumer) string {
var why []string
want, have := slices.Sorted(slices.Values(c.Filters)), slices.Sorted(slices.Values(h.Filters))
if !slices.Equal(want, have) {
why = append(why, fmt.Sprintf("filters %v, composed %v", have, want))
}
maxDeliver := c.MaxDeliver
if maxDeliver == 0 {
maxDeliver = -1
}
if h.MaxDeliver != maxDeliver {
why = append(why, fmt.Sprintf("max deliver %d, composed %d", h.MaxDeliver, maxDeliver))
}
sort.Strings(why)
return strings.Join(why, "; ")
}
+5 -17
View File
@@ -6,7 +6,6 @@ import (
"strings"
"sync"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -19,7 +18,7 @@ type Watcher interface {
// Followed are the states whose change composes again: placement, the indexer's manifests by module, the resolved
// config values, the node agents' haves (a machine's first have, and the bus node's loaded list) and the assigner's
// wants (the manifest each machine is wanted to run), and the vault's hashes of the bus accounts.
var Followed = []string{ReadAssignments, ReadModules, ReadResolved, ReadHave, ReadWants, ReadAccounts, ReadPlacement, ReadLease}
var Followed = []string{ReadAssignments, ReadModules, ReadResolved, ReadHave, ReadWants, ReadAccounts, ReadPlacement}
// Follower composes again on every change it hears, never on a timer. It never stops for a refusal: a watch it cannot
// open is said, composed without, and opened again at the next wake (any change it does hear, or a reconnect).
@@ -29,9 +28,9 @@ type Follower struct {
Logf func(string, ...any)
// Asked watches the part's own ask to compose now (a tool's compose-now), waking at each; nil watches none.
Asked func(wake func()) error
// Hear hears the controller seat's condition events and the vault's account-issued (Consumes) and hands each, by event
// name, with its body, to handle; nil hears none. A condition of the kind root-not-free raised or cleared wakes a pass:
// a machine's root-free word changed; and so does an account the vault minted (ADR 0561 §1), whose hash the list carries.
// Hear hears the controller seat's condition events (Consumes) and hands each, by event name, with its body, to
// handle; nil hears none. A condition of the kind root-not-free raised or cleared wakes a pass: a machine's
// root-free word changed.
Hear func(handle func(event string, body []byte)) error
wake chan struct{}
@@ -47,8 +46,6 @@ type Follower struct {
held string
notAsserted string
refusedMans string
// leaseOn is whether the lease was handed away at its last change heard, once leaseSeen.
leaseOn, leaseSeen bool
}
func (f *Follower) init() {
@@ -88,15 +85,6 @@ func (f *Follower) logf(format string, args ...any) {
// heard says whether a change composes again: every change but a have's, and of the haves a machine's first and the
// bus module's loaded list moving.
func (f *Follower) heard(state, key string, value []byte) bool {
if state == ReadLease {
// Renewed every few seconds by its holder: only the write that hands it away, or back, composes again (ADR 0561 §6).
on, _ := buscompose.LeaseHandedAway(value, len(value) > 0, nil)
f.mu.Lock()
defer f.mu.Unlock()
was, seen := f.leaseOn, f.leaseSeen
f.leaseOn, f.leaseSeen = on, true
return !seen || was != on
}
if state != ReadHave {
return true
}
@@ -136,7 +124,7 @@ func (f *Follower) open() {
}
if f.Hear != nil && !f.heard_ {
err := f.Hear(func(event string, body []byte) {
if IsAccountIssued(event) || IsRootNotFree(body) {
if IsRootNotFree(body) {
f.Wake()
}
})
@@ -1,268 +0,0 @@
package grants
import (
"fmt"
"slices"
"sort"
"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 grants the part writes** (novox/hq ADR 0547 §4, §5, ADR 0561 §2, §6; issue 774). A module's grant is the record
// that its bus credential exists for the fingerprint the catalogue's trunk names for it: `<module>.<fingerprint hex>` in
// the part's state grants, naming the accounts (the module's own and each process's) the vault holds a hash for. It is the
// controller's grant as the controller composes it (cmd/mesh-controller grants.go grantsFrom), so the two compare by key.
// Until the controller's lease is handed away nobody reads the part's keys: the assigner reads the controller's
// (delivery.GrantSource), and the tool grants says every difference, so the switch is made on a measured equality.
//
// A module of which an account has no hash yet is granted the accounts that have one; a module none of whose accounts has
// one has no grant, and one it was written for is deleted (an older grant naming a node whose account is gone would let the
// assigner want it, and a grant naming no account names every node). Nothing else is ever deleted, and nothing is written
// or deleted in a pass that could not read the vault's hashes, the indexer's modules or a manifest of the module.
// GrantsWritten is what one pass did to the state grants.
type GrantsWritten struct {
Wanted int `json:"wanted"`
Written []string `json:"written,omitempty"`
Deleted []string `json:"deleted,omitempty"`
// Kept says why a pass wrote and deleted nothing, or left a module out.
Kept []string `json:"kept,omitempty"`
// Missing is each module the placement or an assignment puts on a machine that has no grant after the pass,
// `<module>: <why>`: the assigner's sixth rule wants none of it (MissingGrants). Never passed over in silence: once
// the part's grants are the ones read, it is the condition mesh-grants.grant-missing.
Missing []string `json:"missing,omitempty"`
}
// GrantMissingCondition is the key of the warning raised while a placed module has no grant (issue 1126).
const GrantMissingCondition = "mesh-grants.grant-missing"
// MissingGrants is each module assigned or placed on a machine that want leaves without a grant, `<module>: <why>`,
// sorted: its manifest refused (skip), no fingerprint of the new form at the indexer's trunk, or no account with a hash
// in the vault (drop). **The fingerprint is the indexer's, never a commit's** (issue 1126: link2pay, snake, f1-circuits
// and txt-game, whose source commit has not moved since a build of the old form, have a done key of the new form at it,
// and that is the fingerprint granted). Pure.
func MissingGrants(in Inputs, want map[string]delivery.ControllerGrant, drop []string, skip map[string]bool) []string {
placed := map[string]bool{}
for _, ms := range in.Assigned {
for _, m := range ms {
placed[m] = true
}
}
var out []string
for _, module := range sortedKeys(placed) {
fp, has := in.Fingerprints[module]
key, err := delivery.PartGrantKey(module, fp)
if has && err == nil {
if _, granted := want[key]; granted {
continue
}
}
why := ""
switch {
case skip[module]:
why = "its manifest at the indexer's trunk is refused"
case !has:
why = "no fingerprint of the new form: " + orSaid(in.NoFingerprint[module], "the indexer holds no value of it")
case err != nil:
why = fmt.Sprintf("no grant key for %s: %v", fp, err)
case slices.Contains(drop, key):
why = "none of its accounts at " + fp + " has a hash in the vault"
default:
why = "no grant composed at " + fp
}
out = append(out, module+": "+why)
}
return out
}
func orSaid(s, none string) string {
if s == "" {
return none
}
return s
}
// sayMissing raises mesh-grants.grant-missing while a placed module has no grant, once the controller's lease is handed
// away (the part's grants are then the ones the assigner reads), and clears it when every one has one; in shadow the
// pass and the tool say them. Said once per set, again when it changes, and always at the first pass after a start.
func (p *Part) sayMissing(missing []string) {
if p.Say == nil {
return
}
why := ""
if on, _ := leaseHandedAway(p.Read); on && len(missing) > 0 {
why = strings.Join(missing, "; ")
}
p.holdMu.Lock()
defer p.holdMu.Unlock()
if p.missingKnown && why == p.missingSaid {
return
}
c := asks.Condition{Key: GrantMissingCondition, Subject: asks.Scope{Scope: "module", ID: "mesh-grants"}}
if why == "" {
c.State = asks.StateCleared
} else {
c.Severity, c.State = asks.SeverityWarning, asks.StateRaised
c.Summary = fmt.Sprintf("%d placed module(s) have no grant, so no want can name them", len(missing))
c.Headline, c.Needs = "A placed module has no bus grant", "a look at why each has none"
c.Explanation = asks.NeedsYou + "a look. The grants part wrote no grant for these modules, so the assigner's " +
"credential rule gives them no want: " + why
c.Resolved = "every placed module has a grant at the fingerprint the indexer names"
}
if p.Say(c) == nil {
p.missingSaid, p.missingKnown = why, true
}
}
// GrantsOf is the grant of every module at the fingerprint the indexer's trunk names for it, by key, and the keys that
// have none (drop): see the comment above. A module in skip (its manifest was refused) is neither. Pure.
func GrantsOf(in Inputs, recs buscompose.Records, skip map[string]bool) (map[string]delivery.ControllerGrant, []string, error) {
principals, err := buscompose.Users(recs)
if err != nil {
return nil, nil, err
}
accounts, missing := map[string][]string{}, map[string]bool{}
for _, p := range principals {
if p.Module == "" || (p.Kind != buscompose.KindModule && p.Kind != buscompose.KindProcess) {
continue
}
if in.Hashes[p.Username()].Hash == "" {
missing[p.Module] = true
continue
}
accounts[p.Module] = append(accounts[p.Module], p.Username())
}
out := map[string]delivery.ControllerGrant{}
var drop []string
for module, fp := range in.Fingerprints {
if skip[module] || !strings.HasPrefix(fp, delivery.SourcePrefix2) {
continue
}
key, err := delivery.PartGrantKey(module, fp)
if err != nil {
continue
}
names := accounts[module]
if missing[module] && len(names) == 0 {
drop = append(drop, key)
continue
}
sort.Strings(names)
out[key] = delivery.ControllerGrant{Accounts: names}
}
sort.Strings(drop)
return out, drop, nil
}
// grantsReadFailed names a read of this pass without which no grant is written: the hashes and the indexer's modules and
// manifests. A manifest refused for one module only leaves that module out (writeGrants).
func grantsReadFailed(in Inputs) string {
for _, g := range in.Gaps {
if !g.Failed {
continue
}
for _, input := range []string{ReadAccounts, ReadModules, ReadManifests} {
if strings.HasPrefix(g.Input, input) {
return g.Input + ": " + g.Why
}
}
}
return ""
}
// writeGrants writes each grant whose key is absent or differs and says grant-written for each, and deletes the key of a
// module that has none now.
func (p *Part) writeGrants(res *Result, in Inputs, recs buscompose.Records) {
if why := grantsReadFailed(in); why != "" {
res.Grants = &GrantsWritten{Kept: []string{"no grant is written or deleted in a pass that could not read " + why}}
return
}
skip := map[string]bool{}
for _, g := range in.Refused {
skip[strings.TrimPrefix(g.Input, "manifest of ")] = true
}
want, drop, err := GrantsOf(in, recs, skip)
if err != nil {
res.Errors = append(res.Errors, "the grants could not be composed: "+err.Error())
return
}
done := &GrantsWritten{Wanted: len(want), Missing: MissingGrants(in, want, drop, skip)}
res.Grants = done
p.sayMissing(done.Missing)
for _, key := range sortedKeys(want) {
g := want[key]
data, err := delivery.EncodeControllerGrant(g)
if err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("the grant %s cannot be encoded: %v", key, err))
continue
}
if raw, _, found, err := p.Store.Get(StateGrants, key); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be read: %v", StateGrants, key, err))
continue
} else if found {
if was, err := delivery.DecodeControllerGrant(raw); err == nil && delivery.SameControllerGrant(was, withVersion(g)) {
continue
}
}
if _, err := p.Store.Put(StateGrants, key, data); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateGrants, key, err))
continue
}
done.Written = append(done.Written, key)
module, fp, _ := delivery.ParsePartGrantKey(key)
p.say(res, EventGrantWritten, map[string]any{"module": module, "fingerprint": fp, "accounts": g.Accounts})
}
for _, key := range drop {
if _, _, found, err := p.Store.Get(StateGrants, key); err != nil || !found {
continue
}
if err := p.Store.Delete(StateGrants, key); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be deleted: %v", StateGrants, key, err))
continue
}
done.Deleted = append(done.Deleted, key)
}
}
func withVersion(g delivery.ControllerGrant) delivery.ControllerGrant {
g.V = delivery.Version
return g
}
// GrantDifference is one key on which the part's grants and the controller's differ.
type GrantDifference struct {
Key string `json:"key"`
// Kind is `only the controller's`, `only the part's` or `accounts differ`.
Kind string `json:"kind"`
Controller []string `json:"controller_accounts,omitempty"`
Part []string `json:"part_accounts,omitempty"`
}
// CompareGrants lists every key the two sets differ on, in key order: a key held by one alone, or held by both with other
// accounts. Pure.
func CompareGrants(controller, part map[string]delivery.ControllerGrant) []GrantDifference {
keys := map[string]bool{}
for k := range controller {
keys[k] = true
}
for k := range part {
keys[k] = true
}
out := []GrantDifference{}
for _, k := range sortedKeys(keys) {
c, inC := controller[k]
g, inP := part[k]
switch {
case inC && !inP:
out = append(out, GrantDifference{Key: k, Kind: "only the controller's", Controller: c.Accounts})
case inP && !inC:
out = append(out, GrantDifference{Key: k, Kind: "only the part's", Part: g.Accounts})
case !delivery.SameControllerGrant(withVersion(c), withVersion(g)):
out = append(out, GrantDifference{Key: k, Kind: "accounts differ", Controller: c.Accounts, Part: g.Accounts})
}
}
return out
}
+13 -66
View File
@@ -38,11 +38,9 @@ const (
ReadPlacement = delivery.PlacementRead
)
// Reads is every state the part reads, in the manifest's order. ReadAssignments stays (and in Followed) only while the
// controller holds the lease, when the assignments are unioned with the placement (ReadInputs); after the lease write
// they place nothing, and the first change after it takes them out (../assignments-readers names the part until then).
// Reads is every state the part reads, in the manifest's order.
var Reads = []string{ReadAssignments, ReadControllerGrant, ReadModules, ReadManifests, ReadResolved, ReadHave, ReadWants, ReadBuilds,
ReadAccounts, ReadPlacement, ReadLease}
ReadAccounts, ReadPlacement}
// The bus module's tools the part calls, as its manifest's invokes name them: read-only, each.
const (
@@ -107,7 +105,7 @@ type resolved struct {
type Inputs struct {
// Nodes is every machine seen: in a node agent's have or a standing assignment.
Nodes []string
// Assigned is each machine's standing assignments and what the seat's placement puts there, sorted: while both
// Assigned is each machine's standing assignments and what the catalogue's placement puts there, sorted: while both
// stand (ADR 0549 §5) a module either names keeps its membership.
Assigned map[string][]string
// Placed is each node's placement as the seat mesh-placement holds it (ADR 0567 §1); empty before any is read.
@@ -117,12 +115,6 @@ type Inputs struct {
Manifests map[string]catalogue.Manifest
// Digests is each module's manifest digest, by module.
Digests map[string]string
// Fingerprints is the fingerprint of the new form the indexer's trunk value names for each module, by module: what a
// grant is written for (grants.go).
Fingerprints map[string]string
// NoFingerprint is why the indexer's value of a module names no fingerprint of the new form, by module (its none2, or
// that it names none): a module here gets no grant, and that is said (grants.go MissingGrants).
NoFingerprint map[string]string
// Running is the manifest a machine is wanted to run, by `<node>.<module>`: the one the builder built at the
// fingerprint the assigner's want names. An assignment with none is composed from the trunk's manifest.
Running map[string]catalogue.Manifest
@@ -146,22 +138,6 @@ type HeldHash struct {
Generation int `json:"generation,omitempty"`
At time.Time `json:"at"`
Adopted bool `json:"adopted,omitempty"`
// Fingerprint is the vault's fingerprint of the value the hash is of: with the generation, what says the password
// changed, never the hash itself (a hash of the same password differs by its salt).
Fingerprint string `json:"fingerprint,omitempty"`
// Rotating is the vault's rotation of the account under way: a change of its hash it announces (ADR 0561 §4, §7).
Rotating *struct {
Login string `json:"login"`
} `json:"rotating,omitempty"`
}
// credentialOf is what says which password a held hash is of, never the hash: adopted, or the vault's generation and the
// fingerprint of its value.
func credentialOf(h HeldHash) string {
if h.Adopted {
return "adopted"
}
return fmt.Sprintf("generation %d %s", h.Generation, h.Fingerprint)
}
// readHashes reads the vault's bus-accounts; a refusal is a gap, and every user then has no hash held.
@@ -245,23 +221,19 @@ func each(r Reader, state string, gaps *[]Gap) map[string][]byte {
// ReadInputs reads what the shadow composes from. It never fails: what it cannot read is a gap.
func ReadInputs(r Reader, cache *Manifests) Inputs {
in := Inputs{Assigned: map[string][]string{}, Manifests: map[string]catalogue.Manifest{}, Digests: map[string]string{}, Fingerprints: map[string]string{},
NoFingerprint: map[string]string{}, Running: map[string]catalogue.Manifest{},
Values: map[string]any{}, Gaps: append([]Gap(nil), standingGaps...)}
in := Inputs{Assigned: map[string][]string{}, Manifests: map[string]catalogue.Manifest{}, Digests: map[string]string{},
Running: map[string]catalogue.Manifest{},
Values: map[string]any{}, Gaps: append([]Gap(nil), standingGaps...)}
nodes := map[string]bool{}
// **After the lease write the assignments place nothing** (novox/hq ADR 0549 §3, §6): they are frozen then, so a
// module the catalogue takes off a node would keep its account, grant, membership and user-list entry. Once the lease
// is handed away and the placement is read, the placement alone places modules, as the assigner's Placed does with
// the same lease reading (buscompose.LeaseHandedAway); before, or with no placement on the bus, the two are unioned.
byAssignment, busByAssignment := map[string][]string{}, ""
for _, raw := range each(r, ReadAssignments, &in.Gaps) {
var a assignment
if json.Unmarshal(raw, &a) != nil || !a.stands() || a.Node == "" || a.Module == "" {
continue
}
byAssignment[a.Node] = append(byAssignment[a.Node], a.Module)
nodes[a.Node] = true
in.Assigned[a.Node] = append(in.Assigned[a.Node], a.Module)
if a.Module == BusModule {
busByAssignment = a.Node
in.BusNode = a.Node
}
}
// The seat's placement (ADR 0567 §9): what it places is composed beside the assignments, and the seats held once
@@ -285,27 +257,11 @@ func ReadInputs(r Reader, cache *Manifests) Inputs {
in.Placed = map[string]delivery.Placement{}
}
in.Placed[node] = p
}
}
away, _ := leaseHandedAway(r)
if away && len(in.Placed) > 0 {
in.Gaps = append(in.Gaps, Gap{Input: ReadAssignments, Why: "not used: the controller's lease is handed away, " +
"so the seat's placement alone places modules (ADR 0549 §3, §6)"})
} else {
for node, ms := range byAssignment {
nodes[node] = true
in.Assigned[node] = append(in.Assigned[node], ms...)
}
in.BusNode = busByAssignment
}
for node, p := range in.Placed {
nodes[node] = true
for _, m := range p.Assigned() {
if !slices.Contains(in.Assigned[node], m) {
in.Assigned[node] = append(in.Assigned[node], m)
}
if m == BusModule && in.BusNode == "" {
in.BusNode = node
for _, m := range p.Assigned() {
if !slices.Contains(in.Assigned[node], m) {
in.Assigned[node] = append(in.Assigned[node], m)
}
}
}
}
@@ -366,15 +322,6 @@ func ReadInputs(r Reader, cache *Manifests) Inputs {
if err == nil && v.Manifest == "" {
err = fmt.Errorf("the indexer's value names no manifest digest")
}
if err == nil && strings.HasPrefix(v.FP2, delivery.SourcePrefix2) {
in.Fingerprints[module] = v.FP2
} else if err == nil {
why := v.None2
if why == "" {
why = "the indexer's value names no fingerprint of the new form"
}
in.NoFingerprint[module] = why
}
var m catalogue.Manifest
if err == nil {
m, err = cache.get(r, v.Manifest)
+14 -60
View File
@@ -13,7 +13,6 @@ import (
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/membership"
)
@@ -24,38 +23,31 @@ const (
StateMemberships = "memberships"
StateAssertions = "assertions"
StateTakeover = "takeover"
// StateGrants is the part's grants by module and fingerprint, the key layout of the controller's (grants.go).
StateGrants = delivery.PartGrantsState
KeyList = "list"
KeySwitch = "switch"
KeyList = "list"
KeySwitch = "switch"
// KeyPass is the last pass's result, and KeyAsked a tool's ask to compose again now, both in StateUserList.
KeyPass = "pass"
KeyAsked = "asked"
)
// States is every state the part keeps.
var States = []string{StateAccounts, StateAssertions, StateGrants, StateMemberships, StateTakeover, StateUserList}
var States = []string{StateAssertions, StateMemberships, StateTakeover, StateUserList}
// The events the part says.
const (
EventUserList = "user-list-changed"
EventMembership = "membership-changed"
// EventGrantWritten is said when the part wrote a grant of its state grants (ADR 0547 §5, ADR 0561 §2).
EventGrantWritten = "grant-written"
// EventAsserted is said when the part created a bucket, log or stream a manifest declares (issue 1790).
EventAsserted = "asserted"
)
// Emits is every event the part says.
var Emits = []string{EventAsserted, EventGrantWritten, EventHashesAccepted, EventMembership, EventUserList}
var Emits = []string{EventAsserted, EventMembership, EventUserList}
// Store is the part's own state, by local name: the only thing the part writes.
type Store interface {
Get(state, key string) ([]byte, uint64, bool, error)
Put(state, key string, value []byte) (uint64, error)
// Keys and Delete are for the wanted accounts (accounts.go) and the grants (grants.go): every other state is only put.
Keys(state string) ([]string, error)
Delete(state, key string) error
}
// Emitter says one of the part's events.
@@ -119,15 +111,9 @@ type Result struct {
// free, <why>`: a verified sender is composed as from no root-free machine there (issue 1945).
RootNotFree []string `json:"root_not_free,omitempty"`
// Sealed is the list sealed to the bus node as the key `user-list.sealed` names it (ADR 0547 clause 7).
Sealed *SealedState `json:"sealed,omitempty"`
// Consumers is what the pass found and created of the durable consumers an account needs (ADR 0561 §6).
Consumers *ConsumersState `json:"consumers,omitempty"`
// Accounts is what the pass wrote of the wanted accounts the vault mints from (ADR 0561 §2).
Accounts *AccountsWritten `json:"accounts,omitempty"`
// Grants is what the pass wrote of its grants (grants.go).
Grants *GrantsWritten `json:"grants,omitempty"`
Errors []string `json:"errors,omitempty"`
PublishedOrAsserted string `json:"published_or_asserted"`
Sealed *SealedState `json:"sealed,omitempty"`
Errors []string `json:"errors,omitempty"`
PublishedOrAsserted string `json:"published_or_asserted"`
}
// NothingTakesEffect is what every composition says it did on the bus beyond its own state and the memberships.
@@ -150,12 +136,6 @@ type Part struct {
Logf func(string, ...any)
// Objects is the part's object bucket of sealed lists: the process's connection; nil in the tools.
Objects Objects
// Consumers creates an account's durable consumer from the lease write (consumers.go): the process's connection;
// nil in the tools, which compare only.
Consumers ConsumerCreator
// Bus is where the nodes reach the bus, which each wanted account carries (accounts.go): the process's own credential's;
// empty in the tools, which write no account.
Bus BusAddress
// Say says a condition to the operator: the process's; nil in the tools.
Say func(asks.Condition) error
// Forget takes away the record the asker kept of an ask it failed to publish; Nonce is a test's id suffix.
@@ -177,14 +157,6 @@ type Part struct {
holdMu sync.Mutex
heldSaid string
heldKnown bool
// differSaid is the account consumers' differences last said as a condition, once differKnown (consumers.go).
differSaid string
differKnown bool
// missingSaid is the modules without a grant last said as a condition, once missingKnown (grants.go).
missingSaid string
missingKnown bool
// hashHold are the users whose hash changes held the pass's list, or nil (sealed.go).
hashHold []string
}
// Ask is the value of KeyAsked: a tool's ask to compose now, and, from grants_assert, to create one module's missing
@@ -193,21 +165,18 @@ type Ask struct {
By string `json:"by"`
At time.Time `json:"at"`
Assert string `json:"assert,omitempty"`
// Consumer is, from grants_consumer_match, one account consumer `<stream>.<name>` to bring to the composed
// configuration by hand (ADR 0561 §6).
Consumer string `json:"consumer,omitempty"`
}
// asked is a new ask, read once per ask; the zero Ask when none.
func (p *Part) asked() Ask {
// askedAssert is the module a new ask names to assert by hand, read once per ask; empty when none.
func (p *Part) askedAssert() string {
raw, rev, found, err := p.Store.Get(StateUserList, KeyAsked)
if err != nil || !found || rev == p.askSeen {
return Ask{}
return ""
}
p.askSeen = rev
var a Ask
_ = json.Unmarshal(raw, &a)
return a
return a.Assert
}
func (p *Part) now() time.Time {
@@ -271,14 +240,6 @@ func (p *Part) Compose() Result {
}
}
}
// The accounts the vault mints from, said in the state only it reads (ADR 0561 §2).
if p.Store != nil {
p.writeAccounts(&res, in, recs)
}
// The grants the assigner reads once the lease is handed away, in shadow until then (ADR 0547 §4, §5).
if p.Store != nil {
p.writeGrants(&res, in, recs)
}
// The list itself, sealed to the bus node and named in `user-list.sealed` (ADR 0547 clause 7).
if list, n, leftOut, err := ComposeList(recs, in.Hashes); err != nil {
res.Errors = append(res.Errors, "the user list could not be composed whole: "+err.Error())
@@ -313,11 +274,10 @@ func (p *Part) Compose() Result {
}
}
held, heldErr := HeldStreams(p.Call)
var ask Ask
if p.Create != nil || p.Consumers != nil {
ask = p.asked()
forced := ""
if p.Create != nil {
forced = p.askedAssert()
}
forced := ask.Assert
for _, module := range sortedKeys(in.Manifests) {
if !assigned(in, module) {
continue
@@ -339,7 +299,6 @@ func (p *Part) Compose() Result {
res.AssertionsWritten++
}
}
p.consumers(&res, recs, ask.Consumer)
if _, _, found, err := p.Store.Get(StateTakeover, KeySwitch); err == nil && !found {
data, _ := json.Marshal(Switch{V: 1, State: Shadow, Acts: []Act{}})
if _, err := p.Store.Put(StateTakeover, KeySwitch, data); err != nil {
@@ -365,11 +324,6 @@ func (p *Part) Last() Result {
}
func assigned(in Inputs, module string) bool {
// The node-engine runs on every node though no node is assigned it (novox/hq ADR 0563 §2, §3): its manifest is
// placed everywhere, so its declared state is asserted and created as a placed module's.
if module == buscompose.EngineModule {
return true
}
for _, ms := range in.Assigned {
for _, m := range ms {
if m == module {
@@ -78,11 +78,6 @@ func Records(in Inputs) (buscompose.Records, []Gap) {
}
}
}
// The node-engine's own manifest, placed on every node (ADR 0563): its state, logs and streams alone, created by this
// part; it adds no principal, since each node's engine speaks as the node's account.
if m, ok := in.Manifests[buscompose.EngineModule]; ok {
out.Engine = &buscompose.Declared{Module: m.Module, State: bucketsOf(m), Logs: logsOf(m), Streams: streamsOf(m)}
}
return out, gaps
}
@@ -23,17 +23,8 @@ const (
KindRootNotFree = "root-not-free"
)
// EventAccountIssued is the vault's word that it minted an account (novox/hq ADR 0561 §1): its hash is in the vault's
// bus-accounts, so a pass composes the list with it.
const EventAccountIssued = "mesh-vault." + buscompose.AccountIssuedEvent
// Consumes is every event the part hears, as its manifest's consumes name them.
var Consumes = []string{EventConditionRaised, EventConditionCleared, EventAccountIssued}
// IsAccountIssued says an event heard is the vault's account-issued, by its name or its bus subject.
func IsAccountIssued(event string) bool {
return event == EventAccountIssued || event == buscompose.AccountIssuedSubject
}
var Consumes = []string{EventConditionRaised, EventConditionCleared}
// EventSubject is the bus subject of a controller seat event named `mesh-controller.<event>`.
func EventSubject(event string) string {
+6 -172
View File
@@ -2,12 +2,10 @@ package grants
import (
"encoding/json"
"errors"
"fmt"
"regexp"
"slices"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/buscompose"
@@ -93,12 +91,11 @@ func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut []string
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, digest) != "":
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, digest)
why = p.holdsBack(in, leftOut, bus, lost)
held = why
p.keepHashHold(res, digest)
case cur.Digest != digest || cur.SealedTo != delivery.SealingKeyID(key) || cur.Node != in.BusNode:
object, err := delivery.SealUserList(list, key)
if err == nil {
@@ -136,8 +133,7 @@ func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut []string
return
}
if next.Digest == digest && cur.Digest != digest {
p.keepWrittenUsers(res, list, in.Hashes)
p.acceptedWritten(res, 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 {
@@ -292,50 +288,11 @@ func (p *Part) followReasons(res Result, in Inputs) []string {
// composed after take-over must keep.
const KeyWrittenUsers = "sealed-users"
// KeyWrittenCredentials is the key of the state user-list naming, per user of the list in force, which password its hash
// is of (credentialOf: adopted, or the vault's generation and fingerprint), never the hash: what tells a list that changes
// several loaded users' hashes at once (ADR 0561 §7).
const KeyWrittenCredentials = "sealed-credentials"
func (p *Part) keepWrittenUsers(res *Result, list []byte, hashes map[string]HeldHash) {
users := listUsers(list)
data, _ := json.Marshal(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))
}
creds := map[string]string{}
for _, u := range users {
if h, ok := hashes[u]; ok {
creds[u] = credentialOf(h)
}
}
data, _ = json.Marshal(creds)
if _, err := p.Store.Put(StateUserList, KeyWrittenCredentials, data); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateUserList, KeyWrittenCredentials, err))
}
}
// changedHashes are the users of the list in force whose hash the list composed now would change with no rotation by the
// vault naming them, sorted; none when the part kept no credentials of the list in force (a list written before).
func (p *Part) changedHashes(hashes map[string]HeldHash) ([]string, error) {
raw, _, found, err := p.Store.Get(StateUserList, KeyWrittenCredentials)
if err != nil || !found {
return nil, err
}
var had map[string]string
if err := json.Unmarshal(raw, &had); err != nil {
return nil, err
}
var changed []string
for user, was := range had {
h, ok := hashes[user]
if !ok || credentialOf(h) == was || h.Rotating != nil {
continue
}
changed = append(changed, user)
}
slices.Sort(changed)
return changed, nil
}
var listUser = regexp.MustCompile(`\{\s*user:\s*"([^"]+)"`)
@@ -355,8 +312,7 @@ func listUsers(list []byte) []string {
// 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, digest string) string {
p.hashHold = nil
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, "; ") + ")"
}
@@ -386,127 +342,5 @@ func (p *Part) holdsBack(in Inputs, leftOut, bus, lost []string, digest string)
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"
}
// More than one loaded user's hash changing at once, with no rotation by the vault naming each (ADR 0561 §7): a new
// writer of hashes must not lock several holders out in one list, as nine seats were cut on 2026-10-27 at 09:21.
changed, err := p.changedHashes(in.Hashes)
if err != nil {
return "held: the credentials of the list in force cannot be read: " + err.Error()
}
if len(changed) > 1 && !p.accepted(digest, changed) {
p.hashHold = changed
return "held: the list in force is kept, since the hashes of " + fmt.Sprint(len(changed)) + " loaded users would change at " +
"once with no rotation by the vault naming them: " + strings.Join(changed, ", ")
}
return ""
}
// **A person accepts a held list's hash changes** (ADR 0561 §7, §9; tools-are-how-a-module-is-spoken-to): the hold of
// several loaded users' hashes changing at once is released only by grants_accept_hashes, naming exactly the users held,
// with why, bound to the held list's digest. The acceptance releases this guard alone, once: the other holds (a failed
// read, a user dropped for want of a hash, a lost right) stand whatever it says; and a list composed otherwise since is
// held again, for a new acceptance.
// KeyHashHold names the list held for its hash changes (its digest and the users), and KeyHashAccept a person's acceptance.
const (
KeyHashHold = "hash-hold"
KeyHashAccept = "hash-accept"
)
// EventHashesAccepted is said when a list held for its hash changes is written on a person's acceptance: a hand act.
const EventHashesAccepted = "hashes-accepted"
// HashHold is the value of KeyHashHold, and HashAccept of KeyHashAccept.
type HashHold struct {
Digest string `json:"digest"`
Users []string `json:"users"`
At time.Time `json:"at"`
}
type HashAccept struct {
Digest string `json:"digest"`
Users []string `json:"users"`
By string `json:"by"`
Why string `json:"why"`
At time.Time `json:"at"`
}
// keepHashHold writes which list is held for its hash changes, when that is the hold.
func (p *Part) keepHashHold(res *Result, digest string) {
if p.hashHold == nil {
return
}
data, _ := json.Marshal(HashHold{Digest: digest, Users: p.hashHold, At: p.now()})
if raw, _, found, err := p.Store.Get(StateUserList, KeyHashHold); err == nil && found {
var was HashHold
if json.Unmarshal(raw, &was) == nil && was.Digest == digest && slices.Equal(was.Users, p.hashHold) {
return
}
}
if _, err := p.Store.Put(StateUserList, KeyHashHold, data); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateUserList, KeyHashHold, err))
}
}
// accepted says a person accepted exactly these users' hash changes of the list of this digest.
func (p *Part) accepted(digest string, users []string) bool {
raw, _, found, err := p.Store.Get(StateUserList, KeyHashAccept)
if err != nil || !found {
return false
}
var a HashAccept
return json.Unmarshal(raw, &a) == nil && a.Digest == digest && slices.Equal(a.Users, users)
}
// acceptedWritten says, once, that the list a person accepted is written, and forgets the acceptance and the hold.
func (p *Part) acceptedWritten(res *Result, digest string) {
raw, _, found, err := p.Store.Get(StateUserList, KeyHashAccept)
if err != nil || !found {
return
}
var a HashAccept
if json.Unmarshal(raw, &a) != nil || a.Digest != digest {
return
}
p.say(res, EventHashesAccepted, map[string]any{"digest": digest, "users": a.Users, "by": a.By, "why": a.Why})
for _, k := range []string{KeyHashAccept, KeyHashHold} {
if err := p.Store.Delete(StateUserList, k); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be taken away: %v", StateUserList, k, err))
}
}
}
// AcceptHashes is the tool accept-hashes: a person accepts the hash changes of the list held for them, naming exactly its
// users, with why. Refused when no list is held for its hashes, or for another set of users. Bound to the held list's
// digest: a list composed otherwise since is held again.
func (p *Part) AcceptHashes(caller string, users []string, why string) (any, error) {
if strings.TrimSpace(why) == "" {
return nil, errors.New("accept-hashes says why (`why`)")
}
raw, _, found, err := p.Store.Get(StateUserList, KeyHashHold)
if err != nil {
return nil, fmt.Errorf("the held list cannot be read: %w", err)
}
var h HashHold
if !found || json.Unmarshal(raw, &h) != nil || h.Digest == "" {
return nil, errors.New("no list is held for its users' hashes changing: there is nothing to accept")
}
named := slices.Sorted(slices.Values(users))
if !slices.Equal(named, h.Users) {
return nil, fmt.Errorf("the list held (%s) changes the hashes of %s: an acceptance names exactly those, and %s is not that",
short(h.Digest), strings.Join(h.Users, ", "), strings.Join(named, ", "))
}
data, _ := json.Marshal(HashAccept{Digest: h.Digest, Users: h.Users, By: someoneOr(caller), Why: strings.TrimSpace(why), At: p.now()})
rev, err := p.Store.Put(StateUserList, KeyHashAccept, data)
if err != nil {
return nil, fmt.Errorf("the acceptance could not be written: %w", err)
}
return map[string]any{"accepted": rev, "digest": h.Digest, "users": h.Users, "says": "the part's process writes the held " +
"list at its next pass, once, unless another hold stands or the list composed then is another; it says hashes-accepted"}, nil
}
func someoneOr(caller string) string {
if caller == "" {
return "a caller the runtime did not prove"
}
return caller
}
+27 -75
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/delivery"
@@ -20,92 +21,43 @@ const (
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}
var Tools = []string{ToolGrants, ToolMembership, ToolUserList, ToolComposeNow, ToolAssert, ToolTakeOver, ToolHandBack, ToolTakeOverState}
// WritesGrants is what grants says of the part's own grants in this milestone: written, and read by nobody until the
// controller's lease is handed away (delivery.GrantSource).
const WritesGrants = "in shadow: the grants part writes grants.<module>.<fp> into its state grants, which the assigner reads only once the controller's lease is handed away (ADR 0547 clause 5); the controller's are beside, with every difference"
// WritesNoGrant is what grants says of the part's own grants in this milestone.
const WritesNoGrant = "none: the grants part writes no grants.<module>.<fp> before ADR 0547's third milestone (clause 5); the controller's are beside"
// Grants answers the tool grants: the grants the part wrote, the controller's, and every difference between them, one
// module's or all.
// Grants answers the tool grants: what the part writes of grants (none), and the controller's, 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
keys, err := p.Read.Keys(ReadControllerGrant)
if err != nil {
return map[string]any{"writes": WritesNoGrant, "controller_unread": err.Error()}, nil
}
controller, cerr := read(func() ([]string, func(string) ([]byte, bool, error), error) {
keys, err := p.Read.Keys(ReadControllerGrant)
return keys, func(k string) ([]byte, bool, error) { return p.Read.Get(ReadControllerGrant, k) }, err
})
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"`
Module string `json:"module"`
Fingerprint string `json:"fingerprint"`
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})
out := []grant{}
for _, k := range keys {
m, fp, err := delivery.ParseControllerGrantKey(k)
if err != nil || (module != "" && m != module) {
continue
}
return out
g := grant{Module: m, Fingerprint: fp}
if module != "" {
if raw, found, err := p.Read.Get(ReadControllerGrant, k); err == nil && found {
if v, err := delivery.DecodeControllerGrant(raw); err == nil {
g.Accounts = v.Accounts
}
}
}
out = append(out, g)
}
if cerr != nil {
out["controller_unread"] = cerr.Error()
} else {
out["controller"], out["controller_count"] = list(controller), len(controller)
}
if perr != nil {
out["written_unread"] = perr.Error()
} else {
out["written"], out["count"] = list(mine), len(mine)
}
if cerr == nil && perr == nil {
diff := CompareGrants(controller, mine)
out["differences"], out["differing"] = diff, len(diff)
out["equal"] = len(diff) == 0
}
if last := p.Last(); last.Grants != nil {
out["last_pass"] = last.Grants
}
return out, nil
sort.Slice(out, func(i, j int) bool { return out[i].Module+out[i].Fingerprint < out[j].Module+out[j].Fingerprint })
return map[string]any{"writes": WritesNoGrant, "controller": out, "count": len(out)}, nil
}
// MembershipOf answers the tool membership: one module on one node as composed now, and as last published by the
+3 -18
View File
@@ -12,13 +12,11 @@
"mesh-delivery-indexer.modules",
"mesh-delivery-assigner.placement",
"node-engine.have",
"mesh-vault.bus-accounts",
"mesh-controller.lease"
"mesh-vault.bus-accounts"
],
"consumes": [
"mesh-controller.condition-raised",
"mesh-controller.condition-cleared",
"mesh-vault.account-issued"
"mesh-controller.condition-cleared"
],
"uses": [
"operator-channel"
@@ -31,8 +29,6 @@
],
"emits": [
"asserted",
"grant-written",
"hashes-accepted",
"membership-changed",
"user-list-changed"
],
@@ -44,24 +40,13 @@
"grants_assert",
"grants_take_over",
"grants_take_over_state",
"grants_hand_back",
"grants_accounts",
"grants_consumer_match",
"grants_accept_hashes"
"grants_hand_back"
],
"state": [
{
"name": "accounts",
"history": 5
},
{
"name": "assertions",
"history": 5
},
{
"name": "grants",
"history": 5
},
{
"name": "memberships",
"history": 5