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:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user