Files
mesh-controller/cmd/mesh-controller/grants.go
T
jschoubben fbf14b68db
mesh/delivery held for a person: merged without a passing check: only a person decides what follows
mesh/delivery-group group fix/1945-credential-when-row-without-seal held: a member is held: novox/mesh-controller@fbf14b68dbab (merged without a passing check:…
mesh/check checking fbf14b68dbab on 40034be479f8
Forget the accounts of modules no longer on their nodes at the start's look, and grant sealed accounts alone (issue 1945, ADR 0543, review)
Migration 0108 marks every standing account sealed, ace's stale mesh-build-go
rows from 17:19 among them, and the forgetting ran only at a later unassign:
the next assignment to ace would have issued nothing again.

- The start's look first forgets, from each node's own resolution (assigned
  and required), the accounts of every module that no longer resolves on its
  node, and says each; only from a reading that holds assignments, and a node
  whose resolution is unknown is left alone.
- Unassign forgets a module's accounts only once it no longer resolves on the
  node (a module another requires keeps running), and never the node's own
  provisioner account; the forgetting moved from the inventory to the act.
- Forgetting holds each account's lock across its delete, so an issue in flight
  never writes a row back after it.
- A module whose accounts are not all sealed is granted its sealed accounts
  alone, and its grant is dropped when none is: an older grant naming ace no
  longer lets the assigner want it.
- An issue's locked section has a deadline of two minutes, since it holds a
  connection of the store's pool across the vault and the bus.
Tests: cases in the existing tables.
Research 072: the grants piece (memberships and credentials heard from placement).
2026-10-25 20:27:30 +01:00

289 lines
11 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"sort"
"strings"
sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/nats-io/nats.go/jetstream"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/builderkeys"
"github.com/novox/mesh-controller/internal/inventory"
)
// The grants issued (novox/hq ADR 0528 clause 2, ADR 0429 §6 rule 6, issue 1394): what the assigner's sixth rule reads,
// that a module's bus credential for a fingerprint exists. A module's permissions on the bus are what the bus's user list
// composes from its registered manifest (inv.BusRecords), and a module's manifest is registered at a fingerprint when the
// controller takes it in from the builder's key; so once the list is sent to the machine that runs the bus, the
// credential of every module it carried is issued at the fingerprint of the build it is registered at. The controller
// writes `<module>.<fingerprint hex>` then, naming the accounts it composed for the module, never removed. A module one
// of whose accounts has no password yet (the list leaves it out) has no grant until a later send carries it.
// grantsFrom is the grant of every module at the fingerprint current names for it, naming its accounts that have a
// sealed password in hashes; a module whose current build names no fingerprint of the new form has none. A module one
// of whose accounts has none is granted its sealed accounts alone, and when it has none, its grant is dropped (drop):
// an older grant naming a node whose account is gone would let the assigner want it (issue 1945, rule 6 per node), and
// an empty grant names every node. principals are the bus's users as broker.Users composes them.
func grantsFrom(principals []broker.Principal, hashes map[string]string, current map[string]string) (
map[string]sdkdelivery.ControllerGrant, []string) {
accounts, missing := map[string][]string{}, map[string]bool{}
for _, p := range principals {
if p.Module == "" || (p.Kind != broker.KindModule && p.Kind != broker.KindProcess) {
continue
}
if hashes[p.Username()] == "" {
missing[p.Module] = true
continue
}
accounts[p.Module] = append(accounts[p.Module], p.Username())
}
out := map[string]sdkdelivery.ControllerGrant{}
var drop []string
for module, fp := range current {
if !strings.HasPrefix(fp, sdkdelivery.SourcePrefix2) {
continue
}
key, err := sdkdelivery.ControllerGrantKey(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] = sdkdelivery.ControllerGrant{Accounts: names}
}
sort.Strings(drop)
return out, drop
}
// currentFingerprints is, by module, the fingerprint of the build each registered module is registered at: its
// manifest's commit (built_from), that commit's newest successful build, and the fingerprint that build names.
//
// **A commit with no build of its own asks the indexer** (issue 1945, live from 2026-10-25 12:43). A catalogue merge
// that leaves a module's sources alone re-indexes it at the merge's commit with the fingerprint it had: the take-in
// registers the module at the new commit from the same done key, whose build record (one per key and revision) already
// stands at the commit it was first taken in at, so the new commit has no build pointing at it. Read only by the
// commit, such a module had no fingerprint of the new form: it was composed into the node's own composition while its
// want named its own, and every node declared its files twice. The indexer's trunk value at the commit names the
// fingerprint the module was registered from, so that is its fingerprint.
func currentFingerprints(ctx context.Context, inv *inventory.Inventory) (map[string]string, error) {
builds, err := inv.CurrentBuilds(ctx)
if err != nil {
return nil, err
}
commits := make(map[string]string, len(builds))
for module, b := range builds {
commits[module] = b.Commit
}
var indexed func(ctx context.Context, module, commit string) (string, error)
var closeIndexed func()
defer func() {
if closeIndexed != nil {
closeIndexed()
}
}()
return fingerprintsAt(ctx, commits,
func(ctx context.Context, module, commit string) (string, bool, error) {
id, err := inv.BuildOf(ctx, module, commit)
if err != nil || id == "" {
return "", false, nil
}
build, found, err := inv.BuildByID(ctx, id)
if err != nil || !found {
return "", false, nil
}
return build.SourceFingerprint, true, nil
},
func(ctx context.Context, module, commit string) (string, error) {
if indexed == nil {
read, done, err := openIndexedFingerprints()
if err != nil {
return "", err
}
indexed, closeIndexed = read, done
}
return indexed(ctx, module, commit)
})
}
// fingerprintsAt is, by module, the fingerprint of the build registered at its commit (built), or else the fingerprint
// of the new form the indexer's trunk value at that commit names (indexed). A module with no commit (held without a
// source) has none, and a module neither knows has none. An error of the indexer's is the answer's: a module left out
// for it would be composed into the node's own composition beside its own (issue 1945).
func fingerprintsAt(ctx context.Context, commits map[string]string,
built func(ctx context.Context, module, commit string) (string, bool, error),
indexed func(ctx context.Context, module, commit string) (string, error)) (map[string]string, error) {
modules := make([]string, 0, len(commits))
for module := range commits {
modules = append(modules, module)
}
sort.Strings(modules)
out := map[string]string{}
for _, module := range modules {
commit := commits[module]
if commit == "" {
continue
}
fp, found, err := built(ctx, module, commit)
if err != nil {
return nil, err
}
if found {
out[module] = fp
continue
}
fp, err = indexed(ctx, module, commit)
if err != nil {
return nil, fmt.Errorf("the fingerprint %s is registered at (%.8s): %w", module, commit, err)
}
if strings.HasPrefix(fp, sdkdelivery.SourcePrefix2) {
out[module] = fp
}
}
return out, nil
}
// openIndexedFingerprints opens the indexer's trunk values: what reads the fingerprint of the new form a module's value
// at a commit names ("" when it holds none for that commit), and what closes the bus connection it opened.
var openIndexedFingerprints = func() (func(ctx context.Context, module, commit string) (string, error), func(), error) {
keys, closeKeys, err := openBuilderKeys()
if err != nil {
return nil, nil, fmt.Errorf("the indexer's trunk values: %w", err)
}
return func(ctx context.Context, module, commit string) (string, error) {
return indexedFingerprintAt(ctx, keys, module, commit)
}, closeKeys, nil
}
// indexedFingerprintAt is the fingerprint of the new form the indexer's value of module at commit names: the value it
// holds now when that is the commit's (one read), else the newest of the key's history at the commit; "" when none.
func indexedFingerprintAt(ctx context.Context, keys interface {
ModuleAt(ctx context.Context, module, commit string) (sdkdelivery.Module, error)
}, module, commit string) (string, error) {
m, err := keys.ModuleAt(ctx, module, "")
if err == nil && sameCommitID(m.Commit, commit) {
return m.FP2, nil
}
if err != nil && !errors.Is(err, builderkeys.ErrNoValue) {
return "", err
}
if errors.Is(err, builderkeys.ErrNoValue) {
return "", nil
}
m, err = keys.ModuleAt(ctx, module, commit)
if errors.Is(err, builderkeys.ErrNoValue) {
return "", nil
}
if err != nil {
return "", err
}
return m.FP2, nil
}
// grantsKV is what writing the grants needs of their bucket.
type grantsKV interface {
Get(ctx context.Context, key string) (jetstream.KeyValueEntry, error)
Create(ctx context.Context, key string, value []byte, opts ...jetstream.KVCreateOpt) (uint64, error)
Update(ctx context.Context, key string, value []byte, revision uint64) (uint64, error)
}
// keepGrants writes every grant the list just sent issued that the bucket does not hold as it is: a create for a new one,
// a compare-and-set for one whose accounts changed. It answers how many it wrote.
func keepGrants(ctx context.Context, kv grantsKV, grants map[string]sdkdelivery.ControllerGrant) (int, error) {
keys := make([]string, 0, len(grants))
for k := range grants {
keys = append(keys, k)
}
sort.Strings(keys)
wrote := 0
var errs []error
for _, key := range keys {
value, err := sdkdelivery.EncodeControllerGrant(grants[key])
if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", key, err))
continue
}
e, err := kv.Get(ctx, key)
switch {
case errors.Is(err, jetstream.ErrKeyNotFound):
_, err = kv.Create(ctx, key, value)
case err != nil:
default:
if before, derr := sdkdelivery.DecodeControllerGrant(e.Value()); derr == nil && sdkdelivery.SameControllerGrant(before, withVersion(grants[key])) {
continue
}
_, err = kv.Update(ctx, key, value, e.Revision())
}
if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", key, err))
continue
}
wrote++
}
return wrote, errors.Join(errs...)
}
func withVersion(g sdkdelivery.ControllerGrant) sdkdelivery.ControllerGrant {
g.V = sdkdelivery.Version
return g
}
// grantsIssued writes the grants issued for every module whose credentials all exist at the fingerprint it is
// registered at (ADR 0528 clause 2, ADR 0539 clause 7). Its error is the caller's: with no send left, nothing writes a
// missed grant later but the caller's retry (the done key's take-in, composed_keys.go), and the assigner's wants wait.
func grantsIssued(ctx context.Context, inv *inventory.Inventory, js jetstream.JetStream, indent string) error {
kv, err := js.KeyValue(ctx, broker.GrantsBucket)
if err != nil {
return fmt.Errorf("the grants issued are not written: the bucket %s: %w", broker.GrantsBucket, err)
}
records, err := inv.BusRecords(ctx)
if err != nil {
return fmt.Errorf("the grants issued are not written: %w", err)
}
principals, err := broker.Users(records)
if err != nil {
return fmt.Errorf("the grants issued are not written: %w", err)
}
kept, err := inv.BusUsers(ctx)
if err != nil {
return fmt.Errorf("the grants issued are not written: %w", err)
}
hashes := make(map[string]string, len(kept))
for name, u := range kept {
// A grant names an account whose credential is sealed to its node, never one only minted: the assigner wants a
// node only once the grant names its account (issue 1945, rule 6 per node).
if u.Sealed {
hashes[name] = u.PasswordHash
}
}
current, err := currentFingerprints(ctx, inv)
if err != nil {
return fmt.Errorf("the grants issued are not written: %w", err)
}
grants, drop := grantsFrom(principals, hashes, current)
wrote, err := keepGrants(ctx, kv, grants)
for _, key := range drop {
if _, gerr := kv.Get(ctx, key); gerr == nil {
if derr := kv.Delete(ctx, key); derr != nil {
err = errors.Join(err, fmt.Errorf("%s: %w", key, derr))
} else {
fmt.Printf("%sthe grant %s dropped: none of its accounts is sealed to its node\n", indent, key)
}
}
}
if wrote > 0 {
fmt.Printf("%s%d grant(s) issued written for the assigner\n", indent, wrote)
}
if err != nil {
return fmt.Errorf("some grants issued are not written: %w", err)
}
return nil
}