mesh/delivery merged: source.main moved to novox/mesh-sdk@036266daff85
mesh/delivery-group group feat/1126-removal merged: every member is merged
mesh/check pass abf5eace7791 on 4a4fac090e9e | unit 6915 run, 1941 kept, 59 skipped | integration 6945 run, 2096 kept, 65 skipped | over bound: unit, …
novox_mesh-catalog follows the ended controller's built-before and each <node>_mesh-host restic's backed-up; no part composes either and nothing reads them (novox_mesh-catalog 10036 behind, each mesh-host 331 never delivered), so the removal after the delivery takes them with the controller's (issue 1126). Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
881 lines
51 KiB
Go
881 lines
51 KiB
Go
package buscompose
|
|
|
|
import (
|
|
"encoding/json"
|
|
"maps"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/announce"
|
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
"github.com/nats-io/nats.go/jetstream"
|
|
)
|
|
|
|
// Issue 1126 B3a (novox/hq ADR 0590): the composed user lists and the server configuration are golden files this
|
|
// composer wrote (testdata/user-lists.json, testdata/composed.conf), first from the lists the ended controller composed
|
|
// with its user and the lease's grants taken out. A change to what any user is granted shows here as a diff to read;
|
|
// MESH_SDK_UPDATE_GOLDEN=1 writes them anew.
|
|
var updateGolden = os.Getenv("MESH_SDK_UPDATE_GOLDEN") == "1"
|
|
|
|
// golden compares got with the golden file name, or writes it.
|
|
func golden(t *testing.T, name string, got []byte) {
|
|
t.Helper()
|
|
path := filepath.Join("testdata", name)
|
|
if updateGolden {
|
|
if err := os.WriteFile(path, got, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return
|
|
}
|
|
want, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(got) != string(want) {
|
|
t.Errorf("%s differs from what is composed now; read the diff and, if it is meant, write it with MESH_SDK_UPDATE_GOLDEN=1", path)
|
|
}
|
|
}
|
|
|
|
const SignersBucketName = "mesh-vault_signers"
|
|
|
|
func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
|
// ADR 0567 §3, §9: a node reads every key of the seat's placement and its candidates (it composes with the whole
|
|
// proposal laid over), and none of its open proposals; it writes neither.
|
|
reads := strings.Join(NodeComposeReads("laptop"), " ")
|
|
for bucket, granted := range map[string]bool{delivery.PlacementBucket: true, delivery.PlacementCandidatesBucket: true,
|
|
delivery.PlacementProposalsBucket: false} {
|
|
if strings.Contains(reads, "$JS.API.DIRECT.GET.KV_"+bucket+".>") != granted || strings.Contains(reads, "$KV."+bucket+".laptop") {
|
|
t.Errorf("a node is granted %s: %v, want %v", bucket, !granted, granted)
|
|
}
|
|
}
|
|
// It hears the creation of those two buckets, and of no other.
|
|
if got := NodeComposeAdvisories(); len(got) != 2 || got[0] != "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement" ||
|
|
got[1] != "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates" {
|
|
t.Errorf("a node hears the creation of %v", got)
|
|
}
|
|
type list struct {
|
|
Name, List string
|
|
Missing []string
|
|
Err string
|
|
Memberships json.RawMessage
|
|
}
|
|
var lists []list
|
|
for _, f := range UserListFixtures() {
|
|
l, missing, err := ComposedFor(f)
|
|
row := list{Name: f.Name, List: l, Missing: missing}
|
|
if err != nil {
|
|
row.Err = err.Error()
|
|
}
|
|
if row.Memberships, err = json.Marshal(MembershipsFor(f)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// B3a/AC1: no list holds a user for the ended controller.
|
|
if strings.Contains(l, `user: "controller"`) {
|
|
t.Errorf("%s: the list holds the controller's user", f.Name)
|
|
}
|
|
lists = append(lists, row)
|
|
}
|
|
raw, err := json.MarshalIndent(lists, "", " ")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
golden(t, "user-lists.json", append(raw, '\n'))
|
|
// An asker on a seat pulled by kind hears what that seat's workers gave up on, and no other stream's dead letters
|
|
// (novox/hq issue 734: a check ask given up during a bus reload left its try running for ever).
|
|
t.Run("an asker reads its seat's dead letters", func(t *testing.T) {
|
|
build := Seat{Name: "node-build-go", Scope: "node", Accepts: []string{"delivery", "check", "recheck"}, PulledByKind: true}
|
|
perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "mesh-delivery-checker", Uses: []Seat{build}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for subject, want := range map[string]bool{
|
|
"$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x1.mesh.events.dead.SEAT_NODE_BUILD_GO.>": true,
|
|
"$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x1.mesh.events.dead.SEAT_NODE_BUILD_GO.w.next": true,
|
|
"$JS.API.CONSUMER.DELETE.DEAD_LETTERS.x1": true,
|
|
"$JS.API.CONSUMER.MSG.NEXT.DEAD_LETTERS.x1": true,
|
|
"$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x1.mesh.events.dead.EVENTS.>": false,
|
|
"$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x1": false,
|
|
"$JS.API.STREAM.PURGE.DEAD_LETTERS": false,
|
|
"$JS.FC.DEAD_LETTERS.x1": false,
|
|
} {
|
|
if got := MayPublish(perms, subject); got != want {
|
|
t.Errorf("may publish %s: %v, want %v", subject, got, want)
|
|
}
|
|
}
|
|
plain, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "shop",
|
|
Uses: []Seat{{Name: "telegram-sender", Accepts: []string{"send"}}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if MayPublish(plain, "$JS.API.CONSUMER.DELETE.DEAD_LETTERS.x1") {
|
|
t.Error("a user of a seat not pulled by kind reads DEAD_LETTERS")
|
|
}
|
|
})
|
|
// ADR 0563 §1, §3, §6: each node's engine alone answers its own node's node-engine verbs and says its events, any
|
|
// runtime may call them, and it asks the operator under node-engine-<node> alone.
|
|
t.Run("a node's engine holds its node seat and asks in its own name", func(t *testing.T) {
|
|
router := Seat{Name: AsksSeat, Accepts: []string{"ask", "cancel"}, Emits: []string{"decided"},
|
|
ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"messenger_asks"}}
|
|
users, err := Users(Records{Nodes: []string{"one", "two"}, Assigned: map[string][]Declared{
|
|
"one": {{Module: "messenger", Holds: []Seat{router}, Invokes: []string{"seat:" + EngineSeat + ".root-free"}}},
|
|
"two": {{Module: RuntimeModule}, {Module: "shop", Invokes: []string{"*"}}}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, u := range users {
|
|
perms, err := PermissionsFor(u)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
answers := MaySubscribe(perms, "mesh.seat."+EngineSeat+".tool.root-free.one") ||
|
|
MaySubscribe(perms, "mesh.seat."+EngineSeat+".call.root-free.one.messenger~one")
|
|
if engineOne := u.Kind == KindNode && u.Node == "one"; answers != engineOne {
|
|
t.Errorf("%s answers one's root-free: %v", u.Username(), answers)
|
|
}
|
|
switch {
|
|
case u.Kind == KindNode && u.Node == "one":
|
|
for subject, want := range map[string]bool{
|
|
"mesh.seat.operator-channel.accept.ask.node-engine-one": true,
|
|
"mesh.seat.operator-channel.accept.cancel.node-engine-one": true,
|
|
"mesh.seat.operator-channel.accept.ask.node-engine-two": false,
|
|
"$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.node-engine-one.hand-over-1": true,
|
|
"$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.node-engine-two.hand-over-1": false,
|
|
"mesh.seat." + EngineSeat + ".event.done.one": true,
|
|
"mesh.seat." + EngineSeat + ".event.done.two": false,
|
|
"$SRV.INFO": true,
|
|
} {
|
|
if got := MayPublish(perms, subject); got != want {
|
|
t.Errorf("node one may publish %s: %v, want %v", subject, got, want)
|
|
}
|
|
}
|
|
if !MaySubscribe(perms, "mesh.seat.operator-channel.event.decided.node-engine-one") ||
|
|
MaySubscribe(perms, "mesh.seat.operator-channel.event.decided.node-engine-two") ||
|
|
!MaySubscribe(perms, "$SRV.INFO."+EngineSeat) || MaySubscribe(perms, "mesh.seat."+EngineSeat+".tool.hand-over.two") {
|
|
t.Errorf("node one hears %v", perms.Subscribe)
|
|
}
|
|
// A verb or an event the seat's contract gains later is its engine's on its own node without a release of
|
|
// the grants part (2026-10-31: mesh-host #184's optional verb composition was refused on every node, the
|
|
// grants part's mesh-sdk pin predating it): the seat's subjects on its node, any verb, any event.
|
|
for _, verb := range []string{"composition", "compose", "a-verb-added-later"} {
|
|
if !MaySubscribe(perms, "mesh.seat."+EngineSeat+".tool."+verb+".one") ||
|
|
!MaySubscribe(perms, "mesh.seat."+EngineSeat+".call."+verb+".one.messenger~one") ||
|
|
MaySubscribe(perms, "mesh.seat."+EngineSeat+".tool."+verb+".two") ||
|
|
MaySubscribe(perms, "mesh.seat."+EngineSeat+".call."+verb+".two.messenger~one") {
|
|
t.Errorf("node one does not answer %s on its own node alone: %v", verb, perms.Subscribe)
|
|
}
|
|
}
|
|
if !MayPublish(perms, "mesh.seat."+EngineSeat+".event.an-event-added-later.one") ||
|
|
MayPublish(perms, "mesh.seat."+EngineSeat+".event.an-event-added-later.two") {
|
|
t.Errorf("node one does not say a later event on its own node alone: %v", perms.Publish)
|
|
}
|
|
case u.Kind == KindNodeTools || u.Module == "messenger":
|
|
if !MayPublish(perms, "mesh.seat."+EngineSeat+".tool.root-free.one") {
|
|
t.Errorf("%s cannot call one's root-free", u.Username())
|
|
}
|
|
}
|
|
}
|
|
})
|
|
// Issue 1126 (ADR 0550 §5): a node's engine asks the vault's `own` in its own name while the vault serves it, and no
|
|
// other node's.
|
|
t.Run("a node asks the vault its own secrets", func(t *testing.T) {
|
|
one := Principal{Kind: KindNode, Node: "one", AsksItsOwnSecrets: true}
|
|
perms, err := PermissionsFor(one)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !MayPublish(perms, VaultOwnCall(one)) || MayPublish(perms, VaultOwnCall(Principal{Kind: KindNode, Node: "two"})) {
|
|
t.Errorf("node one asks own as %v, and as two %v", MayPublish(perms, VaultOwnCall(one)),
|
|
MayPublish(perms, VaultOwnCall(Principal{Kind: KindNode, Node: "two"})))
|
|
}
|
|
if plain, _ := PermissionsFor(Principal{Kind: KindNode, Node: "one"}); MayPublish(plain, VaultOwnCall(one)) {
|
|
t.Error("a node asks own while the vault does not serve it")
|
|
}
|
|
if !slices.Contains(perms.Subscribe, VaultSecretChanged) {
|
|
t.Errorf("node one does not hear the vault's secret-changed: %v", perms.Subscribe)
|
|
}
|
|
// L3 s6 (ADR 0550 §2): while the vault serves `sign`, a node asks it in its own name alone, and reads the vault's
|
|
// `signers`, never writing it; the ended controller's lease it reads no more (ADR 0590).
|
|
signs, _ := PermissionsFor(Principal{Kind: KindNode, Node: "one", AsksTheVaultToSign: true})
|
|
signersGet := "$JS.API.DIRECT.GET.KV_" + SignersBucketName + ".$KV." + SignersBucketName + ".key"
|
|
for subject, want := range map[string]bool{VaultSignCall(one): true, signersGet: true,
|
|
"$JS.API.DIRECT.GET.KV_mesh-controller_lease.$KV.mesh-controller_lease.holder": false,
|
|
VaultSignCall(Principal{Kind: KindNode, Node: "two"}): false, "$KV." + SignersBucketName + ".key": false} {
|
|
if MayPublish(signs, subject) != want {
|
|
t.Errorf("a node asking sign may publish %s: %v, want %v", subject, !want, want)
|
|
}
|
|
}
|
|
if MayPublish(perms, VaultSignCall(one)) || MayPublish(perms, signersGet) {
|
|
t.Error("a node asks sign or reads signers while the vault does not serve sign")
|
|
}
|
|
// And what a module on another node serves from its settings, that key alone (mailu's smtp domain, issue 1126).
|
|
users, err := Users(Records{Nodes: []string{"one", "two"}, Assigned: map[string][]Declared{
|
|
"two": {{Module: "mailer", Served: []string{"domain", "not.a.token"}}}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
get := func(key string) string {
|
|
return "$JS.API.DIRECT.GET.KV_" + ResolvedBucket + ".$KV." + ResolvedBucket + "." + key
|
|
}
|
|
for _, u := range users {
|
|
if u.Kind != KindNode {
|
|
continue
|
|
}
|
|
perms, err := PermissionsFor(u)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if u.Node == "one" && (!reflect.DeepEqual(u.ServedReads, []string{"two.mailer.domain"}) || !MayPublish(perms, get("two.mailer.domain")) ||
|
|
MayPublish(perms, get("two.mailer.sitename"))) {
|
|
t.Errorf("node one reads %v of two's resolved values", u.ServedReads)
|
|
}
|
|
if u.Node == "two" && len(u.ServedReads) != 0 {
|
|
t.Errorf("node two is granted its own served keys apart: %v", u.ServedReads)
|
|
}
|
|
}
|
|
})
|
|
// 774/S39 (ADR 0583 §1): while a node rotates, both its logins are users with the node's permissions and its one
|
|
// inbox, each calling in its own name; every node reads its own node-logins key alone; with no rotation the list
|
|
// is the one above (AC2: the controller's golden lists, unchanged).
|
|
t.Run("a node rotating has two logins of one node", func(t *testing.T) {
|
|
vault := Seat{Name: VaultSeat, Serves: []string{"rotate-node", "node-account-confirm", "sign", "own"}}
|
|
since := time.Date(2026, 11, 2, 1, 0, 0, 0, time.UTC)
|
|
users, err := Users(Records{Nodes: []string{"ace", "g14"},
|
|
Assigned: map[string][]Declared{"ace": {{Module: "mesh-vault", Holds: []Seat{vault}}}},
|
|
NodeLogins: map[string]delivery.NodeLogin{
|
|
"g14": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: delivery.RotationBegun, Since: since},
|
|
// A node that rotated before and rotates no more logs in as its second login alone.
|
|
"ace": {Node: "ace", Login: "node.ace--b", Since: since}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
perms := map[string]Permissions{}
|
|
for _, u := range users {
|
|
if u.Kind == KindNode {
|
|
p, err := PermissionsFor(u)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
perms[u.Username()] = p
|
|
}
|
|
}
|
|
if len(perms) != 3 || perms["node.g14"].Publish == nil || perms["node.g14--b"].Publish == nil || perms["node.ace--b"].Publish == nil {
|
|
t.Fatalf("the node users are %v", slices.Sorted(maps.Keys(perms)))
|
|
}
|
|
// Equal, but for the caller each names in its calls (the bus's proof of which login called, ADR 0583 §3).
|
|
own := func(p Permissions, login string) Permissions {
|
|
swap := func(in []string) []string {
|
|
out := make([]string, len(in))
|
|
for i, s := range in {
|
|
out[i] = strings.ReplaceAll(s, CallerToken(login), "<caller>")
|
|
}
|
|
return out
|
|
}
|
|
return Permissions{Publish: swap(p.Publish), PublishDeny: swap(p.PublishDeny), Subscribe: swap(p.Subscribe),
|
|
AllowResponses: p.AllowResponses}
|
|
}
|
|
if a, b := own(perms["node.g14"], "node.g14"), own(perms["node.g14--b"], "node.g14--b"); !reflect.DeepEqual(a, b) {
|
|
t.Errorf("the two logins' permissions differ:\n%v\n%v", a, b)
|
|
}
|
|
for login, p := range perms {
|
|
node, _ := delivery.LoginNode(login)
|
|
if !slices.Contains(p.Subscribe, "_INBOX.node."+node+".>") || slices.Contains(p.Subscribe, "_INBOX."+login+".>") && login != "node."+node {
|
|
t.Errorf("%s's inbox: %v", login, p.Subscribe)
|
|
}
|
|
}
|
|
// AC3: g14 reads node-logins key g14 and not ace, and writes neither; it confirms in its own login's name.
|
|
get := func(key string) string {
|
|
return "$JS.API.DIRECT.GET.KV_" + delivery.NodeLoginsBucket + ".$KV." + delivery.NodeLoginsBucket + "." + key
|
|
}
|
|
confirm := func(login string) string {
|
|
return "mesh.seat.mesh-vault.call.node-account-confirm." + CallerToken(login)
|
|
}
|
|
for subject, want := range map[string]bool{get("g14"): true, get("ace"): false, "$KV." + delivery.NodeLoginsBucket + ".g14": false,
|
|
confirm("node.g14--b"): true, confirm("node.g14"): false} {
|
|
if MayPublish(perms["node.g14--b"], subject) != want {
|
|
t.Errorf("node.g14--b may publish %s: %v, want %v", subject, !want, want)
|
|
}
|
|
}
|
|
if plain, _ := PermissionsFor(Principal{Kind: KindNode, Node: "g14"}); MayPublish(plain, get("g14")) {
|
|
t.Error("a node reads node-logins while the vault does not rotate")
|
|
}
|
|
// And nothing but the grants part names node-logins among its reads.
|
|
if !ReaderMay(GrantsModule, delivery.NodeLoginsRead) || ReaderMay("shop", delivery.NodeLoginsRead) {
|
|
t.Error("node-logins' named readers")
|
|
}
|
|
})
|
|
// 774/S40/AC1 (ADR 0587 §4, §6, ADR 0590): an enrolling node's user may publish the redeem call in its own name and
|
|
// nothing else, and subscribe its own inbox alone.
|
|
t.Run("an enrolling node redeems at the seat alone", func(t *testing.T) {
|
|
holder := Seat{Name: catalogue.EnrolmentSeat, Serves: []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"}}
|
|
{
|
|
users, err := Users(Records{Nodes: []string{"ace"}, Enrolling: []string{"lab"},
|
|
Assigned: map[string][]Declared{"ace": {{Module: "mesh-enrolment", Holds: []Seat{holder}}}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var lab *Principal
|
|
for i := range users {
|
|
if users[i].Username() == "enrol.lab" {
|
|
lab = &users[i]
|
|
}
|
|
}
|
|
if lab == nil {
|
|
t.Fatal("no user enrol.lab")
|
|
}
|
|
perms, err := PermissionsFor(*lab)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := []string{"mesh.seat.mesh-enrolment.call.redeem.enrol~lab"}
|
|
if !reflect.DeepEqual(perms.Publish, want) || !reflect.DeepEqual(perms.Subscribe, []string{"_INBOX.enrol.lab.>"}) ||
|
|
perms.AllowResponses {
|
|
t.Errorf("enrol.lab publishes %v, subscribes %v, answers %v", perms.Publish, perms.Subscribe, perms.AllowResponses)
|
|
}
|
|
}
|
|
// No holder serving redeem: the user may enrol nowhere, refused by name.
|
|
if _, err := PermissionsFor(Principal{Kind: KindEnrolment, Node: "lab"}); err == nil {
|
|
t.Error("an enrolment user that may enrol nowhere was composed")
|
|
}
|
|
})
|
|
// 774/S41/AC1 (ADR 0587 §1-§3, ADR 0561 §5): mesh-enrolment writes its bucket (with per-key expiry) and calls the
|
|
// vault's enrolment-token, node-account and sign in its own name; no other module or runtime may call those two,
|
|
// however wide its grant; and its bucket is read by the grants part alone besides it.
|
|
t.Run("mesh-enrolment alone asks the vault to enrol", func(t *testing.T) {
|
|
vault := Seat{Name: VaultSeat, Serves: []string{"sign", "enrolment-token", "node-account"}}
|
|
holder := Seat{Name: catalogue.EnrolmentSeat, Serves: []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"}}
|
|
calls := []string{"seat:mesh-vault.enrolment-token", "seat:mesh-vault.node-account", "seat:mesh-vault.sign"}
|
|
users, err := Users(Records{Nodes: []string{"ace"}, Assigned: map[string][]Declared{"ace": {
|
|
{Module: "mesh-vault", Holds: []Seat{vault}},
|
|
// One process with a bus account of its own (ADR 0587 §1): never carried by the tool runner.
|
|
{Module: "mesh-enrolment", RunsAs: "mesh-enrolment", Holds: []Seat{holder}, Invokes: calls,
|
|
State: []Bucket{{Module: "mesh-enrolment", Name: "enrolments", KeyTTL: true}}},
|
|
{Module: "shop", Invokes: calls[:2]},
|
|
{Module: RuntimeModule}, {Module: "agent-tools", Invokes: []string{"*"}}}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
call := func(verb, user string) string { return "mesh.seat.mesh-vault.call." + verb + "." + CallerToken(user) }
|
|
for _, u := range users {
|
|
perms, err := PermissionsFor(u)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
name := u.Username()
|
|
for _, verb := range []string{"enrolment-token", "node-account"} {
|
|
enrols := name == "ace.mesh-enrolment"
|
|
if MayPublish(perms, call(verb, name)) != enrols || (!enrols && MayPublish(perms, "mesh.seat.mesh-vault.tool."+verb)) {
|
|
t.Errorf("%s may call %s: %v", name, verb, !enrols)
|
|
}
|
|
}
|
|
if name == "ace.mesh-enrolment" && (!MayPublish(perms, call("sign", name)) ||
|
|
!MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".node.lab") || !MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".address.10.0.0.7")) {
|
|
t.Errorf("mesh-enrolment does not sign or write its bucket: %v", perms.Publish)
|
|
}
|
|
if name != "ace.mesh-enrolment" && MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".node.lab") {
|
|
t.Errorf("%s writes the enrolments", name)
|
|
}
|
|
}
|
|
if !ReaderMay(GrantsModule, delivery.EnrolmentsRead) || ReaderMay("shop", delivery.EnrolmentsRead) {
|
|
t.Error("the enrolments' named readers")
|
|
}
|
|
})
|
|
t.Run("the golden server configuration", func(t *testing.T) {
|
|
seat := Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}}
|
|
got, err := Compose(
|
|
Server{ClientPort: 4222, MonitoringPort: 8222, StoreDir: "/data",
|
|
TLSCert: "/tls/tls.crt", TLSKey: "/tls/tls.key", TLSCA: "/tls/ca.crt"},
|
|
[]Principal{
|
|
{Kind: KindEnrolment, Node: "one", Redeems: true, PasswordHash: "$2a$11$eeeeeeeeeeeeeeeeeeeeee"},
|
|
{Kind: KindNode, Node: "one", PasswordHash: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn"},
|
|
{Kind: KindModule, Node: "one", Module: "telegram", Holds: []Seat{seat},
|
|
Serves: []string{"status"}, PasswordHash: "$2a$11$tttttttttttttttttttttt"},
|
|
{Kind: KindModule, Node: "two", Module: "shop", Uses: []Seat{seat},
|
|
Emits: []string{"order.placed"}, PasswordHash: "$2a$11$ssssssssssssssssssssss"},
|
|
{Kind: KindModule, Node: "two", Module: "audit",
|
|
Consumes: []string{"shop.order.placed"}, PasswordHash: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa"},
|
|
{Kind: KindModule, Node: "one", Module: WatchmanModule, PasswordHash: "$2a$11$wwwwwwwwwwwwwwwwwwwwww"},
|
|
{Kind: KindModule, Node: "one", Module: "nats", SnapshotsTheBus: true,
|
|
Serves: []string{"nats_streams"}, PasswordHash: "$2a$11$bbbbbbbbbbbbbbbbbbbbbb"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
golden(t, "composed.conf", []byte(got))
|
|
})
|
|
// 774/AC2 (novox/hq ADR 0561 §2): a wanted account round-trips through the grants part's state `accounts`, keyed by
|
|
// its user, at the subject `$KV.mesh-grants_accounts.<user>`, read by the vault alone; and the vault's account-issued
|
|
// names the account, never a value.
|
|
t.Run("a wanted account is kept by its user and read by the vault alone", func(t *testing.T) {
|
|
for _, w := range []WantedAccount{
|
|
{User: "ace.mesh-build-go", Kind: KindModule, Node: "ace", Module: "mesh-build-go", URL: "nats://novox.mesh:4222",
|
|
Fingerprint: "ab", Claims: []ClaimedSeat{{Seat: "node-build-go", Scope: "node", Serves: []string{"build"}}}},
|
|
{User: "novox.mesh-mail.send", Kind: KindProcess, Node: "novox", Module: "mesh-mail", Process: "send",
|
|
URL: "nats://novox.mesh:4222"},
|
|
{User: "provisioner.g14", Kind: KindProvisioner, Node: "g14", Module: "mesh-provisioner-client",
|
|
URL: "nats://novox.mesh:4222", Fingerprint: "ab"},
|
|
} {
|
|
if err := w.Valid(); err != nil {
|
|
t.Fatalf("%s: %v", w.User, err)
|
|
}
|
|
raw, err := json.Marshal(w)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
back, err := ReadWantedAccount(w.User, raw)
|
|
if err != nil || !back.Same(w) {
|
|
t.Errorf("%s does not round-trip: %+v, %v", w.User, back, err)
|
|
}
|
|
if got := AccountsSubject(w.User); got != "$KV.mesh-grants_accounts."+w.User {
|
|
t.Errorf("%s is kept at %s", w.User, got)
|
|
}
|
|
if _, err := ReadWantedAccount("someone.else", raw); err == nil {
|
|
t.Errorf("%s was read under another user's key", w.User)
|
|
}
|
|
}
|
|
for _, bad := range []WantedAccount{
|
|
{User: "ace.x", Kind: KindModule, Node: "ace", Module: "y", URL: "nats://a:1"},
|
|
{User: "ace.x.p", Kind: KindProcess, Node: "ace", Module: "x", URL: "nats://a:1"},
|
|
{User: "node.ace", Kind: KindNode, Node: "ace", URL: "nats://a:1"},
|
|
{User: "controller", Kind: "controller", URL: "nats://a:1"},
|
|
{User: "ace.x", Kind: KindModule, Node: "ace", Module: "x"},
|
|
} {
|
|
if bad.Valid() == nil {
|
|
t.Errorf("%+v is taken as a wanted account", bad)
|
|
}
|
|
}
|
|
if ReaderMay("mesh-delivery-assigner", GrantsAccounts) || !ReaderMay(VaultSeat, GrantsAccounts) {
|
|
t.Errorf("%s is read by %v alone, want the vault's", GrantsAccounts, NamedReaders[GrantsAccounts])
|
|
}
|
|
e := AccountIssued{User: "ace.x", Kind: KindModule, Node: "ace", Module: "x", Generation: 1}
|
|
raw, _ := json.Marshal(e)
|
|
if strings.Contains(string(raw), "password") || strings.Contains(string(raw), "hash") ||
|
|
AccountIssuedSubject != "mesh.mod.mesh-vault.event.account-issued" {
|
|
t.Errorf("account-issued says %s at %s", raw, AccountIssuedSubject)
|
|
}
|
|
})
|
|
// 774/AC3 (novox/hq ADR 0561 §2): the account the vault seals is, byte for byte, what the controller hands for each of
|
|
// its three kinds: testdata/controller-credentials.json was written by the controller's own code (its "how").
|
|
t.Run("a held account is the controller's shape byte for byte", func(t *testing.T) {
|
|
raw, err := os.ReadFile(filepath.Join("testdata", "controller-credentials.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var captured struct {
|
|
Rows []struct{ Name, Held string }
|
|
}
|
|
if err := json.Unmarshal(raw, &captured); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
vault := ClaimedSeat{Seat: "mesh-vault", Scope: "mesh", Serves: []string{"get", "accept", "secret-ask", "issued",
|
|
"rotate", "move-in", "node-key", "own", "move-asks"}}
|
|
controller := ClaimedSeat{Seat: "mesh-controller", Scope: "mesh", Serves: strings.Split("tools,calls,status,nodes,"+
|
|
"node,placement-export,modules,seats,builds,plans,delivery-plan,delivery-order,delivery-check,deliver,"+
|
|
"delivery-stop,delivery-backups-again,delivery-walks,plan,assign,unassign,pin,unpin,push,rotate,give,issued,"+
|
|
"secret-move,secret-ask,issue,token,settings,command,queue,cancel,clear,replay,try,kill,pause,resume,hand-act,"+
|
|
"drill,warranted,hand-acts,durations,conditions,healers,doctor,upgrade,bus,retire,cleanup,judge-directories,"+
|
|
"root-free,dead-letters,data,build,artifacts,held,collect,move-placement,move-config,mirrors,images", ",")}
|
|
at := "nats://10.0.0.1:4222"
|
|
wanted := map[string]struct {
|
|
w WantedAccount
|
|
password string
|
|
}{
|
|
"module": {WantedAccount{User: "laptop.cap-bot", Kind: KindModule, Node: "laptop", Module: "cap-bot", URL: at,
|
|
Fingerprint: "ab:cd", Claims: []ClaimedSeat{vault, {Seat: "node-service-manager", Scope: "node",
|
|
Serves: []string{"status"}}, controller, {Seat: "no-such-seat", Scope: "node"}}}, "pw-module"},
|
|
"module-no-claims-no-fingerprint": {WantedAccount{User: "laptop.cap-plain", Kind: KindModule, Node: "laptop",
|
|
Module: "cap-plain", URL: at}, "pw-plain"},
|
|
"provisioner": {WantedAccount{User: "provisioner.laptop", Kind: KindProvisioner, Node: "laptop", Module: "nats",
|
|
URL: at, Fingerprint: "ab:cd"}, "pw-prov"},
|
|
"process": {WantedAccount{User: "laptop.mesh-mail.send", Kind: KindProcess, Node: "laptop", Module: "mesh-mail",
|
|
Process: "send", URL: at, Fingerprint: "ab:cd"}, "pw-proc"},
|
|
}
|
|
if len(captured.Rows) != len(wanted) {
|
|
t.Fatalf("%d rows captured, %d wanted", len(captured.Rows), len(wanted))
|
|
}
|
|
for _, row := range captured.Rows {
|
|
c, ok := wanted[row.Name]
|
|
if !ok {
|
|
t.Fatalf("no wanted account for the captured %s", row.Name)
|
|
}
|
|
got, err := HeldCredential(c.w, c.password)
|
|
if err != nil || string(got) != row.Held {
|
|
t.Errorf("%s:\n got %s (%v)\nwant %s", row.Name, got, err, row.Held)
|
|
}
|
|
}
|
|
if _, err := HeldCredential(wanted["module"].w, ""); err == nil {
|
|
t.Error("an account was held with no password")
|
|
}
|
|
})
|
|
// 774 subtask 8 (novox/hq ADR 0561 §6): a module's consumer is created as the grants part creates it, and every read is
|
|
// judged for its reader.
|
|
t.Run("every read is judged for its reader; a consumer is created as the grants part creates it", func(t *testing.T) {
|
|
announced := map[string]announce.States{}
|
|
// Issue 774 (sweep item 2): Users judges every read for its reader again, whatever split the caller made: a
|
|
// reader-less split refused the vault the grants part's accounts it is named for.
|
|
announced["mesh-grants"] = announce.States{Module: "mesh-grants", States: []announce.State{{Name: "accounts", Readers: []string{VaultSeat}}}}
|
|
announced["disk-load"] = announce.States{Module: "disk-load", States: []announce.State{{Name: "readings"}}}
|
|
judged, err := Users(Records{Nodes: []string{"a"}, Announced: announced, Assigned: map[string][]Declared{"a": {
|
|
{Module: VaultSeat, ReadsRefused: []string{GrantsAccounts}},
|
|
{Module: "restic", ReadsRefused: []string{"disk-load.readings"}},
|
|
{Module: "messenger", Reads: []string{GrantsAccounts}}}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reads := map[string]string{}
|
|
for _, u := range judged {
|
|
if u.Kind == KindModule {
|
|
reads[u.Module] = strings.Join(u.Reads, ",")
|
|
}
|
|
}
|
|
if reads[VaultSeat] != GrantsAccounts || reads["restic"] != "" || reads["messenger"] != "" {
|
|
t.Errorf("the reads granted for their readers: %v", reads)
|
|
}
|
|
// AccountsEntry is the very lines ComposeAccounts writes for a user, so a writer of kept users repeats none.
|
|
filled, _ := WithPasswords(judged, map[string]string{judged[0].Username(): "$2a$11$h"})
|
|
list, err := ComposeAccounts(filled[:1])
|
|
perms, perr := PermissionsFor(filled[0])
|
|
if err != nil || perr != nil || !strings.Contains(list, " users = [\n"+AccountsEntry(filled[0].Username(), "$2a$11$h", perms)+" ]\n") {
|
|
t.Errorf("the accounts entry is not ComposeAccounts' own: %v %v\n%s", err, perr, list)
|
|
}
|
|
c, _ := ConsumerFor(Principal{Kind: KindModule, Node: "a", Module: "m", Consumes: []string{"x.happened"}})
|
|
cfg := ConsumerConfig(c)
|
|
if cfg.Durable != "a_m" || cfg.AckPolicy != jetstream.AckExplicitPolicy || cfg.AckWait != 30*time.Second ||
|
|
cfg.MaxDeliver != 5 || cfg.DeliverPolicy != jetstream.DeliverNewPolicy || len(cfg.FilterSubjects) != 2 ||
|
|
cfg.Description == "" {
|
|
t.Errorf("a module's consumer is created as %+v", cfg)
|
|
}
|
|
// The grants part is granted the create of each account's consumer by its stream and name, and no other.
|
|
users, err := Users(Records{Nodes: []string{"a"}, Assigned: map[string][]Declared{"a": {
|
|
{Module: GrantsModule}, {Module: "m", Consumes: []string{"x.happened"}}, {Module: "quiet"}}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, u := range users {
|
|
if u.Module != GrantsModule || u.Kind != KindModule {
|
|
continue
|
|
}
|
|
perms, err := PermissionsFor(u)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !MayPublish(perms, "$JS.API.CONSUMER.CREATE.EVENTS.a_m") || MayPublish(perms, "$JS.API.CONSUMER.CREATE.EVENTS.a_quiet") ||
|
|
MayPublish(perms, "$JS.API.CONSUMER.DELETE.EVENTS.a_m") || MayPublish(perms, "$JS.API.CONSUMER.CREATE.EVENTS.a_m.mesh.mod.x.event.happened") {
|
|
t.Errorf("the grants part creates consumers as %v", u.CreatesConsumers)
|
|
}
|
|
}
|
|
// 1952 S5 (novox/hq ADR 0564 clauses 2, 5, 8; Limits 2, 4, 8): the health holders' credentials.
|
|
healthChecks(t)
|
|
q := ConsumerConfig(QueueConsumer(Principal{Kind: KindProcess, Node: "a", Module: "m", Process: "p"},
|
|
QueueOwner{Process: "p", Stream: "M_WORK", MaxDeliver: 3}))
|
|
if q.Durable != "a_m_p" || q.FilterSubject != "" || q.MaxDeliver != 3 || q.DeliverPolicy != jetstream.DeliverAllPolicy {
|
|
t.Errorf("a process's queue consumer is created as %+v", q)
|
|
}
|
|
})
|
|
}
|
|
|
|
// healthChecks are the health holders' credential cases (ADR 0564 clauses 2, 5, 8; Limits 2, 4, 8; issue 1952 S5).
|
|
func healthChecks(t *testing.T) {
|
|
t.Helper()
|
|
announced := map[string]announce.States{
|
|
"mesh-healers": {Module: "mesh-healers", States: []announce.State{{Name: "attempts", Readable: true}, {Name: "would", Readable: true}}}}
|
|
for _, c := range []struct {
|
|
reader, read string
|
|
may bool
|
|
}{
|
|
{"mesh-conditions", "mesh-healers.attempts", true}, {"mesh-conditions", HealersWould, false}, {"mesh-delivery-view", HealersWould, false},
|
|
// ADR 0577 clause 1: the node-engine's data items are disk-load's alone to read.
|
|
{"disk-load", delivery.EngineDataRead, true}, {"restic", delivery.EngineDataRead, false},
|
|
} {
|
|
granted, _ := ReadsAnnouncedBy(c.reader, []string{c.read}, announced)
|
|
if (len(granted) == 1) != c.may {
|
|
t.Errorf("%s reads %s: %v, want %v", c.reader, c.read, granted, c.may)
|
|
}
|
|
}
|
|
bucket := func(m, n string) Bucket { return Bucket{Module: m, Name: n} }
|
|
users, err := Users(Records{Nodes: []string{"a"}, Assigned: map[string][]Declared{"a": {
|
|
{Module: "mesh-conditions", State: []Bucket{bucket("mesh-conditions", "conditions"), bucket("mesh-conditions", "takeover")},
|
|
Reads: []string{"mesh-self-check.findings", "mesh-healers.attempts"}},
|
|
{Module: "mesh-self-check", State: []Bucket{bucket("mesh-self-check", "findings")}, Reads: []string{"restic.good"}},
|
|
// Issue 1955 L7: the grants part keeps the hearings it withholds; the self-check reads them.
|
|
{Module: GrantsModule, State: []Bucket{bucket(GrantsModule, GrantsWithheldState)}},
|
|
{Module: "mesh-healers", State: []Bucket{bucket("mesh-healers", "findings"), bucket("mesh-healers", "attempts"), bucket("mesh-healers", "would")}},
|
|
{Module: WatchmanModule, State: []Bucket{bucket(WatchmanModule, "findings")}, Reads: []string{delivery.NodeHealthRead}},
|
|
{Module: "mesh-hand-acts", State: []Bucket{bucket("mesh-hand-acts", "warrants")}, Logs: []Log{{Module: "mesh-hand-acts", Name: "acts"}}},
|
|
// ADR 0570 clauses 1, 8 (issue 1954 L6-P5): mesh-provisioner keeps provisions and plans; the vault and a provider
|
|
// read them.
|
|
{Module: "mesh-provisioner", State: []Bucket{bucket("mesh-provisioner", "provisions"), bucket("mesh-provisioner", "plans"),
|
|
bucket("mesh-provisioner", "registry")}},
|
|
{Module: "mesh-vault", Reads: []string{"mesh-provisioner.provisions", "mesh-provisioner.plans"}},
|
|
{Module: "postgres", Reads: []string{"mesh-provisioner.provisions", "mesh-provisioner.plans"}},
|
|
// The node's provisioner client reads its machine's plans and records no needs (ADR 0570 clause 2; its V3).
|
|
{Module: "mesh-provisioner-client", ProvisionsNode: true},
|
|
// ADR 0571 clauses 2, 4 (issue 1955): the seat mesh-artifact-sweep says artifacts-gone; the assigner keeps seat-holders.
|
|
{Module: "artifact-store-tools", Holds: []Seat{{Name: "mesh-artifact-sweep", Emits: []string{"artifacts-gone"}}},
|
|
Reads: []string{delivery.SeatHoldersRead}},
|
|
{Module: delivery.AssignerModule, State: []Bucket{bucket(delivery.AssignerModule, delivery.SeatHoldersState)}},
|
|
// ADR 0571 clause 3: disk-load keeps the readings; the backup holder reads them.
|
|
// ADR 0577 clause 1: and it reads its own node's keys of the node-engine's data items, which restic may not.
|
|
{Module: "disk-load", State: []Bucket{bucket("disk-load", "readings")}, Reads: []string{delivery.EngineDataRead}},
|
|
{Module: "restic", Reads: []string{"disk-load.readings", delivery.EngineDataRead}, State: []Bucket{bucket("restic", "good")}},
|
|
// ADR 0571 clauses 1, 9: the indexer reads the builder's log through its own consumer; the catalogue module asks
|
|
// the builder's tool.
|
|
{Module: delivery.BuilderModule, Logs: []Log{{Module: delivery.BuilderModule, Name: delivery.BuilderLog, MaxAgeDays: 30}}},
|
|
{Module: delivery.IndexerModule, Reads: []string{delivery.BuildLogRead}},
|
|
{Module: "mesh-catalog", Invokes: []string{delivery.BuilderModule + "." + delivery.BuildsSinceTool}},
|
|
{Module: "telegram", Holds: []Seat{{Name: "telegram-sender", Accepts: []string{"send"}}},
|
|
Processes: []ProcessPrincipal{{Process: "w", Queues: []QueueOwner{{Process: "w", Stream: "M_WORK"}, {Process: "w", Stream: "KV_x"}}}}},
|
|
{Module: "nats", SnapshotsTheBus: true, Processes: []ProcessPrincipal{{Process: "nats-tools",
|
|
Removes: []string{catalogue.RemovesEngineActs, catalogue.RemovesControllerLeftovers}}, {Process: "other"}}},
|
|
{Module: "shop", Processes: []ProcessPrincipal{{Process: "nats-tools", Removes: []string{catalogue.RemovesEngineActs}}}},
|
|
}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
may := map[string][]string{
|
|
"a.mesh-conditions": {"$KV.mesh-conditions_conditions.machine.g14.silent"},
|
|
"a.mesh-provisioner": {"$KV.mesh-provisioner_provisions.a.keycloak.postgres-database", "$KV.mesh-provisioner_plans.a.keycloak"},
|
|
"a.mesh-vault": {"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.a.keycloak.postgres-database",
|
|
"$JS.API.DIRECT.GET.KV_mesh-provisioner_plans.$KV.mesh-provisioner_plans.a.keycloak"},
|
|
"a.mesh-hand-acts": {"$KV.mesh-hand-acts_warrants.mesh-config.ask-1", "mesh.log.mesh-hand-acts.acts.act-1"},
|
|
"a.disk-load": {"$KV.disk-load_readings.a.pg.data", "$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.a.pg",
|
|
"$JS.API.CONSUMER.CREATE.KV_mesh-host_data.c1.$KV.mesh-host_data.a.*"},
|
|
"provisioner.a": {"$JS.API.DIRECT.GET.KV_mesh-provisioner_plans.$KV.mesh-provisioner_plans.a.keycloak",
|
|
"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_plans.c1.$KV.mesh-provisioner_plans.a.keycloak",
|
|
// Every machine's plans, as the client V3 watches them (issue 774, 2026-10-30).
|
|
"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_plans.c1.$KV.mesh-provisioner_plans.>",
|
|
"$JS.API.DIRECT.GET.KV_mesh-provisioner_plans.$KV.mesh-provisioner_plans.b.keycloak",
|
|
// The needs, for the client before V3, until the lease write (review round 3).
|
|
"$KV." + ProvisionerNeedsBucket + ".a.keycloak.db.x"},
|
|
// ADR 0571 clause 3: restic's bundle writes each item's last good night as its own state; the self-check reads it.
|
|
"a.mesh-self-check": {"$JS.API.DIRECT.GET.KV_restic_good.$KV.restic_good.a.pg.data"},
|
|
"a.mesh-grants": {"$KV.mesh-grants_withheld.reader.mail.a"},
|
|
"a.restic": {"$JS.API.DIRECT.GET.KV_disk-load_readings.$KV.disk-load_readings.a.pg.data", "$KV.restic_good.a.pg.data",
|
|
// ADR 0571 clause 7: its own membership from the grants part's state.
|
|
"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.a.restic"},
|
|
"a.mesh-delivery-indexer": {"$JS.API.CONSUMER.CREATE.LOG_mesh-delivery-builder_build-log.r1.mesh.log.mesh-delivery-builder.build-log.letta",
|
|
"$JS.API.CONSUMER.MSG.NEXT.LOG_mesh-delivery-builder_build-log.r1", "$JS.API.CONSUMER.DELETE.LOG_mesh-delivery-builder_build-log.r1",
|
|
"$JS.API.DIRECT.GET.LOG_mesh-delivery-builder_build-log.mesh.log.mesh-delivery-builder.build-log.letta"},
|
|
"a.mesh-delivery-builder": {"mesh.log.mesh-delivery-builder.build-log.letta"},
|
|
"a.mesh-catalog": {"mesh.mod.mesh-delivery-builder.tool.builder_builds_since"},
|
|
WatchmanModule: {"$JS.API.CONSUMER.CREATE.DEAD_LETTER_NOTICES." + WatchmanModule, "$JS.API.CONSUMER.MSG.NEXT.DEAD_LETTER_NOTICES." + WatchmanModule,
|
|
"$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.controller", "$JS.API.STREAM.MSG.GET.DEAD_LETTERS"},
|
|
WatchmanModule + " sources": {"$JS.API.STREAM.MSG.GET.EVENTS", "$JS.API.STREAM.INFO.SEAT_TELEGRAM_SENDER",
|
|
"$JS.API.STREAM.MSG.GET.M_WORK", "$JS.API.STREAM.INFO.EVENTS", "mesh.events.dead.EVENTS.a_m"},
|
|
"a.nats.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.controller",
|
|
"$KV.mesh-host_acts.g14.a1", "$JS.API.STREAM.INFO.KV_mesh-host_acts",
|
|
// Research 072 row 33: the ASSIGNMENTS stream is read and removed at the lease write.
|
|
"$JS.API.STREAM.INFO.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS",
|
|
// Issue 1126 B5 (ADR 0590 clause 6): every leftover of the ended controller, each by its name, read and deleted.
|
|
"$JS.API.STREAM.INFO.KV_mesh-controller_lease", "$JS.API.STREAM.DELETE.KV_mesh-controller_lease",
|
|
"$JS.API.STREAM.DELETE.KV_mesh-controller_rollouts", "$JS.API.STREAM.DELETE.KV_mesh-controller_states-announced",
|
|
"$JS.API.STREAM.DELETE.CONTROL", "$JS.API.STREAM.DELETE.OBJ_declaration-files-a",
|
|
"$JS.API.CONSUMER.DELETE.EVENTS.controller", "$JS.API.CONSUMER.DELETE.EVENTS.controller-merges",
|
|
"$JS.API.CONSUMER.DELETE.EVENTS.novox_mesh-delivery", "$JS.API.CONSUMER.DELETE.CONTROL.controller",
|
|
// And the consumers no part composes that the controller made before it ended: mesh-catalog's of the
|
|
// controller's built-before, and each node's engine's of restic's backed-up (issue 1126).
|
|
"$JS.API.CONSUMER.INFO.EVENTS.novox_mesh-catalog", "$JS.API.CONSUMER.DELETE.EVENTS.novox_mesh-catalog",
|
|
"$JS.API.CONSUMER.INFO.EVENTS.a_mesh-host", "$JS.API.CONSUMER.DELETE.EVENTS.a_mesh-host",
|
|
"$JS.API.CONSUMER.INFO.CONTROL.controller"},
|
|
}
|
|
mayNot := map[string][]string{
|
|
// The keeper writes no findings and no attempts; no observer writes the keeper's buckets (clause 2).
|
|
"a.mesh-conditions": {"$KV.mesh-self-check_findings.x", "$KV.mesh-healers_attempts.x", "$KV.mesh-controller_conditions.x"},
|
|
"a.mesh-self-check": {"$KV.mesh-conditions_conditions.x", "$KV.mesh-conditions_takeover.switch", "$KV.restic_good.a.pg.data",
|
|
"$KV.mesh-grants_withheld.reader.mail.a", "$SRV.INFO", "$SRV.PING.node-tools",
|
|
"$JS.API.DIRECT.GET.KV_mesh-grants_withheld.$KV.mesh-grants_withheld.reader.mail.a"},
|
|
// Only mesh-provisioner writes provisions and plans, and it writes none of the grants part's states (ADR 0570
|
|
// clauses 1, 8).
|
|
"a.mesh-provisioner": {"$KV.mesh-grants_accounts.a.m", "$KV.mesh-grants_user-list.sealed"},
|
|
"a.mesh-vault": {"$KV.mesh-provisioner_provisions.a.keycloak.postgres-database", "$KV.mesh-provisioner_plans.a.keycloak",
|
|
"mesh.seat.mesh-artifact-sweep.event.artifacts-gone"},
|
|
// Each writes its own state alone, and these users are composed.
|
|
"a.disk-load": {"$KV.restic_good.a.pg.data", "$KV.mesh-host_data.a.pg",
|
|
"$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.b.pg", "$JS.API.CONSUMER.CREATE.KV_mesh-host_data.c1.$KV.mesh-host_data.b.*"},
|
|
"a.mesh-delivery-builder": {"$KV.disk-load_readings.a.pg.data"},
|
|
// It reads every machine's plans (a provider's consumers are bound there, ADR 0373) but writes none, nor another
|
|
// machine's needs, which it keeps for the client before V3 until the lease write.
|
|
"provisioner.a": {"$KV.mesh-provisioner_plans.a.keycloak", "$KV." + ProvisionerNeedsBucket + ".b.keycloak.db.x"},
|
|
// Only the assigner writes seat-holders.
|
|
"a.artifact-store-tools": {"$KV.mesh-delivery-assigner_seat-holders.mesh-broker"},
|
|
// Only disk-load writes its readings.
|
|
"a.restic": {"$KV.disk-load_readings.a.pg.data", "$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.a.pg"},
|
|
"a.postgres": {"$KV.mesh-provisioner_provisions.a.keycloak.postgres-database", "$KV.mesh-provisioner_plans.a.keycloak"},
|
|
// Nor mesh-hand-acts' log and warrants, which it alone writes (ADR 0565 clause 10).
|
|
"a.mesh-healers": {"$KV.mesh-conditions_conditions.x", "$KV.mesh-conditions_takeover.switch",
|
|
"$KV.mesh-hand-acts_warrants.x", "mesh.log.mesh-hand-acts.acts.x"},
|
|
// The watchman never deletes or changes the controller's consumer (clause 5).
|
|
// Its reads and deletes of a message are on the streams a dead letter comes from, named, never a bucket's
|
|
// (the lead, 2026-10-29).
|
|
WatchmanModule + " sources": {"$JS.API.STREAM.MSG.GET.KV_mesh-vault_bus-accounts", "$JS.API.STREAM.MSG.GET.KV_x",
|
|
"$JS.API.STREAM.MSG.DELETE.KV_mesh-host_acts", "$JS.API.STREAM.MSG.GET.OBJ_mesh-grants-user-list", "$JS.API.STREAM.MSG.GET.OTHER",
|
|
// In L4 it neither delivers again nor drops (clause 5, the lead 2026-10-29).
|
|
"$JS.API.STREAM.MSG.DELETE.EVENTS", "$JS.API.STREAM.MSG.DELETE.DEAD_LETTERS", "$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x",
|
|
"mesh.again.a_m.x"},
|
|
WatchmanModule: {"$KV.mesh-conditions_conditions.x", "$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller",
|
|
"$JS.API.CONSUMER.CREATE.DEAD_LETTER_NOTICES.controller", "$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES." + WatchmanModule},
|
|
// The bus module's tools remove the one consumer and the acts' keys, nothing else (Limits 4); its other accounts
|
|
// remove nothing; an account is granted the removals its bus declares and no other (the catalogue refuses them
|
|
// outside the bus module).
|
|
"a.nats.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.other", "$JS.API.CONSUMER.DELETE.EVENTS.a_m",
|
|
"$KV.mesh-conditions_conditions.x", "$JS.API.STREAM.DELETE.KV_mesh-host_acts", "$JS.API.STREAM.PURGE.KV_mesh-host_acts",
|
|
"$JS.API.STREAM.DELETE.EVENTS", "$JS.API.STREAM.PURGE.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS_X",
|
|
// It updates nothing, purges nothing, deletes no stream but the leftovers' and writes no key of them (ADR 0590).
|
|
"$JS.API.STREAM.UPDATE.KV_mesh-host_acts", "$JS.API.STREAM.UPDATE.KV_mesh-controller_calls", "$JS.API.STREAM.UPDATE.EVENTS",
|
|
"$JS.API.STREAM.UPDATE.KV_mesh-controller_lease", "$JS.API.STREAM.PURGE.KV_mesh-controller_lease",
|
|
"$JS.API.STREAM.DELETE.KV_mesh-controller_other", "$JS.API.STREAM.DELETE.OBJ_declaration-files-zz",
|
|
"$JS.API.CONSUMER.DELETE.EVENTS.a_mesh-delivery", "$KV.mesh-controller_lease.holder"},
|
|
"a.nats.other": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1",
|
|
"$JS.API.STREAM.UPDATE.KV_mesh-controller_lease"},
|
|
"a.nats": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1"},
|
|
"a.shop.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-conditions_conditions.x", "$JS.API.STREAM.DELETE.ASSIGNMENTS"},
|
|
}
|
|
// ADR 0571 clause 1: a read naming another module's log is issued to the reader as a log it reads, never writes.
|
|
if m := MembershipFor("a", Declared{Module: delivery.IndexerModule, Reads: []string{delivery.BuildLogRead, "x.y"}},
|
|
Placements{Logs: map[string]bool{delivery.BuildLogRead: true}}); len(m.Logs) != 1 || m.Logs[0].Writes ||
|
|
m.Logs[0].Stream != "LOG_mesh-delivery-builder_build-log" || m.Logs[0].Name != delivery.BuildLogRead {
|
|
t.Errorf("the indexer's membership issues the logs %+v", m.Logs)
|
|
}
|
|
// ADR 0565 clauses 6, 7 (issue 1953 L5-S4): a node's engine writes its own packages key and no other node's; only the
|
|
// assigner writes its policies.
|
|
for _, u := range users {
|
|
perms, err := PermissionsFor(u)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
// ADR 0570 clause 6: a node's engine reads its own node's provision keys, and no other node's, and writes none.
|
|
if u.Kind == KindNode && (!MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.a.keycloak.db") ||
|
|
MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.b.keycloak.db") ||
|
|
MayPublish(perms, "$KV.mesh-provisioner_provisions.a.keycloak.db")) {
|
|
t.Errorf("node %s reads the provisions %v", u.Node, perms.Publish)
|
|
}
|
|
// Issue 774: a node's engine writes its own health report's key and no other node's.
|
|
if u.Kind == KindNode && (!MayPublish(perms, "$KV.mesh-host_health.a") || MayPublish(perms, "$KV.mesh-host_health.b")) {
|
|
t.Errorf("node %s writes the health reports %v", u.Node, perms.Publish)
|
|
}
|
|
if u.Kind == KindModule && u.Module == WatchmanModule && (!MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.b") ||
|
|
MayPublish(perms, "$KV.mesh-host_health.b")) {
|
|
t.Errorf("the watchman reads the health reports %v, %v", MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.b"), perms.Publish)
|
|
}
|
|
// ADR 0565 clause 6, ADR 0590: and the ended controller's mirror it reads no more.
|
|
if u.Kind == KindNode && (!MayPublish(perms, "$KV.mesh-host_packages.a") || MayPublish(perms, "$KV.mesh-host_packages.b") ||
|
|
MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-controller_declared-packages.$KV.mesh-controller_declared-packages.a")) {
|
|
t.Errorf("node %s writes the packages keys %v", u.Node, perms.Publish)
|
|
}
|
|
if u.Kind == KindModule && MayPublish(perms, "$KV.mesh-delivery-assigner_policies.x") {
|
|
t.Errorf("%s writes the assigner's policies", u.Username())
|
|
}
|
|
// ADR 0571 clause 10 (issue 774, 774/S5/AC2): nobody hears memberships on mesh.assignment; they are read from
|
|
// the grants part's state.
|
|
if MaySubscribe(perms, "mesh.assignment.a.restic") {
|
|
t.Errorf("%s hears mesh.assignment", u.Username())
|
|
}
|
|
}
|
|
// Nor the runtime carrying the node's modules, which reads each one's membership from the state.
|
|
if perms, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "a", Module: RuntimeModule,
|
|
Carries: []Declared{{Module: "restic"}}}); err != nil || MaySubscribe(perms, "mesh.assignment.a.restic") ||
|
|
!MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.a.restic") {
|
|
t.Errorf("the runtime hears mesh.assignment or reads no membership: %v", err)
|
|
}
|
|
// 1953 S-age (ADR 0565 §10): a log that says an age keeps its entries that long and no longer; one that says none, as
|
|
// long as the log (ADR 0297).
|
|
if aged, kept := (Log{Module: "mesh-hand-acts", Name: "acts", MaxAgeDays: 400}).Config(), (Log{Module: "m", Name: "l"}).Config(); aged.MaxAge != 400*24*time.Hour || kept.MaxAge != 0 || !aged.DenyDelete {
|
|
t.Errorf("an aged log's stream keeps %s, an unaged one %s", aged.MaxAge, kept.MaxAge)
|
|
}
|
|
// The tools account's secret is the module's own broker-<binary>, which its manifest's own-secrets names and the
|
|
// vault keeps by that name (AccountSecret).
|
|
if w := (WantedAccount{User: "a.nats.nats-tools", Kind: KindProcess, Node: "a", Module: "nats", Process: "nats-tools",
|
|
URL: "nats://a:4222"}); w.Valid() != nil || w.OwnSecret() != "broker-nats-tools" {
|
|
t.Errorf("the tools account is held in %q (%v)", w.OwnSecret(), w.Valid())
|
|
}
|
|
// Issue 1952: a module that holds a seat says its own seat's events, also when the tool runner carries it, so the
|
|
// self-check's `checked`, the keeper's condition events and the healers' `healer-acted` reach their hearers. Its
|
|
// membership lists them, the runtime is granted them; a user of the seat is not, nor a machine's event on a node seat
|
|
// (granted per machine, to the holder's own account only).
|
|
selfCheck := Declared{Module: "mesh-self-check", Holds: []Seat{{Name: "mesh-self-check", Scope: "mesh", Emits: []string{"checked"}}}}
|
|
for _, c := range []struct {
|
|
name string
|
|
d Declared
|
|
subject string
|
|
may bool
|
|
}{
|
|
{"the self-check says checked", selfCheck, "mesh.seat.mesh-self-check.event.checked", true},
|
|
{"the keeper says a condition raised", Declared{Module: "mesh-conditions", Holds: []Seat{{Name: "mesh-conditions", Scope: "mesh",
|
|
Emits: []string{"condition-raised", "condition-cleared"}}}}, "mesh.seat.mesh-conditions.event.condition-cleared", true},
|
|
{"a user does not say the seat's events", Declared{Module: "mesh-delivery-view", Uses: []Seat{{Name: "mesh-self-check",
|
|
Scope: "mesh", Emits: []string{"checked"}}}}, "mesh.seat.mesh-self-check.event.checked", false},
|
|
{"the holder says no other seat's", selfCheck, "mesh.seat.mesh-conditions.event.condition-raised", false},
|
|
{"a machine's event on a node seat is not composed here", Declared{Module: "pause", Holds: []Seat{{Name: "node-power",
|
|
Scope: "node", Emits: []string{"paused.*"}}}}, "mesh.seat.node-power.event.paused.b", false},
|
|
} {
|
|
m := MembershipFor("a", c.d, Placements{})
|
|
listed := m.SeatTraffic != nil && slices.ContainsFunc(m.SeatTraffic.Publish, func(p string) bool { return p == c.subject })
|
|
perms, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "a", Carries: []Declared{c.d}})
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", c.name, err)
|
|
}
|
|
if listed != c.may || MayPublish(perms, c.subject) != c.may {
|
|
t.Errorf("%s: the membership lists %s %v and the runtime may publish it %v, want %v", c.name, c.subject, listed,
|
|
MayPublish(perms, c.subject), c.may)
|
|
}
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, u := range users {
|
|
name := u.Username()
|
|
if u.Module == WatchmanModule && u.Kind == KindModule {
|
|
name = WatchmanModule
|
|
}
|
|
perms, err := PermissionsFor(u)
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", name, err)
|
|
}
|
|
if name == WatchmanModule {
|
|
// It hears its watchdogs' words on core subjects (Limits 3, 11).
|
|
for _, subject := range []string{"mesh.mod.power.event.sleeping", "mesh.control.g14.tools-alive",
|
|
"mesh.seat.mesh-self-check.event.checked", "mesh.mod.keycloak.event.provisioner.failing",
|
|
"mesh.mod.nats.event.bus.slow-consumer", "prov.applied.g14.keycloak"} {
|
|
if !MaySubscribe(perms, subject) {
|
|
t.Errorf("the watchman may not hear %s", subject)
|
|
}
|
|
}
|
|
// Never the bus's API audit, which carries every API answer in the account (the lead, 2026-10-29).
|
|
if MaySubscribe(perms, "$JS.EVENT.ADVISORY.API") || MaySubscribe(perms, "$JS.EVENT.ADVISORY.API.x") {
|
|
t.Error("the watchman hears the bus's API audit")
|
|
}
|
|
if !MayPublish(perms, "$JS.API.DIRECT.GET.PROV_APPLIED.prov.applied.g14.keycloak") {
|
|
t.Error("the watchman may not read the newest provisioner report")
|
|
}
|
|
}
|
|
subjects := append(append([]string(nil), may[name]...), may[name+" sources"]...)
|
|
denied := append(append([]string(nil), mayNot[name]...), mayNot[name+" sources"]...)
|
|
if len(may[name+" sources"]) > 0 {
|
|
seen[name+" sources"] = true
|
|
}
|
|
for _, subject := range subjects {
|
|
seen[name] = true
|
|
if !MayPublish(perms, subject) {
|
|
t.Errorf("%s may not publish %s", name, subject)
|
|
}
|
|
}
|
|
for _, subject := range denied {
|
|
seen[name] = true
|
|
if MayPublish(perms, subject) {
|
|
t.Errorf("%s may publish %s", name, subject)
|
|
}
|
|
}
|
|
}
|
|
// Every row names a user that was composed, in either table: a row for a user absent would pass for nothing.
|
|
for _, table := range []map[string][]string{may, mayNot} {
|
|
for name := range table {
|
|
if !seen[name] {
|
|
t.Errorf("no user %s was composed", name)
|
|
}
|
|
}
|
|
}
|
|
}
|