Issue a node's bus accounts and grant when its assignment comes to stand (issue 1945, ADR 0543)

ADR 0539 clause 7 issues a module's credential and grant only when its build is done.
A module built long before and assigned to one more node never has a build done again:
on 2026-10-25 at 17:19 mesh-build-node and mesh-build-go were assigned to ace and composed
for it, the bus's user list never named ace's accounts, and both runtimes restarted every
5 s (one with no credential, one refused by the bus). The assign act composes the assigned
node alone, never the node holding the bus, and writes no grant.

So a write of the assignments bucket is the event: when a key comes to stand (new, or
ended or pending before), the lease holder issues the module's accounts on that node that
are not minted (never re-issued), composes the node holding the bus and writes the grants,
through the composer the done keys' take-ins use. A rewrite of a standing assignment does
nothing; the start's look settles every standing one, one after another. A step of
research 072's grants (memberships and credentials heard from placement).
This commit is contained in:
2026-10-25 17:37:03 +01:00
parent c3db00ceac
commit ca5f74eadb
4 changed files with 563 additions and 0 deletions
@@ -0,0 +1,251 @@
package main
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/chain"
)
// An assignment that comes to stand issues its node's bus accounts and the module's grant (ADR 0543, issue 1945): on
// 2026-10-25 at 17:19 mesh-build-node and mesh-build-go, built long before, were assigned to ace, and nothing issued
// ace's accounts nor put them in the bus's user list.
func anAssignmentValue(t *testing.T, a chain.Assignment) []byte {
t.Helper()
raw, err := json.Marshal(a)
if err != nil {
t.Fatal(err)
}
return raw
}
// Only a write that makes the key stand turns it: a rewrite of a standing assignment (the provisioner's plan kept in its
// value), an ended one, a pending one or a module only pulled in by another does not.
func TestAnAssignmentTurnsWhenItComesToStand(t *testing.T) {
at := time.Date(2026, 10, 25, 16, 19, 42, 0, time.UTC)
stands := chain.Assignment{Node: "ace", Module: "mesh-build-node", Assigned: at}
ended := stands
ended.Ended = at.Add(time.Hour)
pending := stands
pending.State = chain.StatePending
pulled := chain.Assignment{Node: "ace", Module: "mesh-build-node", RequiredBy: "mesh-build-go"}
replanned := stands
replanned.Provisioning = json.RawMessage(`{"plan":1}`)
type write struct {
value *chain.Assignment // nil for a delete
turning bool
}
cases := []struct {
name string
writes []write
}{
{"a new assignment turns", []write{{&stands, true}}},
{"a rewrite of one that stands does not", []write{{&stands, true}, {&replanned, false}, {&stands, false}}},
{"an ended one does not", []write{{&ended, false}}},
{"assigned again after it ended turns again", []write{{&stands, true}, {&ended, false}, {&stands, true}}},
{"a pending one does not, and turns once made", []write{{&pending, false}, {&stands, true}}},
{"a module only pulled in does not", []write{{&pulled, false}}},
{"one deleted and written again turns again", []write{{&stands, true}, {nil, false}, {&stands, true}}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
turns := &assignmentTurns{}
for i, w := range c.writes {
var value []byte
if w.value != nil {
value = anAssignmentValue(t, *w.value)
}
a, turned := turns.heard("ace.mesh-build-node", value)
if turned != w.turning {
t.Fatalf("write %d: turned %v, want %v", i, turned, w.turning)
}
if turned && (a.Node != "ace" || a.Module != "mesh-build-node") {
t.Fatalf("write %d: the assignment turned is %s on %s", i, a.Module, a.Node)
}
}
})
}
}
// settle issues what is missing and then puts it on the bus: the node holding the bus composed (its user list) and the
// grants written; for an assignment that just came to stand even when its accounts were minted before (a module
// assigned again keeps its account), and never by a controller that does not hold the lease.
func TestSettlingAnAssignmentIssuesComposesTheBusAndGrants(t *testing.T) {
cases := []struct {
name string
module string
holds, fresh bool
issued bool
issueErr, composeE error
holder string
wantComposed []string
wantGrants bool
wantErr string
}{
{name: "a new assignment whose account is not minted", module: "mesh-build-node", holds: true, fresh: true,
issued: true, holder: "novox", wantComposed: []string{"novox"}, wantGrants: true},
{name: "a new assignment whose account was minted before", module: "mesh-build-go", holds: true, fresh: true,
holder: "novox", wantComposed: []string{"novox"}, wantGrants: true},
{name: "the start's look issuing a missing account", module: "mesh-build-node", holds: true, issued: true,
holder: "novox", wantComposed: []string{"novox"}, wantGrants: true},
{name: "the start's look with every account minted does nothing", module: "mesh-build-go", holds: true,
holder: "novox"},
{name: "no lease, nothing", module: "mesh-build-node", fresh: true, issued: true, holder: "novox"},
{name: "the controller's own module is the terminal's", module: controllerModule, holds: true, fresh: true,
issued: true, holder: "novox"},
{name: "no bus holder: the grants still", module: "mesh-build-node", holds: true, fresh: true, issued: true,
wantGrants: true},
{name: "an issue that fails is the error, nothing composed", module: "mesh-build-node", holds: true, fresh: true,
issueErr: errors.New("the bus cannot be reached"), holder: "novox", wantErr: "the bus cannot be reached"},
{name: "a composition that fails is the error, no grant", module: "mesh-build-node", holds: true, fresh: true,
issued: true, holder: "novox", composeE: errors.New("store refused"), wantComposed: []string{"novox"},
wantErr: "store refused"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var composed []string
issuedFor, grants := "", false
steps := assignmentAccountSteps{
holds: func(context.Context) bool { return c.holds },
issue: func(_ context.Context, node, module string) (bool, string, error) {
issuedFor = module + "@" + node
if c.issued {
return true, "its bus credential issued", c.issueErr
}
return false, "", c.issueErr
},
busHolder: func(context.Context) (string, error) { return c.holder, nil },
compose: func(_ context.Context, nodes []string) error {
composed = append(composed, nodes...)
return c.composeE
},
grants: func(context.Context) error { grants = true; return nil },
}
said, err := steps.settle(t.Context(), "ace", c.module, c.fresh)
if c.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), c.wantErr) {
t.Fatalf("error %v, want one saying %q", err, c.wantErr)
}
} else if err != nil {
t.Fatal(err)
}
if strings.Join(composed, ",") != strings.Join(c.wantComposed, ",") {
t.Fatalf("composed %v, want %v", composed, c.wantComposed)
}
if grants != c.wantGrants {
t.Fatalf("grants written %v, want %v", grants, c.wantGrants)
}
skipped := !c.holds || c.module == controllerModule
if skipped != (issuedFor == "") {
t.Fatalf("issue asked for %q (lease held %v, module %s)", issuedFor, c.holds, c.module)
}
if c.wantGrants && c.wantErr == "" && !strings.Contains(said, "its grant written") {
t.Fatalf("said %q, want it to name the grant", said)
}
})
}
}
// The account a module speaks with on a node: the module's own when it reads `broker`, its provisioner client's when it
// runs one alone, none when its accounts are its processes' or it does not speak on the bus.
func TestAModulesOwnAccountOnANode(t *testing.T) {
client := aProvisioner()
delete(client.OwnSecrets, "broker")
cases := []struct {
name string
m catalogue.Manifest
user string
own bool
}{
{"a module reading broker", catalogue.Manifest{Module: "mesh-build-node",
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh-build-node/broker"}}},
broker.Principal{Kind: broker.KindModule, Node: "ace", Module: "mesh-build-node"}.Username(), true},
{"a module reading broker and running the client", aProvisioner(),
broker.Principal{Kind: broker.KindModule, Node: "ace", Module: "mesh-provisioner"}.Username(), true},
{"the provisioner client alone", client,
broker.Principal{Kind: broker.KindProvisioner, Node: "ace", Module: "mesh-provisioner"}.Username(), true},
{"no broker, no client", catalogue.Manifest{Module: "hello-web"}, "", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
user, own := ownAccountOf(c.m, "ace")
if user != c.user || own != c.own {
t.Fatalf("got %q %v, want %q %v", user, own, c.user, c.own)
}
})
}
}
// The watch hands the assignments standing when it starts to the start's look (not fresh), says once that they are all
// read, and hands each assignment written afterwards as fresh; a rewrite of one that stands is handed nothing.
func TestTheWatchHandsTheStartsLookThenEachNewAssignment(t *testing.T) {
at := time.Date(2026, 10, 25, 16, 19, 42, 0, time.UTC)
bucket := chain.NewMemory()
put := func(a chain.Assignment) {
t.Helper()
value := anAssignmentValue(t, a)
if e, held, _ := bucket.Get(t.Context(), a.Key()); held {
if _, err := bucket.Update(t.Context(), a.Key(), value, e.Revision); err != nil {
t.Fatal(err)
}
return
}
if _, err := bucket.Create(t.Context(), a.Key(), value); err != nil {
t.Fatal(err)
}
}
put(chain.Assignment{Node: "g14", Module: "mesh-build-go", Assigned: at})
type handed struct {
key string
fresh bool
}
got := make(chan handed, 8)
read := make(chan struct{}, 8)
ctx, stop := context.WithCancel(t.Context())
done := make(chan error, 1)
go func() {
done <- watchAssignmentsOnce(ctx, bucket.Watch, &assignmentTurns{},
func(a chain.Assignment, fresh bool) { got <- handed{a.Key(), fresh} },
func() { read <- struct{}{} })
}()
next := func() handed {
t.Helper()
select {
case h := <-got:
return h
case <-time.After(5 * time.Second):
t.Fatal("nothing handed")
return handed{}
}
}
if h := next(); h != (handed{"g14.mesh-build-go", false}) {
t.Fatalf("the start's look handed %+v", h)
}
select {
case <-read:
case <-time.After(5 * time.Second):
t.Fatal("the current values were never said read")
}
replanned := chain.Assignment{Node: "g14", Module: "mesh-build-go", Assigned: at, Provisioning: json.RawMessage(`{"plan":2}`)}
put(replanned)
put(chain.Assignment{Node: "ace", Module: "mesh-build-node", Assigned: at})
if h := next(); h != (handed{"ace.mesh-build-node", true}) {
t.Fatalf("handed %+v, want ace.mesh-build-node as fresh (and nothing for the rewrite before it)", h)
}
stop()
if err := <-done; err != nil {
t.Fatal(err)
}
if len(read) != 0 {
t.Fatal("the current values were said read more than once")
}
}