Land feature 57's controller work on its release branch
The controller plans config, templates and moved providers' provisions, hands bus accounts to the vault and judges the new manifest words, on the feature's SDK and node-engine validator (feature 57, config and provisioning).
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// **The bus module's watch speaks through the runtime that carries it** (novox/hq ADR 0357): the node's
|
||||
// runtime is granted the events the bus module declares, under the module's name, while the bus's own
|
||||
// account keeps the snapshot API and nothing else (ADR 0235).
|
||||
func TestTheRuntimeCarryingTheBusModuleMayPublishItsEventsAndTheBussAccountMayNot(t *testing.T) {
|
||||
events := []string{"bus.slow-consumer", "bus.connection-closed"}
|
||||
runtime := Principal{Kind: KindNodeTools, Node: "anchor", Module: RuntimeModule,
|
||||
Carries: []Declared{{Module: "nats", Emits: events}}}
|
||||
p, err := PermissionsFor(runtime)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range events {
|
||||
if !slices.Contains(p.Publish, "mesh.mod.nats.event."+e) {
|
||||
t.Errorf("the runtime carrying the bus module may not publish %s: %v", e, p.Publish)
|
||||
}
|
||||
}
|
||||
account := Principal{Kind: KindModule, Node: "anchor", Module: "nats", Emits: events, SnapshotsTheBus: true}
|
||||
a, err := PermissionsFor(account)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range a.Publish {
|
||||
if s == "mesh.mod.nats.event.bus.slow-consumer" || s == "mesh.mod.nats.event.bus.connection-closed" {
|
||||
t.Errorf("the bus's own account is granted %s", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// **Only mesh-config's account writes mesh-config's buckets** (novox/hq ADR 0348 §9 and How it is checked, design
|
||||
// 57 K3, issue 662): on a real server whose users are composed as the controller composes them. mesh-config runs on
|
||||
// an account of its own (`runs-as`, ADR 0259 §8), so the machine's runtime does not carry it and its own principal
|
||||
// alone is granted the writes of its four buckets. A put from any other account is refused by the server: a
|
||||
// module reading the resolved values through `reads`, the runtime of the machine mesh-config runs on, and the
|
||||
// controller.
|
||||
func TestNatsOnlyMeshConfigsAccountPutsToItsBuckets(t *testing.T) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
states := []string{"values", "resolved", "predefined", "proposals"}
|
||||
var buckets []Bucket
|
||||
for _, s := range states {
|
||||
buckets = append(buckets, Bucket{Module: "mesh-config", Name: s})
|
||||
}
|
||||
seat := Seat{Name: "mesh-config", Scope: "mesh", Serves: []string{"config"}}
|
||||
users, err := Users(Records{Nodes: []string{"novox", "g14"}, Assigned: map[string][]Declared{
|
||||
"novox": {
|
||||
{Module: RuntimeModule},
|
||||
{Module: "mesh-config", RunsAs: "mesh-config", Holds: []Seat{seat}, State: buckets,
|
||||
Emits: []string{"config-changed"},
|
||||
Reads: []string{"mesh-controller.assignments", "mesh-controller.declared-config", "mesh-controller.predefined"}},
|
||||
{Module: "mesh-issues", State: []Bucket{{Module: "mesh-issues", Name: "issues"}}},
|
||||
},
|
||||
"g14": {{Module: "dunst", Reads: []string{"mesh-config.resolved"}}},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range users {
|
||||
users[i].PasswordHash = string(hash)
|
||||
}
|
||||
accounts, err := ComposeAccounts(users)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conf := filepath.Join(t.TempDir(), "bus.conf")
|
||||
if err := os.WriteFile(conf, []byte("listen: 127.0.0.1:-1\njetstream { store_dir: "+
|
||||
`"`+t.TempDir()+`"`+" }\n"+accounts), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts, err := server.ProcessConfigFile(conf)
|
||||
if err != nil {
|
||||
t.Fatalf("the composed accounts do not parse: %v", err)
|
||||
}
|
||||
opts.NoLog, opts.NoSigs = true, true
|
||||
s, err := server.NewServer(opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go s.Start()
|
||||
if !s.ReadyForConnections(10 * time.Second) {
|
||||
t.Fatal("the bus did not come up")
|
||||
}
|
||||
t.Cleanup(s.Shutdown)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
connect := func(user string) (*nats.Conn, chan error) {
|
||||
t.Helper()
|
||||
refused := make(chan error, 8)
|
||||
nc, err := nats.Connect(s.ClientURL(), nats.UserInfo(user, "pw"), nats.CustomInboxPrefix("_INBOX."+user),
|
||||
nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) { refused <- err }))
|
||||
if err != nil {
|
||||
t.Fatalf("%s cannot connect: %v", user, err)
|
||||
}
|
||||
t.Cleanup(nc.Close)
|
||||
return nc, refused
|
||||
}
|
||||
// The controller makes the buckets, as it does from a manifest's `state`.
|
||||
controller, controllerRefused := connect(ControllerName)
|
||||
cjs, err := jetstream.New(controller)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, b := range buckets {
|
||||
if _, err := cjs.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: b.Bucket()}); err != nil {
|
||||
t.Fatalf("the controller cannot make %s: %v", b.Bucket(), err)
|
||||
}
|
||||
}
|
||||
|
||||
owner, _ := connect("novox.mesh-config")
|
||||
ojs, err := jetstream.New(owner)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, b := range buckets {
|
||||
kv, err := ojs.KeyValue(ctx, b.Bucket())
|
||||
if err != nil {
|
||||
t.Fatalf("mesh-config cannot bind its own %s: %v", b.Bucket(), err)
|
||||
}
|
||||
if _, err := kv.Put(ctx, "mesh.font-size", []byte(`{"value":12}`)); err != nil {
|
||||
t.Errorf("mesh-config cannot write its own %s: %v", b.Bucket(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// Every other account: a publish on the bucket's subject is refused by the server, and the bucket holds
|
||||
// what mesh-config wrote.
|
||||
reader, readerRefused := connect("g14.dunst")
|
||||
runtime, runtimeRefused := connect("novox." + RuntimeModule)
|
||||
for _, other := range []struct {
|
||||
name string
|
||||
nc *nats.Conn
|
||||
refused chan error
|
||||
}{{"a module reading the resolved values", reader, readerRefused},
|
||||
{"the runtime of mesh-config's machine", runtime, runtimeRefused},
|
||||
{"the controller", controller, controllerRefused}} {
|
||||
for _, b := range buckets {
|
||||
if err := other.nc.Publish("$KV."+b.Bucket()+".mesh.font-size", []byte(`{"value":99}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = other.nc.Flush()
|
||||
select {
|
||||
case err := <-other.refused:
|
||||
if !errors.Is(err, nats.ErrPermissionViolation) {
|
||||
t.Errorf("%s putting to %s: the server said %v, want a permissions violation", other.name, b.Bucket(), err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Errorf("the server did not refuse %s a put to %s", other.name, b.Bucket())
|
||||
}
|
||||
}
|
||||
}
|
||||
// The reader still reads what it was granted.
|
||||
rjs, err := jetstream.New(reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, err := rjs.KeyValue(ctx, "mesh-config_resolved")
|
||||
if err != nil {
|
||||
t.Fatalf("a module reading mesh-config.resolved cannot bind it: %v", err)
|
||||
}
|
||||
e, err := resolved.Get(ctx, "mesh.font-size")
|
||||
if err != nil || string(e.Value()) != `{"value":12}` {
|
||||
t.Errorf("the resolved value reads %v (%v), want mesh-config's own write", e, err)
|
||||
}
|
||||
for _, b := range buckets {
|
||||
kv, err := ojs.KeyValue(ctx, b.Bucket())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e, err := kv.Get(ctx, "mesh.font-size"); err != nil || string(e.Value()) != `{"value":12}` {
|
||||
t.Errorf("%s holds %v (%v) after the refused puts", b.Bucket(), e, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,45 @@ var (
|
||||
AskedBucket = BucketName(ControllerSeat, "asked")
|
||||
)
|
||||
|
||||
// The controller's record mirrors (novox/hq ADR 0289, design 54, design 57 package F3): a bucket per kind of
|
||||
// record, one key per record, written from the store by the lease holder only when the record changed, with
|
||||
// one event on the controller's seat per write. Read by whatever names them in `reads` as
|
||||
// `mesh-controller.<mirror>` (ADR 0201 §1), written by nobody else. **No secret in either** (ADR 0201): an
|
||||
// assignment's value names its module and node; a module's declared config is what its manifest, which is
|
||||
// public, declares.
|
||||
//
|
||||
// AssignmentsBucket holds which module is assigned to which node, keyed `<node>.<module>`; and
|
||||
// DeclaredConfigBucket each registered module's config declarations as its manifest holds them, keyed by
|
||||
// the module: what mesh-config resolves a config value from (ADR 0348 clauses 2 and 4); and PredefinedBucket the
|
||||
// list of predefined keys the mesh published (the newest trunk build's of the catalogue, ADR 0348 §7), one key
|
||||
// `list`: what mesh-config publishes as mesh-config_predefined (design 57 K3, issue 662).
|
||||
var (
|
||||
AssignmentsBucket = BucketName(ControllerSeat, MirrorAssignments)
|
||||
DeclaredConfigBucket = BucketName(ControllerSeat, MirrorDeclaredConfig)
|
||||
PredefinedBucket = BucketName(ControllerSeat, MirrorPredefined)
|
||||
)
|
||||
|
||||
// The mirrors' local names, as a reader names them in `reads` after `mesh-controller.`.
|
||||
const (
|
||||
MirrorAssignments = "assignments"
|
||||
MirrorDeclaredConfig = "declared-config"
|
||||
MirrorPredefined = "predefined"
|
||||
)
|
||||
|
||||
// ControllerMirrors are the local names of the controller's record mirrors, which a module may read.
|
||||
func ControllerMirrors() []string {
|
||||
return []string{MirrorAssignments, MirrorDeclaredConfig, MirrorPredefined}
|
||||
}
|
||||
|
||||
// The mirrors' caps (design 54 §5): one value per key, no age, 64 KiB a value, 64 MiB a bucket.
|
||||
const (
|
||||
MirrorValueBytes = 64 << 10
|
||||
MirrorBytes = 64 << 20
|
||||
// MirrorMarkerTTL is the longest a purge's marker is kept (design 54 §2): the bucket allows a
|
||||
// per-message age for it, and the writer gives each marker a day.
|
||||
MirrorMarkerTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
// AskedKeptFor is how long an ask is kept after it was made: a month, as the router keeps its own.
|
||||
const AskedKeptFor = 30 * 24 * time.Hour
|
||||
|
||||
@@ -66,12 +105,14 @@ const (
|
||||
// IsControllerBucket says a bucket is the controller's own, not a module's state nothing declares.
|
||||
func IsControllerBucket(bucket string) bool {
|
||||
return bucket == CallsBucket || bucket == HandActsBucket || bucket == ConditionsBucket ||
|
||||
bucket == ConditionHistoryBucket || bucket == LeaseBucket || bucket == AskedBucket
|
||||
bucket == ConditionHistoryBucket || bucket == LeaseBucket || bucket == AskedBucket ||
|
||||
bucket == AssignmentsBucket || bucket == DeclaredConfigBucket || bucket == PredefinedBucket
|
||||
}
|
||||
|
||||
// ControllerBuckets are the controller's own buckets, in the order they are asserted.
|
||||
func ControllerBuckets() []string {
|
||||
return []string{LeaseBucket, CallsBucket, HandActsBucket, ConditionsBucket, ConditionHistoryBucket, AskedBucket}
|
||||
return []string{LeaseBucket, CallsBucket, HandActsBucket, ConditionsBucket, ConditionHistoryBucket, AskedBucket,
|
||||
AssignmentsBucket, DeclaredConfigBucket, PredefinedBucket}
|
||||
}
|
||||
|
||||
// ControllerBucketsAsserter is what raising the controller's buckets needs of a connection.
|
||||
@@ -168,6 +209,30 @@ func (j *JetStream) EnsureControllerBuckets() error {
|
||||
}); err != nil {
|
||||
return fmt.Errorf("asserting bucket %s: %w", AskedBucket, err)
|
||||
}
|
||||
// **The record mirrors: no age** (design 54 §5). A key is removed when its record is, by a purge whose
|
||||
// marker lives a day; a key that expired would be a record the bus forgot while the store still holds it.
|
||||
for _, m := range []struct{ bucket, why string }{
|
||||
{AssignmentsBucket, "which module is assigned to which node, keyed <node>.<module> (novox/hq ADR 0289, " +
|
||||
"design 54): written by the controller alone from its store when an assignment changes, read through `reads`"},
|
||||
{DeclaredConfigBucket, "each registered module's config declarations as its manifest holds them, keyed by " +
|
||||
"the module (novox/hq ADR 0348, design 57 F3): written by the controller alone from its store when a " +
|
||||
"registration changes them, read through `reads`"},
|
||||
{PredefinedBucket, "the list of predefined keys the mesh published, the newest trunk build's of the " +
|
||||
"catalogue, one key `list` (novox/hq ADR 0348 §7, design 57 K3): written by the controller alone from " +
|
||||
"its store when a newer list is kept, read through `reads`"},
|
||||
} {
|
||||
if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
|
||||
Bucket: m.bucket,
|
||||
Description: m.why,
|
||||
History: 1,
|
||||
MaxValueSize: MirrorValueBytes,
|
||||
MaxBytes: MirrorBytes,
|
||||
Storage: jetstream.FileStorage,
|
||||
LimitMarkerTTL: MirrorMarkerTTL,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("asserting bucket %s: %w", m.bucket, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,9 @@ func TestTheControllerMayWriteEveryBucketItWrites(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"$KV." + CallsBucket + ".>", "$KV." + HandActsBucket + ".>"}
|
||||
want := []string{"$KV." + CallsBucket + ".>", "$KV." + HandActsBucket + ".>",
|
||||
"$KV." + AssignmentsBucket + ".>", "$KV." + DeclaredConfigBucket + ".>",
|
||||
"$KV." + PredefinedBucket + ".>"}
|
||||
for _, seat := range seatsTheControllerAsks {
|
||||
if hasCancelledSet(seat) {
|
||||
want = append(want, "$KV."+CancelledSetName(seat)+".>")
|
||||
@@ -37,6 +39,35 @@ func TestTheControllerMayWriteEveryBucketItWrites(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// **A module reads a record mirror through `reads`, and never writes it** (ADR 0201 §1, design 54 §5,
|
||||
// design 57 F3): granted the direct reads and watches of the bucket, and refused at composition a grant to
|
||||
// write it, whose writer is the controller.
|
||||
func TestAModuleReadsTheControllersMirrorsAndWritesNone(t *testing.T) {
|
||||
reader := Principal{Kind: KindModule, Node: "one", Module: "mesh-config", PasswordHash: "x",
|
||||
Reads: []string{"mesh-controller." + MirrorAssignments, "mesh-controller." + MirrorDeclaredConfig,
|
||||
"mesh-controller." + MirrorPredefined}}
|
||||
p, err := PermissionsFor(reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, bucket := range []string{AssignmentsBucket, DeclaredConfigBucket, PredefinedBucket} {
|
||||
for _, subject := range []string{"$JS.API.STREAM.INFO.KV_" + bucket, "$JS.API.DIRECT.GET.KV_" + bucket + ".>",
|
||||
"$JS.API.CONSUMER.CREATE.KV_" + bucket + ".>"} {
|
||||
if !slices.Contains(p.Publish, subject) {
|
||||
t.Errorf("a module reading %s may not publish %s", bucket, subject)
|
||||
}
|
||||
}
|
||||
for _, subject := range p.Publish {
|
||||
if SubjectsOverlap(subject, "$KV."+bucket+".>") {
|
||||
t.Errorf("a module reading %s may write it through %s", bucket, subject)
|
||||
}
|
||||
}
|
||||
if err := CheckWriters(reader, []string{"$KV." + bucket + ".>"}); err == nil {
|
||||
t.Errorf("a module granted a write of %s would compose", bucket)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **A machine's node tools may say they are there, as that machine and no other** (novox/hq to-be 45
|
||||
// S11), and the controller may hear the bus's advisories and ask who answers — read-only, named.
|
||||
func TestTheWatchedSignalsMayBeSaidAndHeard(t *testing.T) {
|
||||
@@ -96,3 +127,102 @@ func TestWhatTheControllerAskedIsBounded(t *testing.T) {
|
||||
t.Errorf("history %d, age %s, bytes %d", status.History(), status.TTL(), info.Config.MaxBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// **A listed mirror is no undeclared bucket** (ADR 0356 §3, ADR 0201 §16): the controller's list of its mirrors
|
||||
// stands for their declaration, so the report of state nothing declares never names one.
|
||||
func TestAListedMirrorIsNotReportedAsUndeclared(t *testing.T) {
|
||||
names := []string{"gone_state"}
|
||||
for _, m := range ControllerMirrors() {
|
||||
names = append(names, BucketName(ControllerSeat, m))
|
||||
}
|
||||
undeclared, err := RaiseBuckets(fakeBuckets{names: names}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Equal(undeclared, []string{"gone_state"}) {
|
||||
t.Fatalf("the report of undeclared state names %v", undeclared)
|
||||
}
|
||||
}
|
||||
|
||||
// **The mirrors' list and the writers table agree** (ADR 0356 §3, to-be 45 §1): every listed mirror is a row of
|
||||
// the table whose one writer is the controller, on its bucket alone; and every row that writes a bucket of the
|
||||
// controller's named as a mirror is on the list.
|
||||
func TestTheMirrorsListAndTheWritersTableAgree(t *testing.T) {
|
||||
controller := Principal{Kind: KindController}
|
||||
module := Principal{Kind: KindModule, Module: ControllerSeat, Node: "one"}
|
||||
rowed := map[string]bool{}
|
||||
for _, row := range WritersTable {
|
||||
if !strings.HasSuffix(row.State, " mirror") {
|
||||
continue
|
||||
}
|
||||
for _, subject := range row.Subjects {
|
||||
bucket := strings.TrimSuffix(strings.TrimPrefix(subject, "$KV."), ".>")
|
||||
rowed[bucket] = true
|
||||
if row.Writes == nil || !row.Writes(controller, subject) || row.Writes(module, subject) {
|
||||
t.Errorf("%q is not written by the controller alone", row.State)
|
||||
}
|
||||
}
|
||||
}
|
||||
listed := map[string]bool{}
|
||||
for _, m := range ControllerMirrors() {
|
||||
bucket := BucketName(ControllerSeat, m)
|
||||
listed[bucket] = true
|
||||
if !rowed[bucket] {
|
||||
t.Errorf("the mirror %s is listed and no row of the writers table writes it", bucket)
|
||||
}
|
||||
if !IsControllerBucket(bucket) || !slices.Contains(ControllerBuckets(), bucket) {
|
||||
t.Errorf("the mirror %s is listed and not asserted as the controller's own", bucket)
|
||||
}
|
||||
}
|
||||
for bucket := range rowed {
|
||||
if !listed[bucket] {
|
||||
t.Errorf("the writers table writes the mirror %s, which the list does not name", bucket)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **The controller may ask the vault's intake verbs in its own name** (design 57's V2, issue 675): the vault
|
||||
// takes a value only from the controller, which it knows from the subject that names its caller — so the
|
||||
// controller is granted that subject for the verbs the vault's holder serves, and no more of the vault's seat.
|
||||
// `get` among them since issue 755 (ADR 0349 §16): the vault answers the controller a core credential sealed to its
|
||||
// machine alone, which the controller carries and cannot open.
|
||||
// Composed from the records, as the issued tools are: the installer's first user list carries none.
|
||||
func TestTheControllerMayAskTheVaultInItsOwnName(t *testing.T) {
|
||||
vault := Declared{Module: "mesh-vault", Holds: []Seat{{Name: "mesh-vault",
|
||||
Serves: []string{"get", "accept", "secret-ask", "issued", "rotate"}}}}
|
||||
users, err := Users(Records{Nodes: []string{"novox"}, Assigned: map[string][]Declared{"novox": {vault}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
controller := users[0]
|
||||
if strings.Join(controller.AsksTheVault, ",") != "accept,get,issued,rotate,secret-ask" {
|
||||
t.Fatalf("composed %v", controller.AsksTheVault)
|
||||
}
|
||||
p, err := PermissionsFor(controller)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, verb := range []string{"accept", "secret-ask", "issued", "rotate", "get"} {
|
||||
if !MayPublish(p, CalledSubject("mesh.seat.mesh-vault.tool."+verb, ControllerName)) {
|
||||
t.Errorf("the controller may not ask the vault's %s in its own name", verb)
|
||||
}
|
||||
if MayPublish(p, CalledSubject("mesh.seat.mesh-vault.tool."+verb, "anchor.node-tools")) {
|
||||
t.Errorf("the controller may ask the vault's %s in another's name", verb)
|
||||
}
|
||||
}
|
||||
if MayPublish(p, CalledSubject("mesh.seat.mesh-vault.tool.move-in", ControllerName)) {
|
||||
t.Error("the controller may move a value in: that is a node-engine's alone")
|
||||
}
|
||||
// A vault that serves none of them yet composes no grant, nor does genesis.
|
||||
older := Declared{Module: "mesh-vault", Holds: []Seat{{Name: "mesh-vault", Serves: []string{"move-in"}}}}
|
||||
users, _ = Users(Records{Nodes: []string{"novox"}, Assigned: map[string][]Declared{"novox": {older}}})
|
||||
if len(users[0].AsksTheVault) != 0 {
|
||||
t.Errorf("a vault serving none composed %v", users[0].AsksTheVault)
|
||||
}
|
||||
genesis, _ := PermissionsFor(Principal{Kind: KindController, PasswordHash: "x"})
|
||||
for _, s := range genesis.Publish {
|
||||
if strings.Contains(s, "mesh-vault") {
|
||||
t.Errorf("the installer's controller may publish %s", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +128,13 @@ func ConsumerFor(p Principal) (Consumer, bool) {
|
||||
// connected, and its graph stayed empty with nothing anywhere reporting why.
|
||||
// And one that hears its own answers on a seat it uses (novox/hq ADR 0259 §3): an asker's warrants.
|
||||
hearsItsOwn := len(SeatTrafficOf(p.Module, nil, p.Uses, nil).Subscribe) > 0
|
||||
if p.Kind != KindModule || (len(p.Consumes) == 0 && len(p.Watches) == 0 && !hearsItsOwn) {
|
||||
hearsAStream := false
|
||||
for _, h := range p.Hears {
|
||||
if len(h.Filters) > 0 {
|
||||
hearsAStream = true
|
||||
}
|
||||
}
|
||||
if p.Kind != KindModule || (len(p.Consumes) == 0 && len(p.Watches) == 0 && !hearsItsOwn && !hearsAStream) {
|
||||
return Consumer{}, false
|
||||
}
|
||||
perms, err := PermissionsFor(p)
|
||||
@@ -140,11 +146,14 @@ func ConsumerFor(p Principal) (Consumer, bool) {
|
||||
// business, which is why this is a filter and not the whole list.
|
||||
var filters []string
|
||||
for _, s := range perms.Subscribe {
|
||||
if strings.Contains(s, ".event.") {
|
||||
// Not a module stream's, which its own consumer on that stream hears (novox/hq ADR 0363).
|
||||
if strings.Contains(s, ".event.") && !strings.HasPrefix(s, moduleStreamPrefix) {
|
||||
filters = append(filters, s)
|
||||
}
|
||||
}
|
||||
if len(filters) == 0 {
|
||||
// **A module that hears only a module's stream still has one here** (novox/hq ADR 0363 §7): what it
|
||||
// gives up on there is delivered again under `mesh.again.<consumer>.` on EVENTS, and only this filters it.
|
||||
if len(filters) == 0 && !hearsAStream {
|
||||
return Consumer{}, false
|
||||
}
|
||||
sort.Strings(filters)
|
||||
@@ -181,6 +190,13 @@ func ConsumersOf(users []Principal) []ModuleConsumer {
|
||||
}
|
||||
seen[p.Node+"/"+p.Module] = true
|
||||
out = append(out, ModuleConsumer{Node: p.Node, Module: p.Module, Consumer: c})
|
||||
// And one on each module stream it hears (novox/hq ADR 0363 §5).
|
||||
for _, h := range p.Hears {
|
||||
if len(h.Filters) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, ModuleConsumer{Node: p.Node, Module: p.Module, Consumer: heardConsumer(p, h)})
|
||||
}
|
||||
}
|
||||
for _, p := range users {
|
||||
if p.Kind == KindModule {
|
||||
@@ -193,7 +209,8 @@ func ConsumersOf(users []Principal) []ModuleConsumer {
|
||||
}
|
||||
for _, d := range p.Carries {
|
||||
add(Principal{Kind: KindModule, Node: p.Node, Module: d.Module, Emits: d.Emits,
|
||||
Consumes: d.Consumes, Serves: d.Serves, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches})
|
||||
Consumes: d.Consumes, Serves: d.Serves, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches,
|
||||
Streams: d.Streams, Hears: ComposedHears(d)})
|
||||
}
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -35,7 +35,7 @@ func TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose(t *testing.T)
|
||||
carriedPub := subjectsIn(accounts, "publish")
|
||||
carriedSub := subjectsIn(accounts, "subscribe")
|
||||
|
||||
if diff := missing(want.Publish, carriedPub); len(diff) > 0 {
|
||||
if diff := requiredPublishGap(t, want.Publish, carriedPub); len(diff) > 0 {
|
||||
t.Errorf("the installer's user list does not let the controller publish %v — it would come up "+
|
||||
"and be refused on the first thing it tried", diff)
|
||||
}
|
||||
@@ -62,6 +62,59 @@ func TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// requiredPublishGap is what in want is missing from carried, leaving out a grant DegradableGrants names
|
||||
// and saying why in the test log instead (novox/hq issue 835, the forge check of mesh-controller#345): the
|
||||
// merge check reads mesh-host at the commit the machines run, which a change to mesh-host's examples alone
|
||||
// never moves, so the carried list can lag the controller's derived one without the controller refusing to
|
||||
// come up.
|
||||
func requiredPublishGap(t *testing.T, want, carried []string) []string {
|
||||
t.Helper()
|
||||
diff := missing(want, carried)
|
||||
var required []string
|
||||
for _, subject := range diff {
|
||||
if reason, ok := degradableReason(subject); ok {
|
||||
t.Logf("the installer's user list does not let the controller publish %s, but that falls back "+
|
||||
"rather than refuses: %s", subject, reason)
|
||||
continue
|
||||
}
|
||||
required = append(required, subject)
|
||||
}
|
||||
return required
|
||||
}
|
||||
|
||||
// degradableReason says whether a subject is one of DegradableGrants, and why.
|
||||
func degradableReason(subject string) (string, bool) {
|
||||
for _, g := range DegradableGrants {
|
||||
if g.Subject == subject {
|
||||
return g.Reason, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// TestADegradableGrantMissingFromTheInstallersListPasses is the forge check's own case (mesh-controller#345,
|
||||
// novox/hq issue 835): an installer list that lacks operator-channel.tell alone, because mesh-host has not
|
||||
// yet built with the grant, still passes — the controller falls back to the secret-given condition rather
|
||||
// than coming up refused.
|
||||
func TestADegradableGrantMissingFromTheInstallersListPasses(t *testing.T) {
|
||||
want := []string{"mesh.seat.operator-channel.tool.tell", "mesh.seat.node-service-manager.tool.restart.*"}
|
||||
carried := []string{"mesh.seat.node-service-manager.tool.restart.*"}
|
||||
if diff := requiredPublishGap(t, want, carried); len(diff) > 0 {
|
||||
t.Errorf("a degradable grant missing from the installer's list must not fail the genesis test, got %v", diff)
|
||||
}
|
||||
}
|
||||
|
||||
// TestANonDegradableGrantMissingFromTheInstallersListStillFails proves the exemption is named, not general:
|
||||
// every other grant the controller derives stays required.
|
||||
func TestANonDegradableGrantMissingFromTheInstallersListStillFails(t *testing.T) {
|
||||
want := []string{"mesh.seat.operator-channel.tool.tell", "mesh.seat.node-service-manager.tool.restart.*"}
|
||||
carried := []string{"mesh.seat.operator-channel.tool.tell"}
|
||||
diff := requiredPublishGap(t, want, carried)
|
||||
if len(diff) != 1 || diff[0] != "mesh.seat.node-service-manager.tool.restart.*" {
|
||||
t.Errorf("a missing grant that is not degradable must still fail the genesis test, got %v", diff)
|
||||
}
|
||||
}
|
||||
|
||||
// theCarriedAccounts is the accounts file the installer's template writes at genesis.
|
||||
func theCarriedAccounts(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
@@ -173,6 +173,10 @@ func (j *JetStream) EnsureStream(s Stream) error {
|
||||
Description: s.Why,
|
||||
}
|
||||
want.AllowDirect = s.Direct
|
||||
want.AllowMsgTTL = s.AllowMsgTTL
|
||||
if s.Restricted {
|
||||
want.Metadata = map[string]string{RestrictedMetadata: "true"}
|
||||
}
|
||||
if s.MaxBytes > 0 {
|
||||
want.MaxBytes = s.MaxBytes
|
||||
}
|
||||
@@ -486,3 +490,25 @@ func (j *JetStream) LogStreams() ([]string, error) {
|
||||
}
|
||||
return out, lister.Err()
|
||||
}
|
||||
|
||||
// ConsumerNames is every durable consumer on a stream; none for a stream that does not exist yet.
|
||||
func (j *JetStream) ConsumerNames(stream string) ([]string, error) {
|
||||
if _, err := j.js.StreamInfo(stream); errors.Is(err, nats.ErrStreamNotFound) {
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
for name := range j.js.ConsumerNames(stream) {
|
||||
out = append(out, name)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RemoveConsumer deletes one durable consumer (novox/hq ADR 0363 §6): one the composition no longer asks for.
|
||||
func (j *JetStream) RemoveConsumer(stream, name string) error {
|
||||
if err := j.js.DeleteConsumer(stream, name); err != nil && !errors.Is(err, nats.ErrConsumerNotFound) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -172,7 +172,9 @@ func TestRaisingLogsReportsWhatNothingDeclares(t *testing.T) {
|
||||
// **No path of the mesh deletes or purges a log or a bucket, or recreates one** (novox/hq ADR 0297 §2,
|
||||
// ADR 0201 §15–16): no code of this repository outside its tests calls the client's stream or bucket
|
||||
// delete, or purges a stream, but for the controller lease's own stream, which purges its own history
|
||||
// below a sequence (internal/lease). A new call is a decision, not a refactor.
|
||||
// below a sequence (internal/lease), and the controller's record mirrors, which purge the key of a record
|
||||
// the store no longer holds (internal/recordmirror; novox/hq ADR 0289, design 54 §2). A new call is a decision,
|
||||
// not a refactor.
|
||||
func TestNoPathDeletesALogOrABucket(t *testing.T) {
|
||||
removers := map[string]bool{"DeleteStream": true, "DeleteKeyValue": true, "PurgeStream": true,
|
||||
"DeleteObjectStore": true}
|
||||
@@ -197,7 +199,9 @@ func TestNoPathDeletesALogOrABucket(t *testing.T) {
|
||||
return true
|
||||
}
|
||||
name := sel.Sel.Name
|
||||
if removers[name] || (name == "Purge" && !strings.Contains(filepath.ToSlash(path), "internal/lease/")) {
|
||||
decided := strings.Contains(filepath.ToSlash(path), "internal/lease/") ||
|
||||
strings.Contains(filepath.ToSlash(path), "internal/recordmirror/")
|
||||
if removers[name] || (name == "Purge" && !decided) {
|
||||
found = append(found, path+": "+name)
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -61,6 +61,15 @@ type Membership struct {
|
||||
// over every module on the machine, so one module's code reaching another's name or kind through
|
||||
// the runtime is the runtime's to refuse.
|
||||
SeatTraffic *SeatTraffic `json:"seat-traffic,omitempty"`
|
||||
// Streams are this module's own streams (novox/hq ADR 0363 §2): an event one of them carries is published
|
||||
// on that stream's subject, never on Emits. Hears are the module streams it hears, each through a durable
|
||||
// consumer on that stream the runtime binds beside the one on EVENTS (§8).
|
||||
Streams []StreamIssued `json:"streams,omitempty"`
|
||||
Hears []HeardIssued `json:"hears,omitempty"`
|
||||
// Waits are the event families a session may wait on through the mesh MCP server, the tool runner's
|
||||
// alone (novox/hq ADR 0367 §2): the subjects its account is granted, under the names a session asks for.
|
||||
// The tool runner refuses any family not listed here.
|
||||
Waits []WaitIssued `json:"waits,omitempty"`
|
||||
}
|
||||
|
||||
// Served is one address a tool is answered on.
|
||||
@@ -126,6 +135,9 @@ func MembershipFor(node string, d Declared, where Placements) Membership {
|
||||
}
|
||||
m.State = stateIssuedFor(d, node)
|
||||
m.Logs = logsIssuedFor(d)
|
||||
m.Streams = streamsIssuedFor(d)
|
||||
m.Hears = HeardIssuedFor(node, d)
|
||||
m.Waits = waitsIssuedFor(d)
|
||||
t := SeatTrafficOf(d.Module, d.Holds, d.Uses, d.Watches)
|
||||
for _, s := range append(append([]Seat{}, d.Uses...), d.Watches...) {
|
||||
if !s.Kinded {
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A module's own stream on the bus (novox/hq ADR 0363).
|
||||
//
|
||||
// A module declares a stream as it declares a log: named locally, created by the controller from the
|
||||
// catalogue on every raise and every send, never removed by the mesh. Its events are published on
|
||||
// `mesh.stream.<module>.event.<event>`, a subject space the shared EVENTS stream (`mesh.mod.*.event.>`)
|
||||
// does not take — the bus refuses two streams claiming one subject, so a module's stream cannot share
|
||||
// EVENTS' — and a consumer hears it through a durable consumer of its own on that stream, named as on
|
||||
// EVENTS. Pure; the controller's raise asserts what is composed here.
|
||||
|
||||
// moduleStreamPrefix starts every subject a module's own stream takes.
|
||||
const moduleStreamPrefix = "mesh.stream."
|
||||
|
||||
// ModuleStreamSubject is where a module's event on its own stream lands: `mesh.stream.<module>.event.<event>`.
|
||||
// The token before `.event.` is the emitter, as on EVENTS, so a runtime reads the same key from it.
|
||||
func ModuleStreamSubject(module, event string) string {
|
||||
return moduleStreamPrefix + module + ".event." + event
|
||||
}
|
||||
|
||||
// OnAModuleStream says a subject is one a module's own stream takes.
|
||||
func OnAModuleStream(subject string) bool { return strings.HasPrefix(subject, moduleStreamPrefix) }
|
||||
|
||||
// WirePattern is a local event pattern as the bus spells it: `**` is `>`.
|
||||
func WirePattern(local string) string {
|
||||
parts := strings.Split(local, ".")
|
||||
for i, p := range parts {
|
||||
if p == "**" {
|
||||
parts[i] = ">"
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ".")
|
||||
}
|
||||
|
||||
// A ModuleStream is one module's declared stream as the bus holds it.
|
||||
type ModuleStream struct {
|
||||
Module string
|
||||
// Name is its name on the bus, `MAIL`.
|
||||
Name string
|
||||
// Events are the events it carries, as the bus spells them (WirePattern).
|
||||
Events []string
|
||||
MaxAgeDays int
|
||||
MaxMiB int
|
||||
// Restricted says a trusted config value of the module grants who hears it (ADR 0363 §4).
|
||||
Restricted bool
|
||||
}
|
||||
|
||||
// Subjects are the subjects the stream takes.
|
||||
func (s ModuleStream) Subjects() []string {
|
||||
var out []string
|
||||
for _, e := range s.Events {
|
||||
out = append(out, ModuleStreamSubject(s.Module, e))
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Stream is the stream the controller asserts: kept by limits, its age and size the module's, dropping
|
||||
// its oldest when full — a consumer that was away catches up within the age, and the module's own record
|
||||
// answers for anything older.
|
||||
func (s ModuleStream) Stream() Stream {
|
||||
why := fmt.Sprintf("%s's own stream (novox/hq ADR 0363): its events, kept %d days and at most %d MiB so a "+
|
||||
"consumer that was away catches up", s.Module, s.MaxAgeDays, s.MaxMiB)
|
||||
if s.Restricted {
|
||||
why += "; heard only by the consumers its trusted config value grants"
|
||||
}
|
||||
return Stream{
|
||||
Name: s.Name,
|
||||
Subjects: s.Subjects(),
|
||||
Retention: RetentionLimits,
|
||||
MaxAge: s.MaxAgeDays * 24 * 60 * 60,
|
||||
MaxBytes: int64(s.MaxMiB) * 1024 * 1024,
|
||||
Why: why,
|
||||
// Said on the bus, so a dead letter kept from it ages out with it (novox/hq ADR 0376 §3).
|
||||
Restricted: s.Restricted,
|
||||
}
|
||||
}
|
||||
|
||||
// ModuleStreams is every declared module stream as the controller asserts it, in name order.
|
||||
func ModuleStreams(declared []ModuleStream) []Stream {
|
||||
sorted := append([]ModuleStream(nil), declared...)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
|
||||
out := make([]Stream, 0, len(sorted))
|
||||
for _, s := range sorted {
|
||||
out = append(out, s.Stream())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Heard is a module's stream another module hears, and the subjects it hears of it: its `consumes`
|
||||
// within the stream and, for a restricted stream, within what the provider's trusted config value grants it
|
||||
// (ADR 0363 §5). Composed only with at least one subject.
|
||||
type Heard struct {
|
||||
Stream string
|
||||
Emitter string
|
||||
Filters []string
|
||||
// Restricted says the stream is restricted by its provider's trusted config value (ADR 0363 §4): then it
|
||||
// is heard only by a module on an account of its own (ADR 0376 §1, ComposedHears).
|
||||
Restricted bool
|
||||
}
|
||||
|
||||
// ComposedHears is what of a module's hearing the controller composes (novox/hq ADR 0376 §1): every stream it
|
||||
// hears, but a restricted one only for a module that runs in a runtime of its own on a bus account of its own
|
||||
// (`runs-as`, ADR 0259). **The tool runner never hears a restricted stream**: a module it carries reaches the bus
|
||||
// through the tool runner's account, which every agent session on that machine reaches the bus as too, so a
|
||||
// consumer, its grants or its bind word composed there would hand the stream to any session. A module that
|
||||
// declares no `runs-as` and keeps its own container credential gets none either: only `runs-as` is an account
|
||||
// the mesh keeps from agents.
|
||||
func ComposedHears(d Declared) []Heard {
|
||||
if d.RunsAs != "" {
|
||||
return d.Hears
|
||||
}
|
||||
var out []Heard
|
||||
for _, h := range d.Hears {
|
||||
if !h.Restricted {
|
||||
out = append(out, h)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Withheld is a restricted stream a grant gives a module that the controller composes nothing for, because the
|
||||
// module has no account of its own (novox/hq ADR 0376 §2): said in `doctor` and in the controller's log, never
|
||||
// silent.
|
||||
type Withheld struct {
|
||||
Node, Module, Stream, Emitter string
|
||||
}
|
||||
|
||||
// Said is a withheld hearing in the operator's words: the module, the stream and the remedy.
|
||||
func (w Withheld) Said() string {
|
||||
return fmt.Sprintf("%s on %s is granted part of %s's restricted stream %s, and hears none of it: it is carried "+
|
||||
"by the machine's tool runner, whose bus account every agent session there shares, so nothing is composed "+
|
||||
"for it. Declare runs-as in %s's manifest, so it runs as an account of its own (novox/hq ADR 0376)",
|
||||
w.Module, w.Node, w.Emitter, w.Stream, w.Module)
|
||||
}
|
||||
|
||||
// WithheldOf is every granted hearing of a restricted stream composed for nothing, by machine and module.
|
||||
func WithheldOf(r Records) []Withheld {
|
||||
var out []Withheld
|
||||
for _, node := range sortedCopy(r.Nodes) {
|
||||
for _, d := range r.Assigned[node] {
|
||||
if d.RunsAs != "" {
|
||||
continue
|
||||
}
|
||||
for _, h := range d.Hears {
|
||||
if h.Restricted && len(h.Filters) > 0 {
|
||||
out = append(out, Withheld{Node: node, Module: d.Module, Stream: h.Stream, Emitter: h.Emitter})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// heardConsumer is the durable consumer a module hears a module's stream through: named as its consumer
|
||||
// on EVENTS, on that stream, with the subjects it was granted as its filters.
|
||||
func heardConsumer(p Principal, h Heard) Consumer {
|
||||
filters := append([]string(nil), h.Filters...)
|
||||
sort.Strings(filters)
|
||||
return Consumer{
|
||||
Name: consumerDurable(p),
|
||||
Stream: h.Stream,
|
||||
Filters: filters,
|
||||
AckWaitSeconds: 30,
|
||||
MaxDeliver: 5,
|
||||
Why: "what " + p.Module + " consumes of " + h.Emitter + "'s stream " + h.Stream + " and is granted " +
|
||||
"(novox/hq ADR 0363); after max-deliver it gives an event up, and the controller keeps it in " +
|
||||
"DEAD_LETTERS until a person delivers it again or drops it",
|
||||
}
|
||||
}
|
||||
|
||||
// heardGrants are what a principal publishes to read and acknowledge the consumers it hears module streams
|
||||
// through: asking about each, pulling from it, acknowledging it — its own name on each stream, no other's.
|
||||
func heardGrants(p Principal, hears []Heard) []string {
|
||||
var out []string
|
||||
durable := consumerDurable(p)
|
||||
for _, h := range hears {
|
||||
if !safeSubject.MatchString(h.Stream) || len(h.Filters) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out,
|
||||
"$JS.API.CONSUMER.INFO."+h.Stream+"."+durable,
|
||||
"$JS.API.CONSUMER.MSG.NEXT."+h.Stream+"."+durable,
|
||||
"$JS.ACK."+h.Stream+"."+durable+".>")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ownStreamGrants are the subjects a module publishes its own streams' events on, and no other's.
|
||||
func ownStreamGrants(module string, streams []ModuleStream) []string {
|
||||
var out []string
|
||||
for _, s := range streams {
|
||||
if s.Module != module {
|
||||
continue
|
||||
}
|
||||
out = append(out, s.Subjects()...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// StreamIssued is one of a module's own streams as its membership names it (ADR 0363 §2): the events it
|
||||
// carries, as the bus spells them, and the subject an event of them is published on, `{event}` the event.
|
||||
type StreamIssued struct {
|
||||
Stream string `json:"stream"`
|
||||
Events []string `json:"events"`
|
||||
Subject string `json:"subject"`
|
||||
}
|
||||
|
||||
// HeardIssued is one module stream an assignment hears, as its membership names it (ADR 0363 §8): the
|
||||
// stream and the durable consumer on it the runtime binds.
|
||||
type HeardIssued struct {
|
||||
Stream string `json:"stream"`
|
||||
Consumer string `json:"consumer"`
|
||||
}
|
||||
|
||||
// streamsIssuedFor is every stream of its own a module's membership names.
|
||||
func streamsIssuedFor(d Declared) []StreamIssued {
|
||||
var out []StreamIssued
|
||||
for _, s := range d.Streams {
|
||||
if s.Module != d.Module || !safeSubject.MatchString(d.Module) {
|
||||
continue
|
||||
}
|
||||
out = append(out, StreamIssued{Stream: s.Name, Events: append([]string(nil), s.Events...),
|
||||
Subject: ModuleStreamSubject(d.Module, "{event}")})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// HeardIssuedFor is every module stream an assignment hears, as its membership names it.
|
||||
func HeardIssuedFor(node string, d Declared) []HeardIssued {
|
||||
var out []HeardIssued
|
||||
// A restricted stream's bind word only for a module of its own account (novox/hq ADR 0376 §1).
|
||||
for _, h := range ComposedHears(d) {
|
||||
if len(h.Filters) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, HeardIssued{Stream: h.Stream, Consumer: node + "_" + d.Module})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Stream < out[j].Stream })
|
||||
return out
|
||||
}
|
||||
|
||||
// Pruner is the part of a JetStream connection that removes a module's consumer on a module's stream it
|
||||
// may no longer hear (ADR 0363 §6). Optional: what only records what it is asked to assert has none.
|
||||
type Pruner interface {
|
||||
ConsumerNames(stream string) ([]string, error)
|
||||
RemoveConsumer(stream, name string) error
|
||||
}
|
||||
|
||||
// PruneHeard removes, from each module stream, every durable consumer the composition no longer asks for,
|
||||
// and answers what it removed as `<stream>/<consumer>`. A consumer that loses its grant loses what it reads
|
||||
// through, whatever its runtime still holds; a stream that does not exist yet has nothing to remove.
|
||||
func PruneHeard(p Pruner, streams []ModuleStream, wanted []Consumer) ([]string, error) {
|
||||
keep := map[string]bool{}
|
||||
for _, c := range wanted {
|
||||
keep[c.Stream+"/"+c.Name] = true
|
||||
}
|
||||
var removed []string
|
||||
for _, s := range streams {
|
||||
names, err := p.ConsumerNames(s.Name)
|
||||
if err != nil {
|
||||
return removed, fmt.Errorf("listing the consumers on %s: %w", s.Name, err)
|
||||
}
|
||||
for _, n := range names {
|
||||
if keep[s.Name+"/"+n] {
|
||||
continue
|
||||
}
|
||||
if err := p.RemoveConsumer(s.Name, n); err != nil {
|
||||
return removed, fmt.Errorf("removing %s's consumer on %s, which is no longer granted: %w", n, s.Name, err)
|
||||
}
|
||||
removed = append(removed, s.Name+"/"+n)
|
||||
}
|
||||
}
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
// EnsureHeard asserts a consumer on a module's stream (ADR 0363 §5, §6). **On a restricted stream, a consumer
|
||||
// the bus will not bring to match is removed**: a pull consumer delivers to whoever pulls it, so one left with
|
||||
// its wider filters after a narrowing was refused would keep handing over what the grant no longer names,
|
||||
// whatever the user list now says. The next assertion makes it again with the granted filters. The refusal is
|
||||
// returned either way, saying what was done, so the send says it and raises it.
|
||||
func EnsureHeard(r Raiser, c Consumer, restricted bool) error {
|
||||
err := r.EnsureConsumer(c)
|
||||
if err == nil || !restricted {
|
||||
return err
|
||||
}
|
||||
p, prunes := r.(Pruner)
|
||||
if !prunes {
|
||||
return fmt.Errorf("%w; it could not be removed here, and may still deliver more than it is granted", err)
|
||||
}
|
||||
if rerr := p.RemoveConsumer(c.Stream, c.Name); rerr != nil {
|
||||
return fmt.Errorf("%w; REMOVING IT FAILED TOO, so it may still deliver more than it is granted: %v", err, rerr)
|
||||
}
|
||||
return fmt.Errorf("%w; REMOVED, so it delivers nothing it is no longer granted: the next assertion makes it "+
|
||||
"again with its granted filters (novox/hq ADR 0363 §6)", err)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/testbus"
|
||||
)
|
||||
|
||||
// On a real bus: a module's stream is made beside EVENTS without a clash, keeps what the module publishes on
|
||||
// its own subjects, a granted consumer hears only its part, a narrowed grant narrows it, and a withdrawn one
|
||||
// is removed (novox/hq ADR 0363).
|
||||
//
|
||||
// go test ./internal/broker/ -run TestAModuleStreamOnARealBus
|
||||
func TestAModuleStreamOnARealBus(t *testing.T) {
|
||||
js, err := Dial(testbus.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer js.Close()
|
||||
for _, s := range []Stream{MeshStreams()[3], mailStream.Stream()} {
|
||||
_ = js.js.DeleteStream(s.Name)
|
||||
if err := js.EnsureStream(s); err != nil {
|
||||
t.Fatalf("%s: %v", s.Name, err)
|
||||
}
|
||||
defer func(name string) { _ = js.js.DeleteStream(name) }(s.Name)
|
||||
}
|
||||
for _, account := range []string{"mesh", "work"} {
|
||||
if _, err := js.js.Publish(ModuleStreamSubject("mesh-mail", "mail."+account+".message.added"), []byte("{}")); err != nil {
|
||||
t.Fatalf("publishing %s's mail: %v", account, err)
|
||||
}
|
||||
}
|
||||
if info, _ := js.js.StreamInfo(EventsStream); info.State.Msgs != 0 {
|
||||
t.Fatalf("EVENTS kept %d of the module stream's events", info.State.Msgs)
|
||||
}
|
||||
|
||||
reader := Principal{Kind: KindModule, Node: "ace", Module: "reader", Hears: []Heard{mailHeard}}
|
||||
c := heardConsumer(reader, mailHeard)
|
||||
if err := js.EnsureConsumer(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if info, _ := js.js.ConsumerInfo("MAIL", c.Name); info.NumPending != 1 {
|
||||
t.Fatalf("the reader granted `mesh` has %d pending, want 1", info.NumPending)
|
||||
}
|
||||
narrowed := c
|
||||
narrowed.Filters = []string{"mesh.stream.mesh-mail.event.mail.mesh.message.changed"}
|
||||
if err := EnsureHeard(js, narrowed, true); err != nil {
|
||||
t.Fatalf("narrowing the grant: %v", err)
|
||||
}
|
||||
if info, _ := js.js.ConsumerInfo("MAIL", c.Name); info.Config.FilterSubject != narrowed.Filters[0] || len(info.Config.FilterSubjects) != 0 {
|
||||
t.Fatalf("narrowed to %q %v, want only %s", info.Config.FilterSubject, info.Config.FilterSubjects, narrowed.Filters[0])
|
||||
}
|
||||
if info, _ := js.js.ConsumerInfo("MAIL", c.Name); info.NumPending != 0 {
|
||||
t.Fatalf("after narrowing, %d of the reader's former grant are still pending", info.NumPending)
|
||||
}
|
||||
removed, err := PruneHeard(js, []ModuleStream{mailStream}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Equal(removed, []string{"MAIL/ace_reader"}) {
|
||||
t.Fatalf("removed %v", removed)
|
||||
}
|
||||
if names, _ := js.ConsumerNames("MAIL"); len(names) != 0 {
|
||||
t.Fatalf("still on MAIL: %v", names)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mailStream is mesh-mail's own stream as the bus holds it (novox/hq ADR 0363).
|
||||
var mailStream = ModuleStream{Module: "mesh-mail", Name: "MAIL",
|
||||
Events: []string{"mail.*.message.added", "mail.*.message.changed", "mail.*.message.removed"},
|
||||
MaxAgeDays: 7, MaxMiB: 1024, Restricted: true}
|
||||
|
||||
// mailHeard is what a reader granted the account `mesh` hears of it.
|
||||
var mailHeard = Heard{Stream: "MAIL", Emitter: "mesh-mail", Restricted: true, Filters: []string{
|
||||
"mesh.stream.mesh-mail.event.mail.mesh.message.added", "mesh.stream.mesh-mail.event.mail.mesh.message.changed"}}
|
||||
|
||||
// newsStream is a module's stream without a grant: heard by whoever asks (ADR 0363 §5), the tool runner included.
|
||||
var newsStream = ModuleStream{Module: "news", Name: "NEWS", Events: []string{"story.**"}, MaxAgeDays: 7, MaxMiB: 16}
|
||||
|
||||
var newsHeard = Heard{Stream: "NEWS", Emitter: "news", Filters: []string{"mesh.stream.news.event.story.>"}}
|
||||
|
||||
// A module's stream is its own: subjects EVENTS does not claim, its age and size, kept by limits (ADR 0363 §1, §2).
|
||||
func TestAModuleStreamTakesSubjectsEventsDoesNot(t *testing.T) {
|
||||
s := mailStream.Stream()
|
||||
if s.Name != "MAIL" || s.Retention != RetentionLimits || s.MaxAge != 7*24*60*60 || s.MaxBytes != 1024<<20 || s.DiscardNew {
|
||||
t.Fatalf("composed as %+v", s)
|
||||
}
|
||||
want := []string{"mesh.stream.mesh-mail.event.mail.*.message.added", "mesh.stream.mesh-mail.event.mail.*.message.changed",
|
||||
"mesh.stream.mesh-mail.event.mail.*.message.removed"}
|
||||
if !reflect.DeepEqual(s.Subjects, want) {
|
||||
t.Fatalf("subjects %v", s.Subjects)
|
||||
}
|
||||
for _, own := range MeshStreams() {
|
||||
for _, a := range own.Subjects {
|
||||
for _, b := range s.Subjects {
|
||||
if SubjectsOverlap(a, b) {
|
||||
t.Fatalf("%s claims %s, which overlaps %s of %s: the bus would refuse the stream", s.Name, b, a, own.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if WirePattern("mail.**") != "mail.>" {
|
||||
t.Fatal("`**` is spelled `>` on the wire")
|
||||
}
|
||||
}
|
||||
|
||||
// The provider publishes its stream's events on their subjects and nowhere on EVENTS for them; a consumer
|
||||
// granted a part hears it through its own consumer on the stream, and one not granted has nothing there
|
||||
// (ADR 0363 §2, §5).
|
||||
func TestTheProviderPublishesItsStreamAndOnlyAGrantedConsumerHearsIt(t *testing.T) {
|
||||
provider, err := PermissionsFor(Principal{Kind: KindModule, Node: "novox", Module: "mesh-mail",
|
||||
Streams: []ModuleStream{mailStream}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !MayPublish(provider, "mesh.stream.mesh-mail.event.mail.work.message.added") {
|
||||
t.Fatal("the provider may not publish its own stream's event")
|
||||
}
|
||||
if MayPublish(provider, "mesh.mod.mesh-mail.event.mail.work.message.added") {
|
||||
t.Fatal("the provider may publish its stream's event on EVENTS")
|
||||
}
|
||||
if MayPublish(provider, "mesh.stream.other.event.mail.work.message.added") {
|
||||
t.Fatal("the provider may publish on another module's stream")
|
||||
}
|
||||
|
||||
reader := Principal{Kind: KindModule, Node: "ace", Module: "reader", Hears: []Heard{mailHeard}}
|
||||
perms, err := PermissionsFor(reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range []string{"$JS.API.CONSUMER.MSG.NEXT.MAIL.ace_reader", "$JS.API.CONSUMER.INFO.MAIL.ace_reader",
|
||||
"$JS.ACK.MAIL.ace_reader.1"} {
|
||||
if !MayPublish(perms, s) {
|
||||
t.Errorf("the granted reader may not publish %s", s)
|
||||
}
|
||||
}
|
||||
if !MaySubscribe(perms, "mesh.stream.mesh-mail.event.mail.mesh.message.added") ||
|
||||
MaySubscribe(perms, "mesh.stream.mesh-mail.event.mail.work.message.added") {
|
||||
t.Fatal("the reader's subscription is not what it was granted")
|
||||
}
|
||||
if MayPublish(perms, "$JS.API.CONSUMER.MSG.NEXT.MAIL.ace_other") {
|
||||
t.Fatal("the reader may pull another's consumer")
|
||||
}
|
||||
|
||||
stranger, err := PermissionsFor(Principal{Kind: KindModule, Node: "ace", Module: "stranger",
|
||||
Consumes: []string{"billing.paid"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if MayPublish(stranger, "$JS.API.CONSUMER.MSG.NEXT.MAIL.ace_stranger") ||
|
||||
MaySubscribe(stranger, "mesh.stream.mesh-mail.event.mail.mesh.message.added") {
|
||||
t.Fatal("a module not granted reaches the stream")
|
||||
}
|
||||
}
|
||||
|
||||
// A granted consumer of its own account has a durable consumer on the stream, with its granted subjects as
|
||||
// filters, and an EVENTS consumer that hears what it gives up on delivered again (ADR 0363 §5, §7); a module of its
|
||||
// own account is no module the runtime carries, so the runtime reads neither (ADR 0376 §1).
|
||||
func TestAGrantedConsumerHasAConsumerOnTheStream(t *testing.T) {
|
||||
records := Records{Nodes: []string{"ace", "novox"}, Assigned: map[string][]Declared{
|
||||
"novox": {{Module: "mesh-mail", Streams: []ModuleStream{mailStream}}},
|
||||
"ace": {{Module: RuntimeModule}, {Module: "reader", RunsAs: "reader", Hears: []Heard{mailHeard}, HearsAStream: true},
|
||||
{Module: "stranger", HearsAStream: true}},
|
||||
}}
|
||||
users, err := Users(records)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var onMail, onEvents []Consumer
|
||||
for _, c := range ConsumersOf(users) {
|
||||
switch c.Consumer.Stream {
|
||||
case "MAIL":
|
||||
onMail = append(onMail, c.Consumer)
|
||||
case EventsStream:
|
||||
onEvents = append(onEvents, c.Consumer)
|
||||
}
|
||||
}
|
||||
if len(onMail) != 1 || onMail[0].Name != "ace_reader" || !slices.Equal(onMail[0].Filters, mailHeard.Filters) {
|
||||
t.Fatalf("on MAIL: %+v", onMail)
|
||||
}
|
||||
if len(onEvents) != 1 || onEvents[0].Name != "ace_reader" || !slices.Equal(onEvents[0].Filters, []string{AgainFilter("ace_reader")}) {
|
||||
t.Fatalf("on EVENTS: %+v", onEvents)
|
||||
}
|
||||
for _, u := range users {
|
||||
if u.Node != "ace" || (u.Kind != KindNodeTools && u.Module != "reader") {
|
||||
continue
|
||||
}
|
||||
perms, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reads := MayPublish(perms, "$JS.API.CONSUMER.MSG.NEXT.MAIL.ace_reader") && MayPublish(perms, "$JS.ACK.MAIL.ace_reader.1")
|
||||
if u.Kind == KindNodeTools && reads {
|
||||
t.Fatal("the runtime may read the consumer on MAIL of a module of its own account")
|
||||
}
|
||||
if u.Kind == KindModule && !reads {
|
||||
t.Fatal("the reader's own account may not read its consumer on MAIL")
|
||||
}
|
||||
if MayPublish(perms, "$JS.API.CONSUMER.MSG.NEXT.MAIL.ace_stranger") {
|
||||
t.Fatal("a consumer no module is granted may be read")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **The tool runner never hears a restricted stream** (ADR 0376 §1, §2): a module it carries, granted part of
|
||||
// MAIL, gets no consumer on MAIL, the tool runner's account no grant to ask about, pull or acknowledge one there,
|
||||
// and its membership no word to bind one; a module of its own account granted the same part gets all three, on
|
||||
// its own account. The grant that composes nothing is said. A stream without a grant is heard through the tool
|
||||
// runner as before.
|
||||
func TestTheToolRunnerNeverHearsARestrictedStream(t *testing.T) {
|
||||
records := Records{Nodes: []string{"ace", "novox"}, Assigned: map[string][]Declared{
|
||||
"novox": {{Module: "mesh-mail", Streams: []ModuleStream{mailStream}}, {Module: "news", Streams: []ModuleStream{newsStream}}},
|
||||
"ace": {{Module: RuntimeModule},
|
||||
{Module: "carried", Hears: []Heard{mailHeard, newsHeard}, HearsAStream: true},
|
||||
{Module: "mailer", RunsAs: "mailer", Hears: []Heard{mailHeard}, HearsAStream: true}},
|
||||
}}
|
||||
users, err := Users(records)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var onMail, onNews []string
|
||||
for _, c := range ConsumersOf(users) {
|
||||
switch c.Consumer.Stream {
|
||||
case "MAIL":
|
||||
onMail = append(onMail, c.Consumer.Name)
|
||||
case "NEWS":
|
||||
onNews = append(onNews, c.Consumer.Name)
|
||||
}
|
||||
}
|
||||
if !slices.Equal(onMail, []string{"ace_mailer"}) {
|
||||
t.Fatalf("consumers on MAIL: %v, want only the mailer's", onMail)
|
||||
}
|
||||
if !slices.Equal(onNews, []string{"ace_carried"}) {
|
||||
t.Fatalf("consumers on NEWS: %v, want the carried module's as before", onNews)
|
||||
}
|
||||
for _, u := range users {
|
||||
if u.Node != "ace" {
|
||||
continue
|
||||
}
|
||||
perms, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
switch {
|
||||
case u.Kind == KindNodeTools:
|
||||
for _, s := range perms.Publish {
|
||||
if strings.Contains(s, ".MAIL.") {
|
||||
t.Errorf("the tool runner's account may publish %s", s)
|
||||
}
|
||||
}
|
||||
for _, s := range perms.Subscribe {
|
||||
if strings.HasPrefix(s, "mesh.stream.mesh-mail.") {
|
||||
t.Errorf("the tool runner's account may subscribe %s", s)
|
||||
}
|
||||
}
|
||||
if !MayPublish(perms, "$JS.API.CONSUMER.MSG.NEXT.NEWS.ace_carried") {
|
||||
t.Error("the tool runner may no longer read the carried module's consumer on a stream without a grant")
|
||||
}
|
||||
case u.Kind == KindModule && u.Module == "carried":
|
||||
if MayPublish(perms, "$JS.API.CONSUMER.MSG.NEXT.MAIL.ace_carried") {
|
||||
t.Error("a module without an account of its own may pull a consumer on MAIL")
|
||||
}
|
||||
case u.Kind == KindModule && u.Module == "mailer":
|
||||
for _, s := range []string{"$JS.API.CONSUMER.INFO.MAIL.ace_mailer", "$JS.API.CONSUMER.MSG.NEXT.MAIL.ace_mailer",
|
||||
"$JS.ACK.MAIL.ace_mailer.1"} {
|
||||
if !MayPublish(perms, s) {
|
||||
t.Errorf("the mailer's own account may not publish %s", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
where := Placements{Nodes: map[string][]string{}}
|
||||
carried := MembershipFor("ace", records.Assigned["ace"][1], where)
|
||||
if !reflect.DeepEqual(carried.Hears, []HeardIssued{{Stream: "NEWS", Consumer: "ace_carried"}}) {
|
||||
t.Fatalf("the carried module is told to bind %+v", carried.Hears)
|
||||
}
|
||||
mailer := MembershipFor("ace", records.Assigned["ace"][2], where)
|
||||
if !reflect.DeepEqual(mailer.Hears, []HeardIssued{{Stream: "MAIL", Consumer: "ace_mailer"}}) {
|
||||
t.Fatalf("the mailer is told to bind %+v", mailer.Hears)
|
||||
}
|
||||
withheld := WithheldOf(records)
|
||||
if len(withheld) != 1 || withheld[0] != (Withheld{Node: "ace", Module: "carried", Stream: "MAIL", Emitter: "mesh-mail"}) {
|
||||
t.Fatalf("withheld %+v", withheld)
|
||||
}
|
||||
if said := withheld[0].Said(); !strings.Contains(said, "carried on ace") || !strings.Contains(said, "MAIL") ||
|
||||
!strings.Contains(said, "runs-as") {
|
||||
t.Fatalf("said %q", said)
|
||||
}
|
||||
}
|
||||
|
||||
// The membership says where a module's stream events go and which consumers on module streams to bind
|
||||
// (ADR 0363 §2, §8).
|
||||
func TestTheMembershipNamesTheStreamAndWhatIsHeard(t *testing.T) {
|
||||
where := Placements{Nodes: map[string][]string{}}
|
||||
provider := MembershipFor("novox", Declared{Module: "mesh-mail", Streams: []ModuleStream{mailStream}}, where)
|
||||
if len(provider.Streams) != 1 || provider.Streams[0].Stream != "MAIL" ||
|
||||
provider.Streams[0].Subject != "mesh.stream.mesh-mail.event.{event}" ||
|
||||
!slices.Equal(provider.Streams[0].Events, mailStream.Events) {
|
||||
t.Fatalf("the provider's membership: %+v", provider.Streams)
|
||||
}
|
||||
reader := MembershipFor("ace", Declared{Module: "reader", RunsAs: "reader", Hears: []Heard{mailHeard}}, where)
|
||||
if !reflect.DeepEqual(reader.Hears, []HeardIssued{{Stream: "MAIL", Consumer: "ace_reader"}}) {
|
||||
t.Fatalf("the reader's membership: %+v", reader.Hears)
|
||||
}
|
||||
if none := MembershipFor("ace", Declared{Module: "stranger", HearsAStream: true}, where); none.Hears != nil {
|
||||
t.Fatalf("a module not granted is told to bind %+v", none.Hears)
|
||||
}
|
||||
}
|
||||
|
||||
type fakePruner struct {
|
||||
on map[string][]string
|
||||
removed []string
|
||||
}
|
||||
|
||||
func (f *fakePruner) ConsumerNames(stream string) ([]string, error) { return f.on[stream], nil }
|
||||
func (f *fakePruner) RemoveConsumer(stream, name string) error {
|
||||
f.removed = append(f.removed, stream+"/"+name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// A consumer that lost its grant has its consumer on the stream removed by the next assertion (ADR 0363 §6).
|
||||
func TestAConsumerThatLostItsGrantIsRemoved(t *testing.T) {
|
||||
f := &fakePruner{on: map[string][]string{"MAIL": {"ace_reader", "ace_former"}}}
|
||||
wanted := []Consumer{{Name: "ace_reader", Stream: "MAIL"}, {Name: "ace_former", Stream: EventsStream}}
|
||||
removed, err := PruneHeard(f, []ModuleStream{mailStream}, wanted)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Equal(removed, []string{"MAIL/ace_former"}) || !slices.Equal(f.removed, removed) {
|
||||
t.Fatalf("removed %v", removed)
|
||||
}
|
||||
}
|
||||
|
||||
type refusingRaiser struct {
|
||||
fakePruner
|
||||
refuse bool
|
||||
}
|
||||
|
||||
func (r *refusingRaiser) EnsureStream(Stream) error { return nil }
|
||||
func (r *refusingRaiser) EnsureConsumer(Consumer) error {
|
||||
if r.refuse {
|
||||
return errors.New("consumer update refused")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// A narrowing the bus refuses on a restricted stream removes the consumer, so its wider filters deliver nothing
|
||||
// more; on an unrestricted stream the refusal is only said (review of PR 338, ADR 0363 §6).
|
||||
func TestARefusedNarrowingOnARestrictedStreamRemovesTheConsumer(t *testing.T) {
|
||||
c := Consumer{Name: "ace_reader", Stream: "MAIL", Filters: mailHeard.Filters[:1]}
|
||||
r := &refusingRaiser{refuse: true}
|
||||
err := EnsureHeard(r, c, true)
|
||||
if err == nil || !strings.Contains(err.Error(), "REMOVED") || !slices.Equal(r.removed, []string{"MAIL/ace_reader"}) {
|
||||
t.Fatalf("restricted: %v, removed %v", err, r.removed)
|
||||
}
|
||||
r = &refusingRaiser{refuse: true}
|
||||
if err := EnsureHeard(r, c, false); err == nil || len(r.removed) != 0 {
|
||||
t.Fatalf("unrestricted: %v, removed %v", err, r.removed)
|
||||
}
|
||||
r = &refusingRaiser{}
|
||||
if err := EnsureHeard(r, c, true); err != nil || len(r.removed) != 0 {
|
||||
t.Fatalf("accepted: %v, removed %v", err, r.removed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The move of the secrets into the vault (design 57's V3, issue 676): each machine's node-engine may ask the
|
||||
// vault's move-in in its own name — the subject that names it, and no other machine's — once the vault's holder
|
||||
// serves it; it hears the controller's ask to move a value on its own subject; and the controller may copy its
|
||||
// record of asks with the vault's move-asks. Composed from the records: a vault that serves neither grants nothing.
|
||||
func TestANodeEngineMayMoveItsSecretsIntoTheVaultInItsOwnName(t *testing.T) {
|
||||
vault := Declared{Module: "mesh-vault", Holds: []Seat{{Name: "mesh-vault",
|
||||
Serves: []string{"get", "accept", "secret-ask", "issued", "rotate", "move-in", "move-asks"}}}}
|
||||
users, err := Users(Records{Nodes: []string{"anchor", "shanks"}, Assigned: map[string][]Declared{"anchor": {vault}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var anchor, controller Principal
|
||||
for _, u := range users {
|
||||
switch {
|
||||
case u.Kind == KindNode && u.Node == "anchor":
|
||||
anchor = u
|
||||
case u.Kind == KindController:
|
||||
controller = u
|
||||
}
|
||||
}
|
||||
p, err := PermissionsFor(anchor)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !MayPublish(p, CalledSubject("mesh.seat.mesh-vault.tool.move-in", "node.anchor")) {
|
||||
t.Error("anchor's node-engine may not ask the vault's move-in in its own name")
|
||||
}
|
||||
for _, s := range []string{CalledSubject("mesh.seat.mesh-vault.tool.move-in", "node.shanks"),
|
||||
CalledSubject("mesh.seat.mesh-vault.tool.move-in", ControllerName),
|
||||
CalledSubject("mesh.seat.mesh-vault.tool.get", "node.anchor"),
|
||||
CalledSubject("mesh.seat.mesh-vault.tool.accept", "node.anchor"),
|
||||
"mesh.seat.mesh-vault.tool.move-in"} {
|
||||
if MayPublish(p, s) {
|
||||
t.Errorf("anchor's node-engine may publish %s", s)
|
||||
}
|
||||
}
|
||||
if !MaySubscribe(p, AskMoveSecretSubject("anchor")) || MaySubscribe(p, AskMoveSecretSubject("shanks")) {
|
||||
t.Error("anchor's node-engine does not hear its own move asks alone")
|
||||
}
|
||||
// The vault's holder hears the call naming the node-engine.
|
||||
held := false
|
||||
for _, u := range users {
|
||||
if u.Kind == KindModule && u.Module == "mesh-vault" {
|
||||
held = true
|
||||
h, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !MaySubscribe(h, CalledSubject("mesh.seat.mesh-vault.tool.move-in", "node.anchor")) {
|
||||
t.Error("the vault's holder does not hear move-in asked by a node-engine")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !held {
|
||||
t.Fatal("no principal holds the vault's seat")
|
||||
}
|
||||
c, err := PermissionsFor(controller)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !MayPublish(c, CalledSubject("mesh.seat.mesh-vault.tool.move-asks", ControllerName)) {
|
||||
t.Error("the controller may not copy its record of asks into the vault")
|
||||
}
|
||||
if MayPublish(c, CalledSubject("mesh.seat.mesh-vault.tool.move-in", ControllerName)) {
|
||||
t.Error("the controller may move a value in itself: it opens none")
|
||||
}
|
||||
if !MayPublish(c, AskMoveSecretSubject("anchor")) {
|
||||
t.Error("the controller may not ask a machine's node-engine to move a value")
|
||||
}
|
||||
// A vault that serves neither, the running one: no grant for either.
|
||||
older := Declared{Module: "mesh-vault", Holds: []Seat{{Name: "mesh-vault", Serves: []string{"get"}}}}
|
||||
users, _ = Users(Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{"anchor": {older}}})
|
||||
for _, u := range users {
|
||||
p, _ := PermissionsFor(u)
|
||||
for _, s := range p.Publish {
|
||||
if strings.Contains(s, "move-in") || strings.Contains(s, "move-asks") {
|
||||
t.Errorf("%s may publish %s while the vault serves neither", u.Username(), s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/testbus"
|
||||
)
|
||||
|
||||
// **DEAD_LETTERS and EVENTS take a message's own time to live once updated in place** (novox/hq ADR 0376 §3, §5),
|
||||
// on the pinned server: each made as it was before, without one, is brought to its definition by the controller's
|
||||
// assertion, keeps what it held, and removes a message once its time to live passes. A restricted module stream says
|
||||
// so in its metadata; one without a grant does not.
|
||||
//
|
||||
// go test ./internal/broker/ -run TestTheStreamsTakeAMessagesTimeToLive
|
||||
func TestTheStreamsTakeAMessagesTimeToLive(t *testing.T) {
|
||||
js, err := Dial(testbus.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer js.Close()
|
||||
for _, s := range MeshStreams() {
|
||||
if s.Name != EventsStream && s.Name != DeadLettersStream {
|
||||
if s.AllowMsgTTL {
|
||||
t.Errorf("%s takes a time to live of a message's own, which nothing asks of it", s.Name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !s.AllowMsgTTL {
|
||||
t.Fatalf("%s is not defined to take a message's time to live", s.Name)
|
||||
}
|
||||
_ = js.js.DeleteStream(s.Name)
|
||||
before := s
|
||||
before.AllowMsgTTL = false
|
||||
if err := js.EnsureStream(before); err != nil {
|
||||
t.Fatalf("%s as it was: %v", s.Name, err)
|
||||
}
|
||||
subject := DeadLetterSubject("EVENTS", "ace_reader")
|
||||
if s.Name == EventsStream {
|
||||
subject = "mesh.mod.gitea.event.pull.merged"
|
||||
}
|
||||
if _, err := js.js.Publish(subject, []byte("held")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := js.EnsureStream(s); err != nil {
|
||||
t.Fatalf("%s updated in place: %v", s.Name, err)
|
||||
}
|
||||
info, err := js.js.StreamInfo(s.Name)
|
||||
if err != nil || !info.Config.AllowMsgTTL || info.State.Msgs != 1 {
|
||||
t.Fatalf("%s after the update: %+v (%v)", s.Name, info, err)
|
||||
}
|
||||
msg := &nats.Msg{Subject: subject, Data: []byte("brief"), Header: nats.Header{}}
|
||||
msg.Header.Set(nats.MsgTTLHdr, "1s")
|
||||
ack, err := js.js.PublishMsg(msg)
|
||||
if err != nil {
|
||||
t.Fatalf("%s refused a message's time to live: %v", s.Name, err)
|
||||
}
|
||||
deadline := time.Now().Add(8 * time.Second)
|
||||
for {
|
||||
if _, err := js.js.GetMsg(s.Name, ack.Sequence); err != nil {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("%s still holds a message past its time to live", s.Name)
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
if info, _ := js.js.StreamInfo(s.Name); info.State.Msgs != 1 {
|
||||
t.Fatalf("%s holds %d, want only the message without an age", s.Name, info.State.Msgs)
|
||||
}
|
||||
_ = js.js.PurgeStream(s.Name)
|
||||
}
|
||||
if !mailStream.Stream().Restricted || newsStream.Stream().Restricted {
|
||||
t.Fatal("a module stream does not say whether it is restricted")
|
||||
}
|
||||
_ = js.js.DeleteStream("MAIL")
|
||||
if err := js.EnsureStream(mailStream.Stream()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = js.js.DeleteStream("MAIL") }()
|
||||
if info, _ := js.js.StreamInfo("MAIL"); info.Config.Metadata[RestrictedMetadata] != "true" {
|
||||
t.Fatalf("MAIL's metadata: %v", info.Config.Metadata)
|
||||
}
|
||||
}
|
||||
+194
-5
@@ -16,6 +16,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -42,6 +43,10 @@ const (
|
||||
// Its authority is the union of what the modules it carries would each have had for their
|
||||
// tools — and nothing of what they consume, because tools are what it runs, not reactions.
|
||||
KindNodeTools Kind = "node-tools"
|
||||
// KindProvisioner is a machine's provisioner client (novox/hq ADR 0349 clauses 6 and 11, design 57 P3): a
|
||||
// service of the module holding NodeProvisionerSeat, outside the tool runner, under an account of its own so
|
||||
// the vault can tell it from an agent. Its authority is provisionerGrants and nothing else.
|
||||
KindProvisioner Kind = "provisioner"
|
||||
)
|
||||
|
||||
// RuntimeModule is the module that IS the node's tool runtime (novox/hq ADR 0175). Where it is
|
||||
@@ -125,11 +130,24 @@ type Principal struct {
|
||||
|
||||
// AsksIssued are the tools the controller asks in its own name for a credential a provider's software
|
||||
// issued, each `<module>.<tool>` (novox/hq ADR 0305): on the subject that names its caller, so the tool
|
||||
// knows the controller asks. Composed from the records, so the installer's first user list — a controller
|
||||
// knows the controller asks. The vault's holder is granted the same tools in its own name (issue 723,
|
||||
// vaultCalls); the controller's grant goes with its path in R1 (issue 682). Composed from the records, so the installer's first user list — a controller
|
||||
// with no records — carries none, and moves with issue 464 like the rest of the controller's caller-named
|
||||
// grants.
|
||||
AsksIssued []string
|
||||
|
||||
// AsksTheVault are the vault's verbs the controller asks in its own name (VerbsTheControllerAsksTheVault,
|
||||
// issue 675), those the vault's holder serves: on the subject that names its caller, so the vault knows the
|
||||
// controller asks. Composed from the records like AsksIssued, so the installer's first user list carries none.
|
||||
AsksTheVault []string
|
||||
|
||||
// AsksTheDesk says this principal is the vault's holder, which asks the desks' secret prompt in its own name
|
||||
// (novox/hq ADR 0349 clause 9, issue 723): `node-launcher.secret` on any machine, on the subject that names
|
||||
// its caller, and nothing else of that seat. With AsksIssued on the same principal, the vault's whole reach
|
||||
// outside its own seat: it asks each issuing provider's tool in its own name (clause 10). The controller
|
||||
// keeps its own grant to both until R1 (issue 682), for the modules still filled the old way (clause 17).
|
||||
AsksTheDesk bool
|
||||
|
||||
// State is the local names of the state this principal's module keeps, and Reads the state of
|
||||
// others it reads as `<module>.<name>` (novox/hq ADR 0201): a bucket each, kept by the owner's
|
||||
// instances and read by whoever declares it.
|
||||
@@ -141,6 +159,10 @@ type Principal struct {
|
||||
KeyedReads []KeyedRead
|
||||
// Logs is the local names of the logs this principal's module keeps (novox/hq ADR 0297).
|
||||
Logs []string
|
||||
// Streams are the streams this principal's module owns, and Hears the module streams it hears with the
|
||||
// subjects it was granted (novox/hq ADR 0363).
|
||||
Streams []ModuleStream
|
||||
Hears []Heard
|
||||
|
||||
// SnapshotsTheBus is the bus's own module, the one holding mesh-broker (novox/hq ADR 0235). Its
|
||||
// whole authority is BusSnapshotGrants: it copies the streams for the night's backup and nothing
|
||||
@@ -156,6 +178,16 @@ type Principal struct {
|
||||
// `<module>.<tool>` (novox/hq ADR 0240, to-be 48 §3): asked of the instance on its own machine and
|
||||
// nowhere else.
|
||||
Checks []string
|
||||
// MovesIntoTheVault is a machine principal whose node-engine may ask the vault's `move-in` in its own name (design
|
||||
// 57's V3, issue 676): set while the vault's holder serves it, so the engine can hand in the values sealed to its
|
||||
// machine, which nothing else can open.
|
||||
MovesIntoTheVault bool
|
||||
// StatesItsKeyToTheVault is a machine principal whose node-engine may ask the vault's `node-key` in its own name
|
||||
// (issue 786, novox/hq ADR 0372): set while the vault's holder serves it. That one verb, and nothing else new.
|
||||
StatesItsKeyToTheVault bool
|
||||
// BoundBuckets are the providers' buckets of entries, `<module>_bound`, a provisioner client reads its own
|
||||
// machine's keys of (mesh-sdk go/provider, issue 679): every bucket named bound of a module the mesh places.
|
||||
BoundBuckets []string
|
||||
|
||||
// PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal
|
||||
// and never appears here: this file is written to a node's disk and read by a server, and a
|
||||
@@ -269,12 +301,63 @@ var VerbsTheControllerAsksTheDeliveryOwner = []SeatVerb{{Seat: "mesh-delivery",
|
||||
{Seat: "mesh-delivery", Verb: "close"}, {Seat: "mesh-delivery", Verb: "release"}, {Seat: "mesh-delivery", Verb: "stop"},
|
||||
{Seat: "mesh-delivery", Verb: "times"}, {Seat: "mesh-delivery", Verb: "groups"}}
|
||||
|
||||
// VerbsTheControllerAsksTheVault are the mesh-vault seat's verbs the controller calls in its own name (novox/hq
|
||||
// ADR 0349 clauses 8 to 10, design 57's V2, issue 675): it carries a value given at its terminal, typed at a desk
|
||||
// or issued by a provider's software into the vault, sealed to a key the vault made for the call, and asks a
|
||||
// rotation. The vault takes these from the controller alone, which it knows from the subject that names its
|
||||
// caller; granted as Principal.AsksTheVault says.
|
||||
//
|
||||
// And `move-asks` (design 57's V3, issue 676): the controller copies its record of asks at a desk, which holds no
|
||||
// value, into the vault's.
|
||||
//
|
||||
// And `get` (novox/hq ADR 0349 §16, issue 755): the controller asks for a core module's own credential sealed to its
|
||||
// machine's key, which it carries in that machine's declaration and cannot open (mesh-vault's core.go).
|
||||
var VerbsTheControllerAsksTheVault = []SeatVerb{{Seat: "mesh-vault", Verb: "accept"},
|
||||
{Seat: "mesh-vault", Verb: "secret-ask"}, {Seat: "mesh-vault", Verb: "issued"}, {Seat: "mesh-vault", Verb: "rotate"},
|
||||
{Seat: "mesh-vault", Verb: "move-asks"}, {Seat: "mesh-vault", Verb: "get"}}
|
||||
|
||||
// VaultMoveIn is the vault's verb a machine's node-engine asks in its own name to hand in a value sealed to its
|
||||
// machine (design 57's V3, issue 676); granted as Principal.MovesIntoTheVault says.
|
||||
var VaultMoveIn = SeatVerb{Seat: "mesh-vault", Verb: "move-in"}
|
||||
|
||||
// VaultNodeKey is the vault's verb a machine's node-engine asks in its own name to state its sealing key (issue 786,
|
||||
// novox/hq ADR 0372); granted as Principal.StatesItsKeyToTheVault says.
|
||||
var VaultNodeKey = SeatVerb{Seat: "mesh-vault", Verb: "node-key"}
|
||||
|
||||
// VerbsTheControllerActsOnAWarrant are the other seat verbs the controller calls when the operator's warrant
|
||||
// chooses them (novox/hq ADR 0259): a machine's service restarted, and a walk started or stopped through the
|
||||
// controller's own `plans`. Named one by one; a node seat's on any machine, a mesh seat's flat.
|
||||
var VerbsTheControllerActsOnAWarrant = []SeatVerb{{Seat: "node-service-manager", Verb: "restart"},
|
||||
{Seat: ControllerSeat, Verb: "plans"}}
|
||||
|
||||
// VerbsTheControllerAsksTheOperatorChannel are the operator channel's verbs the controller calls (novox/hq
|
||||
// ADR 0380): `tell`, one plain message with no condition behind it — a module's own secret given, named,
|
||||
// where and when and by whom, asking nothing. A mesh seat's verb is flat: no machine in the subject.
|
||||
var VerbsTheControllerAsksTheOperatorChannel = []SeatVerb{{Seat: "operator-channel", Verb: "tell"}}
|
||||
|
||||
// DegradableGrant is a publish grant whose absence from the installer's carried first-user list is not a
|
||||
// bug in the template: a fallback covers the refusal.
|
||||
type DegradableGrant struct {
|
||||
Subject string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// DegradableGrants are the only publish grants TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose
|
||||
// may find missing from the installer's carried list without failing (novox/hq issue 835, the forge check
|
||||
// of mesh-controller#345): a change that only edits mesh-host's examples can never satisfy a check that
|
||||
// reads the mesh-host commit the machines actually run, so the carried list lags behind the controller's
|
||||
// derived one until mesh-host's own module next builds. operator-channel.tell is the one grant the
|
||||
// controller already has a fallback for: refused (no holder, or the bus refuses it), the controller raises
|
||||
// the secret-given condition exactly as before (ADR 0380 §9), so an older carried list still comes up and
|
||||
// works. Every other grant stays required — missing one of those is a controller that comes up and is
|
||||
// refused on the first thing it tries, with no fallback that covers it.
|
||||
var DegradableGrants = []DegradableGrant{
|
||||
{
|
||||
Subject: "mesh.seat." + VerbsTheControllerAsksTheOperatorChannel[0].Seat + ".tool." + VerbsTheControllerAsksTheOperatorChannel[0].Verb,
|
||||
Reason: "refused: the controller raises the secret-given condition as before (ADR 0380 §9)",
|
||||
},
|
||||
}
|
||||
|
||||
// perMachineEvents are a node-scoped seat's events about the holder itself, whose last token is the
|
||||
// holder's machine (novox/hq ADR 0219): `paused.<node>`, the build agent saying whether it takes work.
|
||||
var perMachineEvents = map[string]bool{"paused.*": true}
|
||||
@@ -304,6 +387,10 @@ func (p Principal) Username() string {
|
||||
return "node." + p.Node
|
||||
case KindController:
|
||||
return "controller"
|
||||
case KindProvisioner:
|
||||
// Named for the machine, as a node is: one client per machine, whichever module runs it, and never
|
||||
// `<node>.<module>`, which is the module's own account or the tool runner's.
|
||||
return "provisioner." + p.Node
|
||||
case KindEnrolment:
|
||||
// Per token, not one shared user. **The inbox is the reason**: with a single `enrolment`
|
||||
// user every machine enrolling at once could read every other's answer, and an answer
|
||||
@@ -343,6 +430,11 @@ func AskJudgeDirectoriesSubject(node string) string {
|
||||
return "mesh.node." + node + ".ask.judge-directories"
|
||||
}
|
||||
|
||||
// AskMoveSecretSubject is where the controller asks one machine's node-engine to move one value sealed to it into
|
||||
// the vault (design 57's V3, issue 676): a request answered once, signed as a hand-over is (link.MoveSecretContext),
|
||||
// for the same reason.
|
||||
func AskMoveSecretSubject(node string) string { return "mesh.node." + node + ".ask.move-secret" }
|
||||
|
||||
// inbox is a principal's own reply space. No user is ever granted a bare `_INBOX.>` (design 25
|
||||
// §4): with one account, inbox privacy is the permission list or it is nothing, so each user's
|
||||
// inbox is derived from its own identity and its permissions name that prefix and no other.
|
||||
@@ -496,6 +588,10 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
for _, v := range VerbsTheControllerAsksForASecret {
|
||||
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb+".*")
|
||||
}
|
||||
// And the operator channel, a mesh seat, asked flat: one message when a secret is given (ADR 0380).
|
||||
for _, v := range VerbsTheControllerAsksTheOperatorChannel {
|
||||
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb)
|
||||
}
|
||||
// And the delivery's owner, a mesh seat, asked on its flat subjects (ADR 0239).
|
||||
for _, v := range VerbsTheControllerAsksTheDeliveryOwner {
|
||||
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb)
|
||||
@@ -562,6 +658,12 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
pub = append(pub, "$JS.ACK."+DeadLetterNoticesStream+"."+ControllerName+".>", deadLetterPrefix+">",
|
||||
againPrefix+">")
|
||||
|
||||
case KindProvisioner:
|
||||
var err error
|
||||
if pub, sub, err = provisionerGrants(p); err != nil {
|
||||
return Permissions{}, err
|
||||
}
|
||||
|
||||
case KindPerson:
|
||||
// Tools, and nothing else. Every subject a person may publish is a tool call; a person
|
||||
// who could publish an event would be able to claim a module said something.
|
||||
@@ -623,11 +725,24 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// And asking it for a fresh search for setuid programs (novox/hq issue 361), answered the same way.
|
||||
AskSetuidSearchSubject(p.Node),
|
||||
// And asking it to judge its declared directories, read-only (novox/hq issue 358), answered the same way.
|
||||
AskJudgeDirectoriesSubject(p.Node)}
|
||||
AskJudgeDirectoriesSubject(p.Node),
|
||||
// And asking it to move a value sealed to it into the vault (design 57's V3, issue 676), answered the
|
||||
// same way.
|
||||
AskMoveSecretSubject(p.Node)}
|
||||
// The node-engine witnesses the core builds it places (novox/hq to-be 45 §8, ADR 0236; the
|
||||
// contract is lease/witness.go): it asks its own machine's node tools PING, and, where the
|
||||
// machine runs the controller, reads the lease's one key — read, never written.
|
||||
pub = append(pub, WitnessSubjects(p)...)
|
||||
// And, while the vault serves it, the vault's move-in in its own name and nobody else's (issue 676): the
|
||||
// values sealed to this machine, which only it opens, handed in sealed to the vault's key for the call.
|
||||
if p.MovesIntoTheVault {
|
||||
pub = append(pub, "mesh.seat."+VaultMoveIn.Seat+"."+CallKind+"."+VaultMoveIn.Verb+"."+CallerToken(p.Username()))
|
||||
}
|
||||
// And, while the vault serves it, its node-key in its own name (issue 786): the machine's sealing key, stated
|
||||
// by the engine that holds it, which the vault takes from no other caller.
|
||||
if p.StatesItsKeyToTheVault {
|
||||
pub = append(pub, "mesh.seat."+VaultNodeKey.Seat+"."+CallKind+"."+VaultNodeKey.Verb+"."+CallerToken(p.Username()))
|
||||
}
|
||||
|
||||
case KindModule:
|
||||
// 1. Its own namespace: it publishes its events there and serves its tools there. Nothing
|
||||
@@ -637,6 +752,8 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
for _, e := range p.Emits {
|
||||
pub = append(pub, own+".event."+e)
|
||||
}
|
||||
// And its own streams' events, on their own subjects (novox/hq ADR 0363 §2).
|
||||
pub = append(pub, ownStreamGrants(p.Module, p.Streams)...)
|
||||
// Every tool under its own name, not a list: the tools a module serves are what its code
|
||||
// answers, and a second copy of that list in the manifest would be a second source of
|
||||
// truth for the mesh to keep in step (2026-09-28: every module that served a tool was
|
||||
@@ -678,6 +795,16 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
sub = append(sub, subject)
|
||||
}
|
||||
|
||||
// 2a. What it hears of a module's own stream, as it was granted (novox/hq ADR 0363 §5): the subjects,
|
||||
// and its own consumer on that stream, asked about, pulled from and acknowledged.
|
||||
for _, h := range p.Hears {
|
||||
if len(h.Filters) == 0 {
|
||||
continue
|
||||
}
|
||||
sub = append(sub, h.Filters...)
|
||||
}
|
||||
pub = append(pub, heardGrants(p, p.Hears)...)
|
||||
|
||||
// 2b. Events of a role it watches, under the seat's own namespace. Subscribe only: watching a
|
||||
// role is hearing what it announced, not taking part in it.
|
||||
for _, w := range p.Watches {
|
||||
@@ -800,6 +927,8 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
for _, e := range d.Emits {
|
||||
pub = append(pub, own+".event."+e)
|
||||
}
|
||||
// And its own streams' events (novox/hq ADR 0363 §2).
|
||||
pub = append(pub, ownStreamGrants(d.Module, d.Streams)...)
|
||||
for _, s := range d.Holds {
|
||||
if servedOnlyByTheController(s) {
|
||||
continue
|
||||
@@ -824,6 +953,11 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
return Permissions{}, err
|
||||
}
|
||||
pub = append(pub, invoked...)
|
||||
// And its own machine's provisioner client, for a carried module's config and non-secret bound values,
|
||||
// naming the module from the bundle that asks (novox/hq ADR 0370 §3, §4): the ask on its own machine's
|
||||
// subject alone, and what the client says moved, so a module's code's watches are asked again.
|
||||
pub = append(pub, ProvisionerAskSubject(p.Node))
|
||||
sub = append(sub, "prov.changed."+p.Node+".>")
|
||||
// It says what it serves and may ask what answers (novox/hq ADR 0197): the runtime answers
|
||||
// discovery for each module and seat it carries, and the console it is asks the bus.
|
||||
// One service per runtime process, named for the runtime: the bus lets a principal answer each
|
||||
@@ -838,7 +972,11 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// consumer is still the controller's to make, from the module's own principal.
|
||||
for _, d := range p.Carries {
|
||||
own := Principal{Kind: KindModule, Node: p.Node, Module: d.Module, Emits: d.Emits,
|
||||
Consumes: d.Consumes, Serves: d.Serves, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches}
|
||||
Consumes: d.Consumes, Serves: d.Serves, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches,
|
||||
Streams: d.Streams, Hears: ComposedHears(d)}
|
||||
// And the module streams it hears, each through its own consumer there (novox/hq ADR 0363 §8) — never
|
||||
// a restricted one, which only a module on an account of its own hears (ADR 0376 §1).
|
||||
pub = append(pub, heardGrants(own, own.Hears)...)
|
||||
if _, consumes := ConsumerFor(own); !consumes {
|
||||
continue
|
||||
}
|
||||
@@ -862,6 +1000,14 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
for _, d := range p.Carries {
|
||||
pub = append(pub, logGrants(d.Module, logNames(d.Logs))...)
|
||||
}
|
||||
// **And the event families a session may wait on** (novox/hq ADR 0367 §2): the tool runner's module's own,
|
||||
// named one by one in its manifest, as a plain subscription — no consumer is made for them, and no
|
||||
// call or setting adds one.
|
||||
waits, err := waitGrants(p.Carries)
|
||||
if err != nil {
|
||||
return Permissions{}, err
|
||||
}
|
||||
sub = append(sub, waits...)
|
||||
// **Never the traffic of a trusted holder** (novox/hq ADR 0259 §8): the machine's runtime runs as the
|
||||
// operator's account, which every agent runs as, so a module saying warrants or speaking for a kind
|
||||
// that proves its sender is never composed into it — refused here, naming it, whatever registration
|
||||
@@ -917,8 +1063,14 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// runs, so a change to them waits on a node-engine delivery. It calls and serves on the subjects that name
|
||||
// no caller meanwhile — a caller-named call to its seat reaches nobody and is asked again on those at once —
|
||||
// and moves with issue 464.
|
||||
// The vault's calls in its own name (issue 723), kept apart so the desk's grant is not denied below.
|
||||
var vaultsOwn []string
|
||||
if p.Kind != KindController {
|
||||
pub, sub = callerNamed(p.Username(), pub, sub)
|
||||
if p.Kind == KindModule {
|
||||
vaultsOwn = vaultCalls(p)
|
||||
pub = append(pub, vaultsOwn...)
|
||||
}
|
||||
} else {
|
||||
// **But the tools that answer a software-issued credential** (novox/hq ADR 0305 §2): those answer the
|
||||
// controller alone, and know it only from the subject's last token, so it asks them in its own name —
|
||||
@@ -930,6 +1082,13 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
}
|
||||
pub = append(pub, "mesh.mod."+module+"."+CallKind+"."+tool+".*."+CallerToken(p.Username()))
|
||||
}
|
||||
// **And the vault's intake verbs** (issue 675): the vault takes a value from the controller alone, known
|
||||
// from the subject's last token — the verbs its holder serves, flat, and nothing else of its seat.
|
||||
for _, v := range VerbsTheControllerAsksTheVault {
|
||||
if slices.Contains(p.AsksTheVault, v.Verb) {
|
||||
pub = append(pub, "mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+"."+CallerToken(p.Username()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(pub)
|
||||
@@ -944,6 +1103,9 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
if p.Kind != KindController {
|
||||
for _, only := range ControllerOnly() {
|
||||
for _, a := range pub {
|
||||
if slices.Contains(vaultsOwn, a) {
|
||||
continue
|
||||
}
|
||||
if SubjectsOverlap(a, only) {
|
||||
deny = append(deny, only)
|
||||
break
|
||||
@@ -960,11 +1122,38 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// thing, a hand-over on its own subject (novox/hq issue 356), and answers that: the node is its
|
||||
// machine's engine, root there already, and it is delivered only its own subjects. What it answers is
|
||||
// never trusted for being an answer — the controller reads the engine's words and records nothing. A
|
||||
// person is never asked anything, and is granted nothing here.
|
||||
AllowResponses: p.Kind == KindModule || p.Kind == KindController || p.Kind == KindNodeTools || p.Kind == KindNode,
|
||||
// provisioner client is asked by its own machine's tool runner alone, on its own machine's subject (novox/hq
|
||||
// ADR 0370 §3), and answers that. A person is never asked anything, and is granted nothing here.
|
||||
AllowResponses: p.Kind == KindModule || p.Kind == KindController || p.Kind == KindNodeTools || p.Kind == KindNode ||
|
||||
p.Kind == KindProvisioner,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// vaultCalls are the calls the vault's holder makes in its own name (novox/hq ADR 0349 clauses 9 and 10, issue
|
||||
// 723): the desks' secret prompt on any machine, and each tool that answers a credential its provider's software
|
||||
// issued, on any machine — each on the subject whose last token is the vault's own name, which the bus lets it
|
||||
// alone publish, so the prompt and the tool know the vault asks. Nothing else outside its seat.
|
||||
func vaultCalls(p Principal) []string {
|
||||
token := CallerToken(p.Username())
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
if p.AsksTheDesk {
|
||||
for _, v := range VerbsTheControllerAsksForASecret {
|
||||
out = append(out, "mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+".*."+token)
|
||||
}
|
||||
}
|
||||
for _, key := range p.AsksIssued {
|
||||
module, tool, ok := strings.Cut(key, ".")
|
||||
if !ok || module == "" || tool == "" || strings.ContainsAny(key, "*> ") {
|
||||
continue
|
||||
}
|
||||
out = append(out, "mesh.mod."+module+"."+CallKind+"."+tool+".*."+token)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// seatSubject places a seat's verb under the kind of traffic it is.
|
||||
//
|
||||
// **The kind token is load-bearing, not decoration.** A stream is defined by a subject filter, so
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package broker
|
||||
|
||||
import "testing"
|
||||
|
||||
// Issue 786 (novox/hq ADR 0372): each machine's node-engine may ask the vault's node-key in its own name, the
|
||||
// subject that names it and no other machine's, once the vault's holder serves it; and nothing else of the vault's
|
||||
// is granted by it. A vault that does not serve it grants nothing.
|
||||
func TestANodeEngineMayStateItsSealingKeyToTheVaultInItsOwnNameAlone(t *testing.T) {
|
||||
vault := Declared{Module: "mesh-vault", Holds: []Seat{{Name: "mesh-vault", Serves: []string{"get", "node-key"}}}}
|
||||
users, err := Users(Records{Nodes: []string{"anchor", "shanks"}, Assigned: map[string][]Declared{"anchor": {vault}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var anchor Principal
|
||||
for _, u := range users {
|
||||
if u.Kind == KindNode && u.Node == "anchor" {
|
||||
anchor = u
|
||||
}
|
||||
}
|
||||
p, err := PermissionsFor(anchor)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !MayPublish(p, CalledSubject("mesh.seat.mesh-vault.tool.node-key", "node.anchor")) {
|
||||
t.Error("anchor's node-engine may not state its key to the vault in its own name")
|
||||
}
|
||||
for _, s := range []string{CalledSubject("mesh.seat.mesh-vault.tool.node-key", "node.shanks"),
|
||||
CalledSubject("mesh.seat.mesh-vault.tool.node-key", ControllerName),
|
||||
CalledSubject("mesh.seat.mesh-vault.tool.get", "node.anchor"),
|
||||
CalledSubject("mesh.seat.mesh-vault.tool.move-in", "node.anchor"),
|
||||
"mesh.seat.mesh-vault.tool.node-key"} {
|
||||
if MayPublish(p, s) {
|
||||
t.Errorf("anchor's node-engine may publish %s", s)
|
||||
}
|
||||
}
|
||||
for _, u := range users {
|
||||
if u.Kind == KindModule && u.Module == "mesh-vault" {
|
||||
h, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !MaySubscribe(h, CalledSubject("mesh.seat.mesh-vault.tool.node-key", "node.anchor")) {
|
||||
t.Error("the vault's holder does not hear node-key asked by a node-engine")
|
||||
}
|
||||
}
|
||||
}
|
||||
older := Declared{Module: "mesh-vault", Holds: []Seat{{Name: "mesh-vault", Serves: []string{"get", "move-in"}}}}
|
||||
users, _ = Users(Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{"anchor": {older}}})
|
||||
for _, u := range users {
|
||||
if u.Kind != KindNode {
|
||||
continue
|
||||
}
|
||||
q, _ := PermissionsFor(u)
|
||||
if MayPublish(q, CalledSubject("mesh.seat.mesh-vault.tool.node-key", "node.anchor")) {
|
||||
t.Error("a node-engine may state its key while the vault does not serve node-key")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// **On a real server, as composed** (design 57 P6, issue 672, ADR 0362): a machine's provisioner client's word on a
|
||||
// module's files is kept by the mesh's own stream, newest per module, and the controller reads it by subject; the
|
||||
// client cannot say it of another machine, and nothing but a machine's own client is granted to say it.
|
||||
func TestTheProvisionersReportIsKeptAndReadByTheController(t *testing.T) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accounts, err := ComposeAccounts([]Principal{
|
||||
{Kind: KindController, PasswordHash: string(hash)},
|
||||
{Kind: KindProvisioner, Node: "g14", Module: "mesh-provisioner-client", PasswordHash: string(hash)},
|
||||
{Kind: KindProvisioner, Node: "shanks", Module: "mesh-provisioner-client", PasswordHash: string(hash)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conf := filepath.Join(t.TempDir(), "bus.conf")
|
||||
if err := os.WriteFile(conf, []byte("listen: 127.0.0.1:-1\njetstream { store_dir: "+
|
||||
`"`+t.TempDir()+`"`+" }\n"+accounts), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts, err := server.ProcessConfigFile(conf)
|
||||
if err != nil {
|
||||
t.Fatalf("the composed accounts do not parse: %v", err)
|
||||
}
|
||||
opts.NoLog, opts.NoSigs = true, true
|
||||
s, err := server.NewServer(opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go s.Start()
|
||||
if !s.ReadyForConnections(10 * time.Second) {
|
||||
t.Fatal("the bus did not come up")
|
||||
}
|
||||
defer s.Shutdown()
|
||||
|
||||
refusals := make(chan error, 16)
|
||||
connect := func(user string) *nats.Conn {
|
||||
t.Helper()
|
||||
nc, err := nats.Connect(s.ClientURL(), nats.UserInfo(user, "pw"), nats.CustomInboxPrefix("_INBOX."+user),
|
||||
nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) { refusals <- err }))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(nc.Close)
|
||||
return nc
|
||||
}
|
||||
control := OnConn(connect("controller"))
|
||||
if err := AssertMeshStreams(control); err != nil {
|
||||
t.Fatalf("the mesh's streams cannot be asserted: %v", err)
|
||||
}
|
||||
|
||||
g14 := connect("provisioner.g14")
|
||||
for _, said := range []string{`{"status":"waiting"}`, `{"status":"applied","templates":"sha256:new"}`} {
|
||||
if err := g14.Publish(ProvisionerAppliedSubject("g14", "dunst"), []byte(said)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := g14.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Another machine's client says nothing of g14: refused by the server, and nothing kept.
|
||||
shanks := connect("provisioner.shanks")
|
||||
if err := shanks.Publish(ProvisionerAppliedSubject("g14", "dunst"), []byte(`{"status":"failed"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = shanks.Flush()
|
||||
select {
|
||||
case err := <-refusals:
|
||||
if !errors.Is(err, nats.ErrPermissionViolation) {
|
||||
t.Fatalf("the server said %v, want a permissions violation", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("the server did not refuse shanks's client a report on g14")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
js, err := control.Conn().JetStream(nats.Context(ctx))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A core publish is kept by the stream once the server has stored it: read until it has, a few seconds at most.
|
||||
var got []byte
|
||||
var said bool
|
||||
for deadline := time.Now().Add(5 * time.Second); ; {
|
||||
got, said, err = LastProvApplied(js, "g14", "dunst")
|
||||
if (err == nil && said && strings.Contains(string(got), "applied")) || time.Now().After(deadline) {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if err != nil || !said || string(got) != `{"status":"applied","templates":"sha256:new"}` {
|
||||
t.Fatalf("the controller read %q %v %v, want g14's newest report", got, said, err)
|
||||
}
|
||||
if _, said, err := LastProvApplied(js, "shanks", "dunst"); err != nil || said {
|
||||
t.Fatalf("a module nothing was said of reads as said: %v %v", said, err)
|
||||
}
|
||||
all, err := ProvAppliedReports(js)
|
||||
if err != nil || len(all) != 1 || string(all["prov.applied.g14.dunst"]) != `{"status":"applied","templates":"sha256:new"}` {
|
||||
t.Fatalf("every report, newest per module: %q %v", all, err)
|
||||
}
|
||||
info, err := js.StreamInfo(ProvAppliedStream)
|
||||
if err != nil || info.State.Msgs != 1 {
|
||||
t.Fatalf("the stream keeps the newest report per module only: %+v %v", info, err)
|
||||
}
|
||||
|
||||
// The writers table: a machine's own client alone is granted to say it.
|
||||
if err := CheckWriters(Principal{Kind: KindProvisioner, Node: "g14"}, []string{"prov.applied.g14.>"}); err != nil {
|
||||
t.Fatalf("g14's client may not say its own: %v", err)
|
||||
}
|
||||
for _, p := range []Principal{{Kind: KindProvisioner, Node: "shanks"}, {Kind: KindNode, Node: "g14"},
|
||||
{Kind: KindModule, Node: "g14", Module: "dunst"}} {
|
||||
err := CheckWriters(p, []string{"prov.applied.g14.>"})
|
||||
if err == nil || !strings.Contains(err.Error(), "a module's files placed on a machine") {
|
||||
t.Errorf("%s may say g14's provisioner's report: %v", p.Username(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// The provisioner client's own bus account (novox/hq ADR 0349 clauses 6 and 11, design 57 package P3).
|
||||
//
|
||||
// **Not the tool runner's account.** The client fills a machine's files and asks the vault for the secrets that
|
||||
// machine's declaration names; the vault hands a secret only to an account of its own, because a call under the
|
||||
// tool runner's account cannot be told from an agent's on that machine (ADR 0305 §3, §12). So each machine that
|
||||
// runs the client has a principal of its own, KindProvisioner, named for the machine, and granted what the client
|
||||
// does and nothing else: read its own machine's keys in each domain's bucket, write its own machine's keys of the
|
||||
// provisioner server's needs, applied and collections (design 57 P4), read the server's registry, say what it
|
||||
// applied on its own machine, and ask the vault in its own name.
|
||||
|
||||
// NodeProvisionerSeat is the seat the provisioner client holds on every machine (ADR 0349 clause 3).
|
||||
const NodeProvisionerSeat = "node-provisioner"
|
||||
|
||||
// ProvisionerSecret is the own secret of the module running the client that its bus account is delivered in, as
|
||||
// `broker` is a module's own (novox/hq issue 195): a module that claims NodeProvisionerSeat and declares no own
|
||||
// secret of this name has nowhere to read an account, and is given none.
|
||||
const ProvisionerSecret = "provisioner-broker"
|
||||
|
||||
// ConfigResolvedBucket is the config domain's resolved values, keyed `<node>.<module>.<key>` (ADR 0348, research
|
||||
// 051): mesh-config's state `resolved`, written by mesh-config alone.
|
||||
var ConfigResolvedBucket = BucketName("mesh-config", "resolved")
|
||||
|
||||
// ProvisionerReads are the domains' buckets a provisioner client reads its own machine's keys of, each keyed
|
||||
// `<node>.…` (research 051): placement, the controller's assignments mirror (`<node>.<module>`), whose value carries
|
||||
// the provisioner's plan of a module handed to it (inventory.Provisioning, design 57 P5); and config,
|
||||
// mesh-config's resolved values. **Named one by one**, as the controller's asks are: a domain that moves to the
|
||||
// provisioner adds its bucket here, and a bucket not named is one the client cannot read.
|
||||
var ProvisionerReads = []string{ConfigResolvedBucket}
|
||||
|
||||
// ProvisionerReadsWhole are the buckets a provisioner client reads every key of: the controller's assignments mirror,
|
||||
// where a provider's consumers are bound to it in their own machines' plans, which the provider machine's client
|
||||
// reads to fill ${consumers:<provision>} with each one's credential (novox/hq ADR 0373, issue 679). It holds placement
|
||||
// and plans, never a secret's value; the client writes none of it.
|
||||
var ProvisionerReadsWhole = []string{AssignmentsBucket}
|
||||
|
||||
// BoundState is a providing module's state of entries, one per consumer (mesh-sdk go/provider, issue 678): a
|
||||
// consumer's machine's client reads its own keys of it, `<node>.<module>.<provision>[.<local>]`, to fill
|
||||
// ${bound:<provision>:<key>} (issue 679).
|
||||
const BoundState = "bound"
|
||||
|
||||
// ProvisionerServerModule is the module holding the seat mesh-provisioner once for the mesh (ADR 0349 clause 3,
|
||||
// design 57 P4), whose buckets the clients write their own machine's keys of.
|
||||
const ProvisionerServerModule = "mesh-provisioner"
|
||||
|
||||
// The provisioner server's buckets (ADR 0349 clause 3): its state, created by the controller from its manifest
|
||||
// like any module's, and written by its own account whole.
|
||||
var (
|
||||
// ProvisionerRegistryBucket is which domain answers which kind of placeholder, keyed by the kind; the
|
||||
// server's alone, read whole by every client.
|
||||
ProvisionerRegistryBucket = BucketName(ProvisionerServerModule, "registry")
|
||||
// ProvisionerNeedsBucket is what each module on each machine needs, `<node>.<module>.<kind>.<name>`.
|
||||
ProvisionerNeedsBucket = BucketName(ProvisionerServerModule, "needs")
|
||||
// ProvisionerAppliedBucket is what is applied where, `<node>.<module>`.
|
||||
ProvisionerAppliedBucket = BucketName(ProvisionerServerModule, "applied")
|
||||
// ProvisionerCollectBucket is the items of every collection, `<node>.<collection>.<contributor>`.
|
||||
ProvisionerCollectBucket = BucketName(ProvisionerServerModule, "collect")
|
||||
)
|
||||
|
||||
// ProvisionerWrites are the server's buckets a client writes, and reads, its own machine's keys of (`<node>.>`)
|
||||
// and no other's: the needs it records, what it applied, and the items of the collections on its machine. A
|
||||
// client cannot write another machine's needs, or say another machine applied a file.
|
||||
var ProvisionerWrites = []string{ProvisionerNeedsBucket, ProvisionerAppliedBucket, ProvisionerCollectBucket}
|
||||
|
||||
// VerbsTheProvisionerAsksTheVault are the vault's verbs a provisioner client asks in its own name (ADR 0349
|
||||
// clause 11): `get`, a secret its machine's declaration names, answered sealed to the asker's key. On the subject
|
||||
// that names its caller, so the vault knows which machine's client asks from the bus's word.
|
||||
var VerbsTheProvisionerAsksTheVault = []SeatVerb{{Seat: "mesh-vault", Verb: "get"}}
|
||||
|
||||
// VaultSecretChangedSubject is where the vault says a secret it holds changed (`secret-changed`: which, by whom,
|
||||
// when, its generation; never a value, ADR 0349 clause 11), as link.EventSubject addresses mesh-vault's event. A
|
||||
// provisioner client listens to it so a rotated secret reaches its file without a new declaration (design 57 V4):
|
||||
// it asks the vault again, in its own name, for a secret its machine's declaration names.
|
||||
const VaultSecretChangedSubject = "mesh.mod.mesh-vault.event.secret-changed"
|
||||
|
||||
// ProvisionerAppliedSubject is where a machine's client says what it applied for one module, applied or failed
|
||||
// with the reason (ADR 0349 clauses 4 and 15): `prov.applied.<node>.<module>`.
|
||||
func ProvisionerAppliedSubject(node, module string) string {
|
||||
return "prov.applied." + node + "." + module
|
||||
}
|
||||
|
||||
// ProvisionerAskSubject is where a machine's tool runner asks that machine's provisioner client for a config value
|
||||
// or a non-secret bound value of a module it carries, naming the module from the bundle that asks (novox/hq ADR
|
||||
// 0370 §1, §3, §4; issue 685): `prov.ask.<node>`, which the client subscribes and the tool runner's account of the
|
||||
// same machine alone may publish. The bus proves only that the tool runner asks; the module is the runtime's word,
|
||||
// so the client answers no secret here (ADR 0370 §2).
|
||||
func ProvisionerAskSubject(node string) string { return "prov.ask." + node }
|
||||
|
||||
// ProvisionerChangedSubject is where a machine's client says that a value of one module it answers moved, so the
|
||||
// tool runner asks again for the module's code's watches (ADR 0349 §14, ADR 0370 §1): `prov.changed.<node>.<module>`,
|
||||
// carrying which value and never the value itself, heard by that machine's tool runner alone.
|
||||
func ProvisionerChangedSubject(node, module string) string {
|
||||
return "prov.changed." + node + "." + module
|
||||
}
|
||||
|
||||
// provisionerGrants is a provisioner client's whole authority: to publish, and to subscribe.
|
||||
func provisionerGrants(p Principal) (pub, sub []string, err error) {
|
||||
if p.Node == "" {
|
||||
// Refused rather than composed into `$KV.<bucket>..>`, a pattern no machine's keys are under, and a
|
||||
// name every nameless client would share.
|
||||
return nil, nil, errors.New("a provisioner client names no machine, so it would have no keys of its own " +
|
||||
"to read and an account it shared with every other")
|
||||
}
|
||||
for _, bucket := range ProvisionerReads {
|
||||
pub = append(pub, machineKeysRead(bucket, p.Node)...)
|
||||
}
|
||||
for _, bucket := range ProvisionerReadsWhole {
|
||||
pub = append(pub, wholeBucketRead(bucket)...)
|
||||
}
|
||||
for _, bucket := range p.BoundBuckets {
|
||||
module, name, ok := strings.Cut(bucket, "_")
|
||||
if !ok || name != BoundState || !safeSubject.MatchString(module) || strings.Contains(module, "_") {
|
||||
return nil, nil, fmt.Errorf("%q is not a provider's bucket of entries, <module>_%s", bucket, BoundState)
|
||||
}
|
||||
pub = append(pub, machineKeysRead(bucket, p.Node)...)
|
||||
}
|
||||
// The server's buckets (design 57 P4): its own machine's keys of the needs, the applied and the
|
||||
// collections, read and written, `$KV.<bucket>.<node>.>` and nothing wider; and the registry, read whole,
|
||||
// which says which domain answers which kind and is the same for every machine.
|
||||
for _, bucket := range ProvisionerWrites {
|
||||
pub = append(pub, machineKeysRead(bucket, p.Node)...)
|
||||
pub = append(pub, "$KV."+bucket+"."+p.Node+".>")
|
||||
}
|
||||
pub = append(pub, wholeBucketRead(ProvisionerRegistryBucket)...)
|
||||
pub = append(pub, "prov.applied."+p.Node+".>")
|
||||
token := CallerToken(p.Username())
|
||||
if token == "" {
|
||||
return nil, nil, fmt.Errorf("%q cannot name a caller", p.Username())
|
||||
}
|
||||
for _, v := range VerbsTheProvisionerAsksTheVault {
|
||||
pub = append(pub, "mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+"."+token)
|
||||
}
|
||||
// What moved of a value it answers its own machine's tool runner (ADR 0370 §1), under its own machine's name.
|
||||
pub = append(pub, "prov.changed."+p.Node+".>")
|
||||
// Its own reply space, for the vault's answers and its reads' and watches' deliveries; what the vault says
|
||||
// changed; and its own machine's tool runner asking it for a module's config and non-secret bound values (ADR
|
||||
// 0370 §3), which it answers on the ask's reply and nowhere else.
|
||||
return pub, []string{p.inbox(), VaultSecretChangedSubject, ProvisionerAskSubject(p.Node)}, nil
|
||||
}
|
||||
|
||||
// machineKeysRead is reading every key of a bucket under one machine's name, `<node>.>`, and no other key: a
|
||||
// direct get of a key's own subject, and a consumer whose filter is under it — the server's create-with-filter
|
||||
// form, which it holds to the filter the subject names (as stateGrants does for one key, novox/hq ADR 0260).
|
||||
// Binding, deleting a consumer and flow control cannot be narrowed by key and read no value.
|
||||
func machineKeysRead(bucket, node string) []string {
|
||||
stream := "KV_" + bucket
|
||||
keys := "$KV." + bucket + "." + node + ".>"
|
||||
return []string{
|
||||
"$JS.API.STREAM.INFO." + stream,
|
||||
"$JS.API.DIRECT.GET." + stream + "." + keys,
|
||||
"$JS.API.CONSUMER.CREATE." + stream + ".*." + keys,
|
||||
"$JS.API.CONSUMER.DELETE." + stream + ".>",
|
||||
"$JS.FC." + stream + ".>",
|
||||
}
|
||||
}
|
||||
|
||||
// wholeBucketRead is reading every key of a bucket and writing none: what stateGrants grants a module for a
|
||||
// bucket it `reads`.
|
||||
func wholeBucketRead(bucket string) []string {
|
||||
stream := "KV_" + bucket
|
||||
return []string{
|
||||
"$JS.API.STREAM.INFO." + stream,
|
||||
"$JS.API.DIRECT.GET." + stream + ".>",
|
||||
"$JS.API.CONSUMER.CREATE." + stream + ".>",
|
||||
"$JS.API.CONSUMER.DELETE." + stream + ".>",
|
||||
"$JS.FC." + stream + ".>",
|
||||
}
|
||||
}
|
||||
|
||||
// LastProvApplied is what a machine's provisioner client last said of one module's files, as it published it on
|
||||
// ProvisionerAppliedSubject and ProvAppliedStream keeps it (design 57 P6): false when it has said nothing of it in
|
||||
// the stream's week.
|
||||
func LastProvApplied(js nats.JetStreamContext, node, module string) ([]byte, bool, error) {
|
||||
msg, err := js.GetLastMsg(ProvAppliedStream, ProvisionerAppliedSubject(node, module))
|
||||
if errors.Is(err, nats.ErrMsgNotFound) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("what %s's provisioner client said of %s cannot be read: %w", node, module, err)
|
||||
}
|
||||
return msg.Data, true, nil
|
||||
}
|
||||
|
||||
// ProvAppliedReports is the newest word of every machine's provisioner client on every module it said one of, kept by
|
||||
// ProvAppliedStream, by subject (`prov.applied.<node>.<module>`).
|
||||
func ProvAppliedReports(js nats.JetStreamContext) (map[string][]byte, error) {
|
||||
info, err := js.StreamInfo(ProvAppliedStream, &nats.StreamInfoRequest{SubjectsFilter: ProvAppliedSubjects})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the provisioner clients' reports cannot be listed: %w", err)
|
||||
}
|
||||
out := map[string][]byte{}
|
||||
for subject := range info.State.Subjects {
|
||||
msg, err := js.GetLastMsg(ProvAppliedStream, subject)
|
||||
if errors.Is(err, nats.ErrMsgNotFound) {
|
||||
continue // aged out between the two reads
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the provisioner client's report on %s cannot be read: %w", subject, err)
|
||||
}
|
||||
out[subject] = msg.Data
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// **On a real server, as composed** (design 57 package P3, issue 669): a machine's provisioner client reads and
|
||||
// watches its own machine's keys in a domain's bucket, and the server refuses it another machine's keys, a write,
|
||||
// and a report or a vault ask in another's name.
|
||||
func TestAServerComposedFromTheGrantsRefusesTheProvisionerClientAnotherMachinesKeys(t *testing.T) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vault := Seat{Name: "mesh-vault", Scope: "mesh", Serves: []string{"get"}}
|
||||
accounts, err := ComposeAccounts([]Principal{
|
||||
{Kind: KindController, PasswordHash: string(hash)},
|
||||
{Kind: KindModule, Node: "novox", Module: "mesh-config", State: []string{"resolved"}, PasswordHash: string(hash)},
|
||||
{Kind: KindModule, Node: "novox", Module: "mesh-vault", Holds: []Seat{vault}, PasswordHash: string(hash)},
|
||||
{Kind: KindModule, Node: "novox", Module: "postgres", State: []string{"bound"}, PasswordHash: string(hash)},
|
||||
{Kind: KindProvisioner, Node: "g14", Module: "mesh-provisioner", BoundBuckets: []string{"postgres_bound"},
|
||||
PasswordHash: string(hash)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conf := filepath.Join(t.TempDir(), "bus.conf")
|
||||
if err := os.WriteFile(conf, []byte("listen: 127.0.0.1:-1\njetstream { store_dir: "+
|
||||
`"`+t.TempDir()+`"`+" }\n"+accounts), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts, err := server.ProcessConfigFile(conf)
|
||||
if err != nil {
|
||||
t.Fatalf("the composed accounts do not parse: %v", err)
|
||||
}
|
||||
opts.NoLog, opts.NoSigs = true, true
|
||||
s, err := server.NewServer(opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go s.Start()
|
||||
if !s.ReadyForConnections(10 * time.Second) {
|
||||
t.Fatal("the bus did not come up")
|
||||
}
|
||||
defer s.Shutdown()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
connect := func(user string, refusals chan error) *nats.Conn {
|
||||
t.Helper()
|
||||
nc, err := nats.Connect(s.ClientURL(), nats.UserInfo(user, "pw"), nats.CustomInboxPrefix("_INBOX."+user),
|
||||
nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) {
|
||||
if refusals != nil {
|
||||
refusals <- err
|
||||
}
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(nc.Close)
|
||||
return nc
|
||||
}
|
||||
|
||||
// The controller makes the buckets, as it does from registration; the controller writes its mirror and
|
||||
// mesh-config writes its own bucket.
|
||||
control, err := jetstream.New(connect("controller", nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
put := func(js jetstream.JetStream, bucket string, values map[string]string) {
|
||||
t.Helper()
|
||||
kv, err := js.KeyValue(ctx, bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for k, v := range values {
|
||||
if _, err := kv.PutString(ctx, k, v); err != nil {
|
||||
t.Fatalf("writing %s in %s: %v", k, bucket, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, bucket := range append(append([]string{"postgres_bound"}, ProvisionerReads...), ProvisionerReadsWhole...) {
|
||||
if _, err := control.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: bucket}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
put(control, AssignmentsBucket, map[string]string{"g14.dunst": "{}", "shanks.dunst": "{}"})
|
||||
provider, err := jetstream.New(connect("novox.postgres", nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
put(provider, "postgres_bound", map[string]string{"g14.gitea.postgres-database": "{}",
|
||||
"shanks.photos.postgres-database": "{}"})
|
||||
config, err := jetstream.New(connect("novox.mesh-config", nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
put(config, ConfigResolvedBucket, map[string]string{"g14.dunst.font-size": "13", "shanks.dunst.font-size": "11"})
|
||||
|
||||
refusals := make(chan error, 16)
|
||||
clientConn := connect("provisioner.g14", refusals)
|
||||
client, err := jetstream.New(clientConn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, err := client.KeyValue(ctx, ConfigResolvedBucket)
|
||||
if err != nil {
|
||||
t.Fatalf("the client cannot bind to the config domain's bucket: %v", err)
|
||||
}
|
||||
|
||||
// Its own machine's key, read and watched; the watch delivers the current value first.
|
||||
if e, err := resolved.Get(ctx, "g14.dunst.font-size"); err != nil || string(e.Value()) != "13" {
|
||||
t.Fatalf("its own machine's key: %v %v", e, err)
|
||||
}
|
||||
w, err := resolved.Watch(ctx, "g14.>")
|
||||
if err != nil {
|
||||
t.Fatalf("watching its own machine's keys: %v", err)
|
||||
}
|
||||
select {
|
||||
case e := <-w.Updates():
|
||||
if e == nil || e.Key() != "g14.dunst.font-size" {
|
||||
t.Fatalf("the watch delivered %v first", e)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the watch of its own machine's keys delivered nothing")
|
||||
}
|
||||
_ = w.Stop()
|
||||
assignments, err := client.KeyValue(ctx, AssignmentsBucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := assignments.Get(ctx, "g14.dunst"); err != nil {
|
||||
t.Fatalf("its own machine's placement: %v", err)
|
||||
}
|
||||
|
||||
// Another machine's key: refused by the server, read or watched, and every key at once too; and a write of
|
||||
// a domain's bucket, which is the domain's alone. Each refusal is the server's word, not a missing key.
|
||||
drain := func() {
|
||||
for {
|
||||
select {
|
||||
case <-refusals:
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
serverRefuses := func(what string, act func(context.Context) error) {
|
||||
t.Helper()
|
||||
drain()
|
||||
short, done := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer done()
|
||||
if err := act(short); err == nil {
|
||||
t.Errorf("the client may %s", what)
|
||||
}
|
||||
select {
|
||||
case err := <-refusals:
|
||||
if !errors.Is(err, nats.ErrPermissionViolation) {
|
||||
t.Errorf("%s: the server said %v, want a permissions violation", what, err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Errorf("%s: the server did not refuse it", what)
|
||||
}
|
||||
}
|
||||
serverRefuses("read another machine's config", func(c context.Context) error {
|
||||
_, err := resolved.Get(c, "shanks.dunst.font-size")
|
||||
return err
|
||||
})
|
||||
// Another machine's placement is read, since a provider's consumers are bound to it in their own machines'
|
||||
// plans (ADR 0373, issue 679), and never written.
|
||||
if _, err := assignments.Get(ctx, "shanks.dunst"); err != nil {
|
||||
t.Fatalf("another machine's plan, which binds its consumers to a provider here: %v", err)
|
||||
}
|
||||
serverRefuses("write the assignments mirror", func(c context.Context) error {
|
||||
_, err := assignments.PutString(c, "shanks.dunst", "{}")
|
||||
return err
|
||||
})
|
||||
// A provider's entries: its own machine's consumers' keys alone (issue 679).
|
||||
entries, err := client.KeyValue(ctx, "postgres_bound")
|
||||
if err != nil {
|
||||
t.Fatalf("the client cannot bind to a provider's bucket of entries: %v", err)
|
||||
}
|
||||
if _, err := entries.Get(ctx, "g14.gitea.postgres-database"); err != nil {
|
||||
t.Fatalf("its own machine's consumer's entry: %v", err)
|
||||
}
|
||||
serverRefuses("read another machine's consumer's entry", func(c context.Context) error {
|
||||
_, err := entries.Get(c, "shanks.photos.postgres-database")
|
||||
return err
|
||||
})
|
||||
serverRefuses("write a provider's entry", func(c context.Context) error {
|
||||
_, err := entries.PutString(c, "g14.gitea.postgres-database", "{}")
|
||||
return err
|
||||
})
|
||||
for _, keys := range []string{"shanks.>", ">"} {
|
||||
serverRefuses("watch "+keys, func(c context.Context) error {
|
||||
w, err := resolved.Watch(c, keys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = w.Stop() }()
|
||||
select {
|
||||
case e := <-w.Updates():
|
||||
if e != nil {
|
||||
return nil
|
||||
}
|
||||
return errors.New("nothing delivered")
|
||||
case <-c.Done():
|
||||
return c.Err()
|
||||
}
|
||||
})
|
||||
}
|
||||
serverRefuses("write the config domain's bucket", func(c context.Context) error {
|
||||
_, err := resolved.PutString(c, "g14.dunst.font-size", "40")
|
||||
return err
|
||||
})
|
||||
|
||||
// What it applied, on its own machine and no other's; the vault asked in its own name and no other's.
|
||||
refused := func(subject string, want bool) {
|
||||
t.Helper()
|
||||
drain()
|
||||
if err := clientConn.Publish(subject, []byte(`{}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = clientConn.Flush()
|
||||
select {
|
||||
case err := <-refusals:
|
||||
if !want {
|
||||
t.Errorf("%s was refused: %v", subject, err)
|
||||
} else if !errors.Is(err, nats.ErrPermissionViolation) {
|
||||
t.Errorf("%s: the server said %v, want a permissions violation", subject, err)
|
||||
}
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
if want {
|
||||
t.Errorf("the server did not refuse %s", subject)
|
||||
}
|
||||
}
|
||||
}
|
||||
refused("prov.applied.g14.dunst", false)
|
||||
refused("prov.applied.shanks.dunst", true)
|
||||
refused("mesh.seat.mesh-vault.call.get.provisioner~g14", false)
|
||||
refused("mesh.seat.mesh-vault.call.get.g14~node-tools", true)
|
||||
refused("mesh.seat.mesh-vault.call.accept.provisioner~g14", true)
|
||||
refused("mesh.mod.dunst.tool.anything", true)
|
||||
}
|
||||
|
||||
// **On a real server, as composed** (novox/hq ADR 0370 §3, issue 685): a machine's tool runner asks its own
|
||||
// machine's provisioner client and is answered; the server refuses the ask from another machine's tool runner, from
|
||||
// a module's own account and from the client of another machine, and refuses the client what another machine's
|
||||
// tool runner would hear.
|
||||
func TestAServerComposedFromTheGrantsLetsOnlyItsOwnToolRunnerAskTheProvisionerClient(t *testing.T) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accounts, err := ComposeAccounts([]Principal{
|
||||
{Kind: KindController, PasswordHash: string(hash)},
|
||||
{Kind: KindNodeTools, Node: "g14", Module: RuntimeModule, PasswordHash: string(hash),
|
||||
Carries: []Declared{{Module: "claude-code"}}},
|
||||
{Kind: KindNodeTools, Node: "shanks", Module: RuntimeModule, PasswordHash: string(hash),
|
||||
Carries: []Declared{{Module: "claude-code"}}},
|
||||
{Kind: KindModule, Node: "g14", Module: "dunst", PasswordHash: string(hash)},
|
||||
{Kind: KindProvisioner, Node: "g14", Module: "mesh-provisioner", BoundBuckets: []string{"postgres_bound"},
|
||||
PasswordHash: string(hash)},
|
||||
{Kind: KindProvisioner, Node: "shanks", Module: "mesh-provisioner", PasswordHash: string(hash)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conf := filepath.Join(t.TempDir(), "bus.conf")
|
||||
if err := os.WriteFile(conf, []byte("listen: 127.0.0.1:-1\n"+accounts), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts, err := server.ProcessConfigFile(conf)
|
||||
if err != nil {
|
||||
t.Fatalf("the composed accounts do not parse: %v", err)
|
||||
}
|
||||
opts.NoLog, opts.NoSigs = true, true
|
||||
s, err := server.NewServer(opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go s.Start()
|
||||
if !s.ReadyForConnections(10 * time.Second) {
|
||||
t.Fatal("the bus did not come up")
|
||||
}
|
||||
defer s.Shutdown()
|
||||
connect := func(user string, refusals chan error) *nats.Conn {
|
||||
t.Helper()
|
||||
nc, err := nats.Connect(s.ClientURL(), nats.UserInfo(user, "pw"), nats.CustomInboxPrefix("_INBOX."+user),
|
||||
nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) {
|
||||
if refusals != nil {
|
||||
refusals <- err
|
||||
}
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(nc.Close)
|
||||
return nc
|
||||
}
|
||||
|
||||
clientRefusals := make(chan error, 8)
|
||||
client := connect("provisioner.g14", clientRefusals)
|
||||
asked := make(chan string, 8)
|
||||
if _, err := client.Subscribe(ProvisionerAskSubject("g14"), func(m *nats.Msg) {
|
||||
asked <- string(m.Data)
|
||||
_ = m.Respond([]byte(`{"value":"answered"}`))
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := client.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case err := <-clientRefusals:
|
||||
t.Fatalf("the client may not hear its own machine's asks: %v", err)
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
}
|
||||
|
||||
// Its own machine's tool runner asks and is answered.
|
||||
runner := connect("g14.node-tools", nil)
|
||||
reply, err := runner.Request(ProvisionerAskSubject("g14"), []byte(`{"module":"claude-code"}`), 5*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("g14's tool runner cannot ask g14's client: %v", err)
|
||||
}
|
||||
if string(reply.Data) != `{"value":"answered"}` {
|
||||
t.Fatalf("answered %s", reply.Data)
|
||||
}
|
||||
<-asked
|
||||
|
||||
// Anybody else's ask is refused by the server, and never reaches the client.
|
||||
for _, user := range []string{"shanks.node-tools", "g14.dunst", "provisioner.shanks", "controller"} {
|
||||
refusals := make(chan error, 4)
|
||||
nc := connect(user, refusals)
|
||||
if err := nc.Publish(ProvisionerAskSubject("g14"), []byte(`{"module":"claude-code"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = nc.Flush()
|
||||
select {
|
||||
case err := <-refusals:
|
||||
if !errors.Is(err, nats.ErrPermissionViolation) {
|
||||
t.Errorf("%s: the server said %v, want a permissions violation", user, err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Errorf("the server did not refuse %s an ask of g14's client", user)
|
||||
}
|
||||
select {
|
||||
case body := <-asked:
|
||||
t.Errorf("g14's client heard %s from %s", body, user)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
// What moved is said under its own machine's name, heard by that machine's tool runner; another machine's name
|
||||
// is refused it.
|
||||
told := make(chan string, 4)
|
||||
if _, err := runner.Subscribe("prov.changed.g14.>", func(m *nats.Msg) { told <- m.Subject }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = runner.Flush()
|
||||
if err := client.Publish(ProvisionerChangedSubject("g14", "claude-code"), []byte(`{"kind":"config"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case subject := <-told:
|
||||
if subject != "prov.changed.g14.claude-code" {
|
||||
t.Errorf("heard %s", subject)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("g14's tool runner did not hear what g14's client said moved")
|
||||
}
|
||||
if err := client.Publish(ProvisionerChangedSubject("shanks", "claude-code"), []byte(`{}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = client.Flush()
|
||||
select {
|
||||
case err := <-clientRefusals:
|
||||
if !errors.Is(err, nats.ErrPermissionViolation) {
|
||||
t.Errorf("the server said %v, want a permissions violation", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Error("the server did not refuse g14's client a word in shanks's name")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// **On a real server, as composed** (design 57 package P4, issue 670): the provisioner server's buckets are
|
||||
// written by the server's own account whole, and by each machine's provisioner client for its own machine's keys
|
||||
// alone (ADR 0349 clause 3). The server refuses a client another machine's needs, applied or items, and the
|
||||
// registry; and refuses every other account a write, a provider reading the needs included.
|
||||
func TestOnlyTheServerAndEachClientForItsOwnMachineWriteTheProvisionersBuckets(t *testing.T) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pw := string(hash)
|
||||
accounts, err := ComposeAccounts([]Principal{
|
||||
{Kind: KindController, PasswordHash: pw},
|
||||
{Kind: KindModule, Node: "novox", Module: ProvisionerServerModule,
|
||||
State: []string{"registry", "needs", "applied", "collect"}, PasswordHash: pw},
|
||||
// A provider learns its consumers from the needs (ADR 0349 clause 12): it reads them, and writes none.
|
||||
{Kind: KindModule, Node: "novox", Module: "postgres", Reads: []string{"mesh-provisioner.needs"}, PasswordHash: pw},
|
||||
{Kind: KindModule, Node: "g14", Module: "dunst", PasswordHash: pw},
|
||||
{Kind: KindProvisioner, Node: "g14", Module: ProvisionerServerModule, PasswordHash: pw},
|
||||
{Kind: KindProvisioner, Node: "shanks", Module: ProvisionerServerModule, PasswordHash: pw},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conf := filepath.Join(t.TempDir(), "bus.conf")
|
||||
if err := os.WriteFile(conf, []byte("listen: 127.0.0.1:-1\njetstream { store_dir: "+
|
||||
`"`+t.TempDir()+`"`+" }\n"+accounts), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts, err := server.ProcessConfigFile(conf)
|
||||
if err != nil {
|
||||
t.Fatalf("the composed accounts do not parse: %v", err)
|
||||
}
|
||||
opts.NoLog, opts.NoSigs = true, true
|
||||
s, err := server.NewServer(opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go s.Start()
|
||||
if !s.ReadyForConnections(10 * time.Second) {
|
||||
t.Fatal("the bus did not come up")
|
||||
}
|
||||
defer s.Shutdown()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
type account struct {
|
||||
js jetstream.JetStream
|
||||
refusals chan error
|
||||
}
|
||||
connect := func(user string) account {
|
||||
t.Helper()
|
||||
refusals := make(chan error, 16)
|
||||
nc, err := nats.Connect(s.ClientURL(), nats.UserInfo(user, "pw"), nats.CustomInboxPrefix("_INBOX."+user),
|
||||
nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) { refusals <- err }))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(nc.Close)
|
||||
js, err := jetstream.New(nc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return account{js: js, refusals: refusals}
|
||||
}
|
||||
bucket := func(a account, name string) jetstream.KeyValue {
|
||||
t.Helper()
|
||||
kv, err := a.js.KeyValue(ctx, name)
|
||||
if err != nil {
|
||||
t.Fatalf("binding to %s: %v", name, err)
|
||||
}
|
||||
return kv
|
||||
}
|
||||
|
||||
// The controller makes the buckets from the server's manifest, as it does for every module's state.
|
||||
control := connect("controller")
|
||||
for _, name := range append([]string{ProvisionerRegistryBucket}, ProvisionerWrites...) {
|
||||
if _, err := control.js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: name}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// The server writes all four, every machine's keys.
|
||||
srv := connect("novox." + ProvisionerServerModule)
|
||||
for name, key := range map[string]string{ProvisionerRegistryBucket: "config", ProvisionerNeedsBucket: "shanks.dunst.config.font-size",
|
||||
ProvisionerAppliedBucket: "shanks.dunst", ProvisionerCollectBucket: "shanks.zsh-snippets.git"} {
|
||||
if _, err := bucket(srv, name).PutString(ctx, key, `{}`); err != nil {
|
||||
t.Fatalf("the server writing %s in %s: %v", key, name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A client writes, reads and watches its own machine's keys, and reads the registry whole.
|
||||
g14 := connect("provisioner.g14")
|
||||
own := map[string]string{ProvisionerNeedsBucket: "g14.dunst.config.font-size", ProvisionerAppliedBucket: "g14.dunst",
|
||||
ProvisionerCollectBucket: "g14.zsh-snippets.git"}
|
||||
for name, key := range own {
|
||||
kv := bucket(g14, name)
|
||||
if _, err := kv.PutString(ctx, key, `{}`); err != nil {
|
||||
t.Fatalf("the client writing its own machine's %s in %s: %v", key, name, err)
|
||||
}
|
||||
if e, err := kv.Get(ctx, key); err != nil || string(e.Value()) != `{}` {
|
||||
t.Fatalf("the client reading back its own %s in %s: %v %v", key, name, e, err)
|
||||
}
|
||||
w, err := kv.Watch(ctx, "g14.>")
|
||||
if err != nil {
|
||||
t.Fatalf("the client watching its own machine's keys in %s: %v", name, err)
|
||||
}
|
||||
select {
|
||||
case e := <-w.Updates():
|
||||
if e == nil || e.Key() != key {
|
||||
t.Fatalf("the watch of %s delivered %v first", name, e)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("the watch of its own machine's keys in %s delivered nothing", name)
|
||||
}
|
||||
_ = w.Stop()
|
||||
if err := kv.Delete(ctx, key); err != nil {
|
||||
t.Fatalf("the client deleting its own %s in %s (a module taken off its machine): %v", key, name, err)
|
||||
}
|
||||
}
|
||||
registry := bucket(g14, ProvisionerRegistryBucket)
|
||||
if e, err := registry.Get(ctx, "config"); err != nil || e == nil {
|
||||
t.Fatalf("the client reading the registry: %v %v", e, err)
|
||||
}
|
||||
|
||||
// Each refusal is the server's word, not a missing key.
|
||||
serverRefuses := func(a account, what string, act func(context.Context) error) {
|
||||
t.Helper()
|
||||
for drained := false; !drained; {
|
||||
select {
|
||||
case <-a.refusals:
|
||||
default:
|
||||
drained = true
|
||||
}
|
||||
}
|
||||
short, done := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer done()
|
||||
if err := act(short); err == nil {
|
||||
t.Errorf("%s was let through", what)
|
||||
}
|
||||
select {
|
||||
case err := <-a.refusals:
|
||||
if !errors.Is(err, nats.ErrPermissionViolation) {
|
||||
t.Errorf("%s: the server said %v, want a permissions violation", what, err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Errorf("%s: the server did not refuse it", what)
|
||||
}
|
||||
}
|
||||
put := func(kv jetstream.KeyValue, key string) func(context.Context) error {
|
||||
return func(c context.Context) error { _, err := kv.PutString(c, key, `{}`); return err }
|
||||
}
|
||||
for name, key := range map[string]string{ProvisionerNeedsBucket: "shanks.dunst.config.font-size",
|
||||
ProvisionerAppliedBucket: "shanks.dunst", ProvisionerCollectBucket: "shanks.zsh-snippets.git"} {
|
||||
kv := bucket(g14, name)
|
||||
serverRefuses(g14, "g14's client writing shanks's "+key+" in "+name, put(kv, key))
|
||||
serverRefuses(g14, "g14's client reading shanks's "+key+" in "+name, func(c context.Context) error {
|
||||
_, err := kv.Get(c, key)
|
||||
return err
|
||||
})
|
||||
}
|
||||
serverRefuses(g14, "g14's client watching every machine's needs", func(c context.Context) error {
|
||||
w, err := bucket(g14, ProvisionerNeedsBucket).Watch(c, ">")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = w.Stop() }()
|
||||
select {
|
||||
case e := <-w.Updates():
|
||||
if e != nil {
|
||||
return nil
|
||||
}
|
||||
return errors.New("nothing delivered")
|
||||
case <-c.Done():
|
||||
return c.Err()
|
||||
}
|
||||
})
|
||||
serverRefuses(g14, "a client writing the registry", put(registry, "config"))
|
||||
|
||||
// Shanks's client is held to shanks's keys the same way: its write of g14's applied is refused.
|
||||
shanks := connect("provisioner.shanks")
|
||||
if _, err := bucket(shanks, ProvisionerAppliedBucket).PutString(ctx, "shanks.dunst", `{}`); err != nil {
|
||||
t.Fatalf("shanks's client writing its own applied: %v", err)
|
||||
}
|
||||
serverRefuses(shanks, "shanks's client writing g14's applied", put(bucket(shanks, ProvisionerAppliedBucket), "g14.dunst"))
|
||||
|
||||
// A provider reads every machine's needs and writes none; a module on a client's machine writes none.
|
||||
provider := connect("novox.postgres")
|
||||
needs := bucket(provider, ProvisionerNeedsBucket)
|
||||
if e, err := needs.Get(ctx, "shanks.dunst.config.font-size"); err != nil || e == nil {
|
||||
t.Fatalf("the provider reading the needs: %v %v", e, err)
|
||||
}
|
||||
serverRefuses(provider, "a provider writing the needs", put(needs, "shanks.postgres.bound.db"))
|
||||
dunst := connect("g14.dunst")
|
||||
for _, name := range append([]string{ProvisionerRegistryBucket}, ProvisionerWrites...) {
|
||||
serverRefuses(dunst, "a module on g14 writing "+name, func(c context.Context) error {
|
||||
// Bound by the stream's own subject: the module is granted no bind, so its put is published raw.
|
||||
_, err := dunst.js.Publish(c, "$KV."+name+".g14.dunst", []byte(`{}`))
|
||||
return err
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The provisioner client's own bus account (novox/hq ADR 0349 clauses 6 and 11, design 57 package P3, issue
|
||||
// 669): one per machine running it, apart from the machine's tool runner, granted to read its own machine's keys
|
||||
// in each domain's bucket, to say what it applied on that machine, and to ask the vault in its own name; nothing
|
||||
// else.
|
||||
|
||||
func provisionerRecords() Records {
|
||||
client := Declared{Module: "mesh-provisioner", ProvisionsNode: true}
|
||||
return Records{
|
||||
Nodes: []string{"g14", "shanks", "ace"},
|
||||
Assigned: map[string][]Declared{
|
||||
"g14": {client, {Module: RuntimeModule}},
|
||||
"shanks": {client},
|
||||
"ace": {{Module: "dunst"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// One account per machine that runs the client, named for the machine and apart from its tool runner's: the
|
||||
// vault tells the client from an agent on that machine by the name the bus proves.
|
||||
func TestEachMachineRunningTheProvisionerClientHasAnAccountOfItsOwn(t *testing.T) {
|
||||
users, err := Users(provisionerRecords())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got []string
|
||||
for _, u := range users {
|
||||
if u.Kind == KindProvisioner {
|
||||
got = append(got, u.Username())
|
||||
if u.Module != "mesh-provisioner" {
|
||||
t.Errorf("%s is delivered to %q, want the module that runs it", u.Username(), u.Module)
|
||||
}
|
||||
}
|
||||
}
|
||||
if want := []string{"provisioner.g14", "provisioner.shanks"}; !slices.Equal(got, want) {
|
||||
t.Fatalf("the provisioner clients' accounts are %v, want %v (none for a machine that does not run it)", got, want)
|
||||
}
|
||||
for _, u := range users {
|
||||
if u.Kind == KindNodeTools && u.Username() == "provisioner.g14" {
|
||||
t.Fatal("the client shares the tool runner's account")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Its whole authority, subject by subject: a read of its own machine's keys in each domain's bucket, a read and a
|
||||
// write of its own machine's keys in the provisioner server's needs, applied and collections, a read of the
|
||||
// server's registry (design 57 P4), a publish of what it applied on its own machine, the vault's `get` in its own
|
||||
// name, its own inbox and the vault's secret-changed.
|
||||
func TestTheProvisionerClientIsGrantedItsMachinesKeysAndNothingElse(t *testing.T) {
|
||||
perms, err := PermissionsFor(Principal{Kind: KindProvisioner, Node: "g14", Module: "mesh-provisioner",
|
||||
BoundBuckets: []string{"postgres_bound"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wantPub []string
|
||||
for _, bucket := range []string{"mesh-config_resolved", "postgres_bound"} {
|
||||
stream := "KV_" + bucket
|
||||
wantPub = append(wantPub,
|
||||
"$JS.API.STREAM.INFO."+stream,
|
||||
"$JS.API.DIRECT.GET."+stream+".$KV."+bucket+".g14.>",
|
||||
"$JS.API.CONSUMER.CREATE."+stream+".*.$KV."+bucket+".g14.>",
|
||||
"$JS.API.CONSUMER.DELETE."+stream+".>",
|
||||
"$JS.FC."+stream+".>")
|
||||
}
|
||||
// The assignments mirror whole, read and never written: a provider's consumers are bound to it in their own
|
||||
// machines' plans, which its machine's client reads to fill ${consumers:<provision>} (novox/hq ADR 0373, issue
|
||||
// 679). The mirror holds placement and plans, never a value of a secret.
|
||||
wantPub = append(wantPub,
|
||||
"$JS.API.STREAM.INFO.KV_mesh-controller_assignments",
|
||||
"$JS.API.DIRECT.GET.KV_mesh-controller_assignments.>",
|
||||
"$JS.API.CONSUMER.CREATE.KV_mesh-controller_assignments.>",
|
||||
"$JS.API.CONSUMER.DELETE.KV_mesh-controller_assignments.>",
|
||||
"$JS.FC.KV_mesh-controller_assignments.>")
|
||||
for _, bucket := range []string{"mesh-provisioner_needs", "mesh-provisioner_applied", "mesh-provisioner_collect"} {
|
||||
stream := "KV_" + bucket
|
||||
wantPub = append(wantPub,
|
||||
"$JS.API.STREAM.INFO."+stream,
|
||||
"$JS.API.DIRECT.GET."+stream+".$KV."+bucket+".g14.>",
|
||||
"$JS.API.CONSUMER.CREATE."+stream+".*.$KV."+bucket+".g14.>",
|
||||
"$JS.API.CONSUMER.DELETE."+stream+".>",
|
||||
"$JS.FC."+stream+".>",
|
||||
"$KV."+bucket+".g14.>")
|
||||
}
|
||||
wantPub = append(wantPub,
|
||||
"$JS.API.STREAM.INFO.KV_mesh-provisioner_registry",
|
||||
"$JS.API.DIRECT.GET.KV_mesh-provisioner_registry.>",
|
||||
"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_registry.>",
|
||||
"$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_registry.>",
|
||||
"$JS.FC.KV_mesh-provisioner_registry.>")
|
||||
wantPub = append(wantPub, "prov.applied.g14.>", "mesh.seat.mesh-vault.call.get.provisioner~g14",
|
||||
"prov.changed.g14.>")
|
||||
slices.Sort(wantPub)
|
||||
if !slices.Equal(perms.Publish, wantPub) {
|
||||
t.Errorf("publish:\n have %q\n want %q", perms.Publish, wantPub)
|
||||
}
|
||||
// Its own inbox, what the vault says changed (`secret-changed`: which, by whom, never a value), so a rotated
|
||||
// secret reaches its file without a new declaration (design 57 V4), and its own machine's tool runner asking
|
||||
// it for a carried module's config (ADR 0370 §3), which it answers on the ask's reply.
|
||||
if want := []string{"_INBOX.provisioner.g14.>", "mesh.mod.mesh-vault.event.secret-changed", "prov.ask.g14"}; !slices.Equal(perms.Subscribe, want) {
|
||||
t.Errorf("subscribe:\n have %q\n want %q", perms.Subscribe, want)
|
||||
}
|
||||
if !perms.AllowResponses || len(perms.PublishDeny) > 0 {
|
||||
t.Errorf("it answers what it is asked and needs no deny: %+v", perms)
|
||||
}
|
||||
// No write of any bucket but its own machine's keys of the server's needs, applied and collections: a domain
|
||||
// writes its own (ADR 0349 clause 3, research 051), and the registry is the server's alone.
|
||||
for _, p := range perms.Publish {
|
||||
if !SubjectsOverlap(p, "$KV.>") {
|
||||
continue
|
||||
}
|
||||
if !slices.Contains([]string{"$KV.mesh-provisioner_needs.g14.>", "$KV.mesh-provisioner_applied.g14.>",
|
||||
"$KV.mesh-provisioner_collect.g14.>"}, p) {
|
||||
t.Errorf("it may write a bucket: %s", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Each provider's bucket of entries, `<module>_bound` (mesh-sdk go/provider, issue 678), is read by every machine's
|
||||
// client for that machine's consumers' keys alone (`<node>.>`), to fill ${bound:<provision>:<key>} (issue 679): the
|
||||
// buckets of every module the mesh places that keeps state named bound, wherever it runs, and no other.
|
||||
func TestTheProvisionerClientReadsItsMachinesKeysOfEachProvidersBoundBucket(t *testing.T) {
|
||||
records := provisionerRecords()
|
||||
records.Assigned["ace"] = append(records.Assigned["ace"],
|
||||
Declared{Module: "postgres", State: []Bucket{{Module: "postgres", Name: "bound"}, {Module: "postgres", Name: "kept"}}},
|
||||
Declared{Module: "keycloak", State: []Bucket{{Module: "keycloak", Name: "bound"}}})
|
||||
records.Assigned["shanks"] = append(records.Assigned["shanks"],
|
||||
Declared{Module: "postgres", State: []Bucket{{Module: "postgres", Name: "bound"}}})
|
||||
users, err := Users(records)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, u := range users {
|
||||
if u.Kind != KindProvisioner {
|
||||
continue
|
||||
}
|
||||
if want := []string{"keycloak_bound", "postgres_bound"}; !slices.Equal(u.BoundBuckets, want) {
|
||||
t.Errorf("%s reads the bound buckets %v, want %v", u.Username(), u.BoundBuckets, want)
|
||||
}
|
||||
perms, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mine := "$JS.API.DIRECT.GET.KV_postgres_bound.$KV.postgres_bound." + u.Node + ".>"
|
||||
if !slices.Contains(perms.Publish, mine) {
|
||||
t.Errorf("%s may not read its machine's keys of postgres_bound", u.Username())
|
||||
}
|
||||
for _, p := range perms.Publish {
|
||||
if SubjectsOverlap(p, "$JS.API.DIRECT.GET.KV_postgres_kept.>") || SubjectsOverlap(p, "$KV.postgres_bound.>") ||
|
||||
SubjectsOverlap(p, "$JS.API.DIRECT.GET.KV_postgres_bound.$KV.postgres_bound.ace.>") {
|
||||
t.Errorf("%s is granted %s: another bucket, a write, or another machine's consumers", u.Username(), p)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := PermissionsFor(Principal{Kind: KindProvisioner, Node: "g14", Module: "mesh-provisioner",
|
||||
BoundBuckets: []string{"postgres_bound.>"}}); err == nil {
|
||||
t.Error("a bound bucket that is a pattern composed")
|
||||
}
|
||||
}
|
||||
|
||||
// A machine with no usable name reaches nothing: its grants would otherwise be a pattern over every machine.
|
||||
func TestAProvisionerClientNamingNoMachineIsRefused(t *testing.T) {
|
||||
for _, node := range []string{"", "g14.>", "*"} {
|
||||
if _, err := PermissionsFor(Principal{Kind: KindProvisioner, Node: node, Module: "mesh-provisioner"}); err == nil {
|
||||
t.Errorf("a provisioner client on %q composed", node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A whole mesh composes with the clients beside every other kind: nobody is granted a second writer's subject.
|
||||
func TestAMeshWithProvisionerClientsComposes(t *testing.T) {
|
||||
users, err := Users(provisionerRecords())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range users {
|
||||
users[i].PasswordHash = "x"
|
||||
}
|
||||
if _, err := ComposeAccounts(users); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// The client is asked on a subject its own machine's tool runner alone may publish (novox/hq ADR 0370 §3, issue
|
||||
// 685): across a whole mesh, no principal but g14's tool runner may ask g14's client, none but g14's client hears
|
||||
// it, and the client's word of what moved is heard by g14's tool runner alone.
|
||||
func TestTheProvisionerClientIsAskedByItsOwnMachinesToolRunnerAlone(t *testing.T) {
|
||||
records := provisionerRecords()
|
||||
records.Assigned["shanks"] = append(records.Assigned["shanks"], Declared{Module: RuntimeModule},
|
||||
Declared{Module: "dunst"})
|
||||
records.Assigned["g14"] = append(records.Assigned["g14"], Declared{Module: "claude-code"})
|
||||
users, err := Users(records)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users = append(users, Principal{Kind: KindPerson, Module: "jochen", Invokes: []string{"*"}})
|
||||
var asks, hears, told []string
|
||||
for _, u := range users {
|
||||
perms, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", u.Username(), err)
|
||||
}
|
||||
if slices.ContainsFunc(perms.Publish, func(p string) bool { return SubjectsOverlap(p, ProvisionerAskSubject("g14")) }) {
|
||||
asks = append(asks, u.Username())
|
||||
}
|
||||
if slices.ContainsFunc(perms.Subscribe, func(p string) bool { return SubjectsOverlap(p, ProvisionerAskSubject("g14")) }) {
|
||||
hears = append(hears, u.Username())
|
||||
}
|
||||
if slices.ContainsFunc(perms.Subscribe, func(p string) bool {
|
||||
return SubjectsOverlap(p, ProvisionerChangedSubject("g14", "claude-code"))
|
||||
}) {
|
||||
told = append(told, u.Username())
|
||||
}
|
||||
}
|
||||
if want := []string{"g14.node-tools"}; !slices.Equal(asks, want) {
|
||||
t.Errorf("g14's client may be asked by %v, want %v alone", asks, want)
|
||||
}
|
||||
if want := []string{"provisioner.g14"}; !slices.Equal(hears, want) {
|
||||
t.Errorf("g14's client's asks are heard by %v, want %v alone", hears, want)
|
||||
}
|
||||
if want := []string{"g14.node-tools"}; !slices.Equal(told, want) {
|
||||
t.Errorf("what g14's client says moved is heard by %v, want %v alone", told, want)
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,8 @@ func TestTheFactsTheGrantPermitsAreTheFactsTheMeshStates(t *testing.T) {
|
||||
states = append(states, link.KeyChecked)
|
||||
// And a walk kept in a new state, for the delivery it walks (novox/hq ADR 0239).
|
||||
states = append(states, link.KeyPlanMoved)
|
||||
// And a key of a record mirror written or removed (novox/hq ADR 0289, design 54 §3; design 57 F3).
|
||||
states = append(states, link.KeyAssignmentChanged, link.KeyDeclaredConfigChanged, link.KeyPredefinedChanged)
|
||||
for _, event := range states {
|
||||
if !slices.Contains(broker.ControllerStates, event) {
|
||||
t.Errorf("the mesh states %q and its account may not publish it", event)
|
||||
@@ -42,6 +44,11 @@ func TestTheFactsTheGrantPermitsAreTheFactsTheMeshStates(t *testing.T) {
|
||||
if len(broker.ControllerStates) != len(states) {
|
||||
t.Errorf("the grant permits %v, which is more than the mesh states", broker.ControllerStates)
|
||||
}
|
||||
// The record mirrors a module may read are the ones the bus holds (design 54 §5, design 57 F3).
|
||||
if !slices.Equal(catalogue.ControllerMirrors, broker.ControllerMirrors()) {
|
||||
t.Errorf("the catalogue lets a module read the controller's mirrors %v and the bus holds %v",
|
||||
catalogue.ControllerMirrors, broker.ControllerMirrors())
|
||||
}
|
||||
// **And the seat says it.** A seat carries the protocol of its role (novox/hq ADR 0129), so the
|
||||
// facts the control plane states are the seat's `emits` — which is what lets anything else declare
|
||||
// that it consumes them, and what the subject-agreement check reads to know they have an owner.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// The catalogue's list of the mesh's own stream names, which a module's stream may not take, is the broker's
|
||||
// own list (novox/hq ADR 0363 §1): read from MeshStreams, so a stream added there cannot be forgotten there.
|
||||
func TestTheCataloguesReservedStreamNamesAreTheMeshsOwn(t *testing.T) {
|
||||
var own []string
|
||||
for _, s := range MeshStreams() {
|
||||
own = append(own, s.Name)
|
||||
}
|
||||
reserved := append([]string(nil), catalogue.MeshStreamNames...)
|
||||
slices.Sort(own)
|
||||
slices.Sort(reserved)
|
||||
if !slices.Equal(own, reserved) {
|
||||
t.Fatalf("the broker's own streams are %v, the catalogue reserves %v", own, reserved)
|
||||
}
|
||||
}
|
||||
@@ -59,8 +59,17 @@ type Stream struct {
|
||||
// DuplicatesSeconds is the window in which a message id published twice is kept once; zero for the
|
||||
// server's default (two minutes).
|
||||
DuplicatesSeconds int
|
||||
// AllowMsgTTL lets a message carry a time to live of its own (`Nats-TTL`), for what must not outlive the
|
||||
// stream it came from (novox/hq ADR 0376 §3, §5).
|
||||
AllowMsgTTL bool
|
||||
// Restricted marks a module's stream restricted by a grant (novox/hq ADR 0363 §4), in the stream's own
|
||||
// metadata, so whoever keeps one of its messages reads it from the bus (ADR 0376 §3).
|
||||
Restricted bool
|
||||
}
|
||||
|
||||
// RestrictedMetadata is the key a restricted stream's metadata says it under.
|
||||
const RestrictedMetadata = "mesh-restricted"
|
||||
|
||||
// AssignmentsStream holds every assignment's membership, the newest per subject.
|
||||
const AssignmentsStream = "ASSIGNMENTS"
|
||||
|
||||
@@ -174,6 +183,8 @@ func MeshStreams() []Stream {
|
||||
Retention: RetentionLimits,
|
||||
MaxAge: 7 * 24 * 60 * 60,
|
||||
MaxMsgsPerSubject: 10000,
|
||||
// A restricted dead letter delivered again keeps what is left of its stream's age (ADR 0376 §5).
|
||||
AllowMsgTTL: true,
|
||||
Why: "a subscriber that was down catches up; tool traffic under the same prefix is " +
|
||||
"excluded by the event token; per-subject caps keep a noisy emitter from " +
|
||||
"evicting a quiet one without splitting the stream",
|
||||
@@ -194,13 +205,34 @@ func MeshStreams() []Stream {
|
||||
MaxBytes: DeadLettersBytes,
|
||||
DiscardNew: true,
|
||||
DuplicatesSeconds: 24 * 60 * 60,
|
||||
AllowMsgTTL: true,
|
||||
Why: "every message a consumer gave up on, with its consumer, subject, deliveries and when, kept " +
|
||||
"until a person delivers it again or drops it with why (novox/hq issue 330); no age, and full " +
|
||||
"it refuses the next copy rather than drop one it holds",
|
||||
"until a person delivers it again or drops it with why (novox/hq issue 330); no age but a restricted " +
|
||||
"stream's message, which ages out with its stream (ADR 0376), and full it refuses the next copy " +
|
||||
"rather than drop one it holds",
|
||||
},
|
||||
{
|
||||
Name: ProvAppliedStream,
|
||||
Subjects: []string{ProvAppliedSubjects},
|
||||
Retention: RetentionLastPerSubject,
|
||||
Direct: true,
|
||||
MaxAge: 7 * 24 * 60 * 60,
|
||||
Why: "each machine's provisioner client's newest word on each module's files, applied, waiting or " +
|
||||
"failed with the reason (novox/hq ADR 0349 §4): what a walk's end and a gate wait for (ADR 0362, " +
|
||||
"ADR 0349 §15), read by subject; a week, as the events are",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ProvAppliedStream keeps what each machine's provisioner client last said of each module's files, newest per
|
||||
// subject (novox/hq ADR 0349 §4, ADR 0362, design 57 P6): `prov.applied.<node>.<module>` lies outside `mesh.*`, so
|
||||
// no other stream captures it, and a report said while no controller listened is still there to be read.
|
||||
const ProvAppliedStream = "PROV_APPLIED"
|
||||
|
||||
// ProvAppliedSubjects is every machine's client's reports: `prov.applied.<node>.<module>`, a module's name dots and
|
||||
// all (ProvisionerAppliedSubject).
|
||||
const ProvAppliedSubjects = "prov.applied.>"
|
||||
|
||||
// DeliverSubjectFor is where a push consumer's messages land.
|
||||
//
|
||||
// **Per consumer, which means per stream as well as per name** (novox/hq 04-ISSUES/146). A push
|
||||
@@ -313,7 +345,10 @@ var ControllerStates = []string{"applied", "refused", "built-before",
|
||||
// the pull request's status, and anybody else may read.
|
||||
"checked",
|
||||
// And a walk kept in a new state (novox/hq ADR 0239): what the delivery it walks reads its steps from.
|
||||
"plan-moved"}
|
||||
"plan-moved",
|
||||
// And a key of a record mirror written or removed (novox/hq ADR 0289, design 54 §3): the assignments and
|
||||
// each registered module's declared config (design 57 F3).
|
||||
"assignment-changed", "declared-config-changed", "predefined-changed"}
|
||||
|
||||
// BusAdvisories are what the bus server says about the mesh's own account that the controller
|
||||
// reads (novox/hq to-be 45 §3, S9): a durable consumer that handed a message over as often as it
|
||||
|
||||
@@ -131,6 +131,9 @@ func TestEachStreamCarriesTheRetentionItsShapeNeeds(t *testing.T) {
|
||||
// kept until somebody acts.
|
||||
"DEAD_LETTER_NOTICES": RetentionWorkQueue,
|
||||
"DEAD_LETTERS": RetentionLimits,
|
||||
// What each machine's provisioner client last said of each module's files (design 57 P6): the newest
|
||||
// word per module, what a walk's end reads.
|
||||
"PROV_APPLIED": RetentionLastPerSubject,
|
||||
}
|
||||
got := map[string]Retention{}
|
||||
for _, s := range MeshStreams() {
|
||||
|
||||
+2
-2
@@ -24,7 +24,7 @@ accounts {
|
||||
jetstream: enabled
|
||||
users = [
|
||||
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
|
||||
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*"] }
|
||||
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_assignments.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_declared-config.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$KV.mesh-controller_predefined.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.assignment-changed", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.declared-config-changed", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.predefined-changed", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*", "mesh.seat.operator-channel.tool.tell"] }
|
||||
subscribe: { allow: ["$JS.API.>", "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.*.event.provisioner.retirement", "mesh.mod.gitea.event.pull.merged", "mesh.mod.gitea.event.pull.updated", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built", "mesh.seat.operator-channel.event.decided.mesh-controller"] }
|
||||
allow_responses: { max: 1, ttl: "1m" }
|
||||
} }
|
||||
@@ -34,7 +34,7 @@ accounts {
|
||||
} }
|
||||
{ user: "node.one", password: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn", permissions: {
|
||||
publish: { allow: ["$JS.ACK.NODES.one.>", "$JS.API.CONSUMER.INFO.NODES.one", "$SRV.PING.node-tools.one", "mesh.control.one.>"] }
|
||||
subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.node.one.ask.hand-over", "mesh.node.one.ask.judge-directories", "mesh.node.one.ask.report", "mesh.node.one.ask.setuid-search", "mesh.node.one.declare"] }
|
||||
subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.node.one.ask.hand-over", "mesh.node.one.ask.judge-directories", "mesh.node.one.ask.move-secret", "mesh.node.one.ask.report", "mesh.node.one.ask.setuid-search", "mesh.node.one.declare"] }
|
||||
allow_responses: { max: 1, ttl: "1m" }
|
||||
} }
|
||||
{ user: "one.nats", password: "$2a$11$bbbbbbbbbbbbbbbbbbbbbb", permissions: {
|
||||
|
||||
+109
-3
@@ -2,6 +2,7 @@ package broker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
)
|
||||
|
||||
@@ -36,10 +37,20 @@ type Declared struct {
|
||||
Watches []Seat
|
||||
// Invokes are the tools it calls, `<module>.<tool>` or `*` (novox/hq ADR 0152).
|
||||
Invokes []string
|
||||
// Waits are the event families a session may wait on through the tool runner, the tool runner's module's
|
||||
// alone (novox/hq ADR 0367 §2): its account subscribes them for a call, through no consumer.
|
||||
Waits []Wait
|
||||
// State is the state it keeps, each a bucket its instances write (novox/hq ADR 0201).
|
||||
State []Bucket
|
||||
// Logs are the logs it keeps, each a stream its instances append to (novox/hq ADR 0297).
|
||||
Logs []Log
|
||||
// Streams are the streams it owns, each its events' one place (novox/hq ADR 0363).
|
||||
Streams []ModuleStream
|
||||
// Hears are the module streams it hears on this machine, each with the subjects it was granted
|
||||
// (novox/hq ADR 0363 §5); HearsAStream says its `consumes` names a module that owns a stream, granted
|
||||
// or not, so its membership is issued again when a grant changes.
|
||||
Hears []Heard
|
||||
HearsAStream bool
|
||||
// Reads are other modules' state it reads, each `<module>.<name>` (novox/hq ADR 0201).
|
||||
Reads []string
|
||||
// KeyedReads are keys of other modules' state it reads, each one key alone: what a seat's holder is
|
||||
@@ -58,6 +69,10 @@ type Declared struct {
|
||||
// RunsAs is the account the module runs as in a runtime of its own (novox/hq ADR 0259 §8): it is never
|
||||
// carried by the machine's runtime, and reaches the bus on its own account.
|
||||
RunsAs string
|
||||
// ProvisionsNode says the module runs the machine's provisioner client: it claims NodeProvisionerSeat and
|
||||
// declares the own secret ProvisionerSecret its account is delivered in (novox/hq ADR 0349 clause 11, design 57
|
||||
// P3). The client is given a bus account of its own, never the module's or the tool runner's.
|
||||
ProvisionsNode bool
|
||||
}
|
||||
|
||||
// Records is what composing a user list needs to know about the mesh, and nothing more.
|
||||
@@ -87,7 +102,7 @@ type Records struct {
|
||||
// is a mesh that cannot be told anything, and there is no state of the records in which that is
|
||||
// correct.
|
||||
func Users(r Records) ([]Principal, error) {
|
||||
out := []Principal{{Kind: KindController, Uses: asksSeatOf(r), AsksIssued: issuedOf(r)}}
|
||||
out := []Principal{{Kind: KindController, Uses: asksSeatOf(r), AsksIssued: issuedOf(r), AsksTheVault: vaultVerbsOf(r)}}
|
||||
|
||||
for _, node := range sortedCopy(r.Nodes) {
|
||||
witness := false
|
||||
@@ -98,7 +113,17 @@ func Users(r Records) ([]Principal, error) {
|
||||
}
|
||||
checks = append(checks, d.Checks...)
|
||||
}
|
||||
out = append(out, Principal{Kind: KindNode, Node: node, WitnessesController: witness, Checks: checks})
|
||||
out = append(out, Principal{Kind: KindNode, Node: node, WitnessesController: witness, Checks: checks,
|
||||
MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey)})
|
||||
// **And the machine's provisioner client, on an account of its own** (novox/hq ADR 0349 clause 11): one
|
||||
// per machine that runs it, delivered to the module running it.
|
||||
for _, d := range r.Assigned[node] {
|
||||
if d.ProvisionsNode {
|
||||
out = append(out, Principal{Kind: KindProvisioner, Node: node, Module: d.Module,
|
||||
BoundBuckets: boundBucketsOf(r)})
|
||||
break
|
||||
}
|
||||
}
|
||||
// **Where the runtime is assigned, the machine gets one runtime principal in place of the
|
||||
// runtime module's own** (novox/hq ADR 0175, to-be 38). It carries every module on the
|
||||
// node: its serving grants are the union of theirs. Every other module keeps its own
|
||||
@@ -129,13 +154,21 @@ func Users(r Records) ([]Principal, error) {
|
||||
Holds: d.Holds, Uses: d.Uses, Watches: d.Watches, Invokes: d.Invokes,
|
||||
State: stateNames(d.State), Reads: d.Reads, SnapshotsTheBus: d.SnapshotsTheBus,
|
||||
PerMachine: perMachineNames(d.State), KeyedReads: d.KeyedReads,
|
||||
Logs: logNames(d.Logs),
|
||||
Logs: logNames(d.Logs), Streams: d.Streams, Hears: ComposedHears(d),
|
||||
})
|
||||
// **The vault's holder asks the desks and the issuing providers itself** (issue 723), under the
|
||||
// account its own runtime runs on (ADR 0259 §8's runs-as): the one module granted it.
|
||||
if holdsSeat(d, VaultSeat) {
|
||||
out[len(out)-1].AsksTheDesk = true
|
||||
out[len(out)-1].AsksIssued = issuedOf(r)
|
||||
}
|
||||
}
|
||||
if runtimeHere {
|
||||
var carried []Declared
|
||||
for _, d := range r.Assigned[node] {
|
||||
if d.RunsAs == "" {
|
||||
// Never a restricted stream for what the tool runner carries (novox/hq ADR 0376 §1).
|
||||
d.Hears = ComposedHears(d)
|
||||
carried = append(carried, d)
|
||||
}
|
||||
}
|
||||
@@ -242,3 +275,76 @@ func asksSeatOf(r Records) []Seat {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VaultSeat is the vault's seat: its holder alone asks the desks and the issuing providers in its own name.
|
||||
const VaultSeat = "mesh-vault"
|
||||
|
||||
func holdsSeat(d Declared, seat string) bool {
|
||||
for _, s := range d.Holds {
|
||||
if s.Name == seat {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// vaultVerbsOf are the vault's intake verbs its holder serves, as the records say (issue 675): what the controller
|
||||
// is granted to ask it in its own name. None while no holder serves them.
|
||||
func vaultVerbsOf(r Records) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, node := range sortedCopy(r.Nodes) {
|
||||
for _, d := range r.Assigned[node] {
|
||||
for _, s := range d.Holds {
|
||||
for _, v := range VerbsTheControllerAsksTheVault {
|
||||
if s.Name == v.Seat && slices.Contains(s.Serves, v.Verb) && !seen[v.Verb] {
|
||||
seen[v.Verb] = true
|
||||
out = append(out, v.Verb)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// servesMoveIn says the vault's holder serves move-in, as the records say (issue 676): what lets each machine's
|
||||
// node-engine ask it in its own name.
|
||||
func servesMoveIn(r Records) bool { return servesVerb(r, VaultMoveIn) }
|
||||
|
||||
// servesVerb says a holder of verb's seat serves it, as the records say.
|
||||
func servesVerb(r Records, verb SeatVerb) bool {
|
||||
for _, node := range r.Nodes {
|
||||
for _, d := range r.Assigned[node] {
|
||||
for _, s := range d.Holds {
|
||||
if s.Name == verb.Seat && slices.Contains(s.Serves, verb.Verb) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// boundBucketsOf is every provider's bucket of entries, `<module>_bound` (mesh-sdk go/provider, issue 678): the state
|
||||
// named bound of every module placed anywhere, once each, in name order. A consumer on any machine may be bound to
|
||||
// it, so each machine's provisioner client reads its own keys of each (issue 679).
|
||||
func boundBucketsOf(r Records) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, mods := range r.Assigned {
|
||||
for _, d := range mods {
|
||||
for _, b := range d.State {
|
||||
if b.Name == BoundState {
|
||||
seen[b.Bucket()] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for b := range seen {
|
||||
out = append(out, b)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// vaultRecords is a mesh of two machines: anchor, a desk whose runtime carries the desk's prompt (rofi holding
|
||||
// node-launcher) beside an issuing provider (plex) and a module with a tool of its own; and novox, where the vault
|
||||
// runs in a runtime of its own on its own account (ADR 0259 §8's runs-as).
|
||||
func vaultRecords() Records {
|
||||
launcher := Seat{Name: "node-launcher", Scope: "node", Serves: []string{"menu", "secret"}}
|
||||
vault := Seat{Name: VaultSeat, Scope: "mesh", Serves: []string{"get", "accept", "secret-ask", "issued", "rotate"}}
|
||||
return Records{Nodes: []string{"anchor", "novox"}, Assigned: map[string][]Declared{
|
||||
"anchor": {{Module: RuntimeModule}, {Module: "rofi", Holds: []Seat{launcher}, NoAccount: true},
|
||||
{Module: "plex", Serves: []string{"plex_issued_credential", "plex_status"},
|
||||
Issued: []string{"plex_issued_credential"}, NoAccount: true},
|
||||
{Module: "other", Serves: []string{"tool"}, NoAccount: true}},
|
||||
"novox": {{Module: RuntimeModule}, {Module: "mesh-vault", Holds: []Seat{vault}, RunsAs: "mesh-vault"}},
|
||||
}}
|
||||
}
|
||||
|
||||
// The vault's holder may ask the desk's prompt and each issuing tool in its own name, and nothing else outside its
|
||||
// seat; no other module and no runtime is granted it (issue 723).
|
||||
func TestTheVaultAsksTheDeskAndTheIssuingToolsInItsOwnNameAlone(t *testing.T) {
|
||||
users, err := Users(vaultRecords())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
perms := map[string]Permissions{}
|
||||
for _, u := range users {
|
||||
p, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
perms[u.Username()] = p
|
||||
}
|
||||
vault, ok := perms["novox.mesh-vault"]
|
||||
if !ok {
|
||||
t.Fatal("the vault has no account of its own")
|
||||
}
|
||||
token := CallerToken("novox.mesh-vault")
|
||||
for _, s := range []string{
|
||||
"mesh.seat.node-launcher.call.secret.anchor." + token,
|
||||
"mesh.mod.plex.call.plex_issued_credential.anchor." + token,
|
||||
} {
|
||||
if !MayPublish(vault, s) {
|
||||
t.Errorf("the vault may not ask %s", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{
|
||||
"mesh.seat.node-launcher.tool.secret.anchor",
|
||||
"mesh.seat.node-launcher.call.secret.anchor.controller",
|
||||
"mesh.seat.node-launcher.call.menu.anchor." + token,
|
||||
"mesh.mod.rofi.call.node-launcher.secret.anchor." + token,
|
||||
"mesh.mod.plex.call.plex_status.anchor." + token,
|
||||
"mesh.mod.plex.tool.plex_issued_credential.anchor",
|
||||
"mesh.mod.other.call.tool.anchor." + token,
|
||||
"mesh.mod.other.tool.tool.anchor",
|
||||
"mesh.seat.mesh-controller.call.status." + token,
|
||||
} {
|
||||
if MayPublish(vault, s) {
|
||||
t.Errorf("the vault may publish %s", s)
|
||||
}
|
||||
}
|
||||
// Outside its own namespace and its own seat, the vault publishes nothing but those two calls, its inbox's
|
||||
// answers and the stream API's reads of what it keeps.
|
||||
for _, s := range vault.Publish {
|
||||
switch {
|
||||
case strings.HasPrefix(s, "mesh.mod.mesh-vault."), strings.HasPrefix(s, "mesh.seat."+VaultSeat+"."),
|
||||
s == "mesh.seat.node-launcher.call.secret.*."+token,
|
||||
s == "mesh.mod.plex.call.plex_issued_credential.*."+token,
|
||||
strings.HasPrefix(s, "$JS."), strings.HasPrefix(s, "$KV."), strings.HasPrefix(s, "_INBOX."),
|
||||
strings.HasPrefix(s, "mesh.discover."), strings.HasPrefix(s, "$SRV."):
|
||||
default:
|
||||
t.Errorf("the vault is granted %s, outside its seat", s)
|
||||
}
|
||||
}
|
||||
// Nobody else is granted the vault's calls; the controller keeps today's path until R1 (issue 682).
|
||||
for name, p := range perms {
|
||||
if name == "novox.mesh-vault" {
|
||||
continue
|
||||
}
|
||||
for _, s := range []string{"mesh.seat.node-launcher.call.secret.anchor." + token,
|
||||
"mesh.mod.plex.call.plex_issued_credential.anchor." + token} {
|
||||
if MayPublish(p, s) {
|
||||
t.Errorf("%s may call in the vault's name: %s", name, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
controller := perms["controller"]
|
||||
for _, s := range []string{"mesh.seat.node-launcher.tool.secret.anchor",
|
||||
"mesh.mod.plex.call.plex_issued_credential.anchor.controller"} {
|
||||
if !MayPublish(controller, s) {
|
||||
t.Errorf("the controller lost today's path %s before R1", s)
|
||||
}
|
||||
}
|
||||
if MayPublish(perms["anchor.node-tools"], "mesh.seat.node-launcher.call.secret.anchor."+CallerToken("anchor.node-tools")) {
|
||||
t.Error("a machine's runtime may ask the desk's prompt")
|
||||
}
|
||||
}
|
||||
|
||||
// **On a real server, as composed** (issue 723): the vault's account asks the desk's prompt and an issuing tool
|
||||
// and is answered, naming itself; the server refuses it any other call; and the controller is still answered on
|
||||
// today's path. That only sealed values cross is the vault's test (mesh-catalog, mesh-vault intake_test.go).
|
||||
func TestOnARealBusTheVaultsAccountIsAnsweredByTheDeskAndTheProviderAndNothingElse(t *testing.T) {
|
||||
users, err := Users(vaultRecords())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range users {
|
||||
users[i].PasswordHash = string(hash)
|
||||
}
|
||||
accounts, err := ComposeAccounts(users)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conf := filepath.Join(t.TempDir(), "bus.conf")
|
||||
if err := os.WriteFile(conf, []byte("listen: 127.0.0.1:-1\njetstream { store_dir: "+
|
||||
`"`+t.TempDir()+`"`+" }\n"+accounts), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts, err := server.ProcessConfigFile(conf)
|
||||
if err != nil {
|
||||
t.Fatalf("the composed accounts do not parse: %v", err)
|
||||
}
|
||||
opts.NoLog, opts.NoSigs = true, true
|
||||
s, err := server.NewServer(opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go s.Start()
|
||||
if !s.ReadyForConnections(10 * time.Second) {
|
||||
t.Fatal("the bus did not come up")
|
||||
}
|
||||
defer s.Shutdown()
|
||||
|
||||
// anchor's runtime serves the desk's prompt and plex's issuing tool, on every subject it is granted, and says
|
||||
// which subject it heard.
|
||||
holder, err := nats.Connect(s.ClientURL(), nats.UserInfo("anchor.node-tools", "pw"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer holder.Close()
|
||||
heard := make(chan string, 8)
|
||||
answer := func(m *nats.Msg) {
|
||||
heard <- m.Subject
|
||||
_ = m.Respond([]byte(`{"result":{"sealed":"c2VhbGVkLXRvLXRoZS1jYWxs"}}`))
|
||||
}
|
||||
for _, subject := range []string{"mesh.seat.node-launcher.call.secret.anchor.*",
|
||||
"mesh.seat.node-launcher.tool.secret.anchor", "mesh.mod.plex.call.plex_issued_credential.anchor.*",
|
||||
"mesh.mod.other.call.tool.anchor.*", "mesh.seat.node-launcher.call.menu.anchor.*"} {
|
||||
sub, err := holder.Subscribe(subject, answer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = holder.Flush()
|
||||
if !sub.IsValid() {
|
||||
t.Fatalf("the desk's runtime may not hear %s", subject)
|
||||
}
|
||||
}
|
||||
|
||||
refusals := make(chan error, 16)
|
||||
vault, err := nats.Connect(s.ClientURL(), nats.UserInfo("novox.mesh-vault", "pw"),
|
||||
nats.CustomInboxPrefix("_INBOX.novox.mesh-vault"),
|
||||
nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) { refusals <- err }))
|
||||
if err != nil {
|
||||
t.Fatalf("the vault's own account cannot connect: %v", err)
|
||||
}
|
||||
defer vault.Close()
|
||||
token := CallerToken("novox.mesh-vault")
|
||||
for _, subject := range []string{"mesh.seat.node-launcher.call.secret.anchor." + token,
|
||||
"mesh.mod.plex.call.plex_issued_credential.anchor." + token} {
|
||||
if _, err := vault.Request(subject, []byte(`{"seal_to":"a2V5LW9mLXRoZS1jYWxs"}`), 3*time.Second); err != nil {
|
||||
t.Fatalf("the vault's call %s was not answered: %v", subject, err)
|
||||
}
|
||||
if got := <-heard; got != subject {
|
||||
t.Fatalf("the holder heard %s, want %s", got, subject)
|
||||
}
|
||||
}
|
||||
for _, subject := range []string{"mesh.seat.node-launcher.tool.secret.anchor",
|
||||
"mesh.seat.node-launcher.call.secret.anchor.controller",
|
||||
"mesh.seat.node-launcher.call.menu.anchor." + token,
|
||||
"mesh.mod.other.call.tool.anchor." + token} {
|
||||
if err := vault.Publish(subject, []byte(`{}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = vault.Flush()
|
||||
select {
|
||||
case err := <-refusals:
|
||||
if !errors.Is(err, nats.ErrPermissionViolation) {
|
||||
t.Errorf("%s: the server said %v, want a permissions violation", subject, err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Errorf("the server did not refuse the vault %s", subject)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case got := <-heard:
|
||||
t.Errorf("a call the vault may not make reached the holder: %s", got)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
|
||||
// The controller is still answered on today's path, for the modules filled the old way (until R1).
|
||||
controller, err := nats.Connect(s.ClientURL(), nats.UserInfo("controller", "pw"),
|
||||
nats.CustomInboxPrefix("_INBOX.controller"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer controller.Close()
|
||||
if _, err := controller.Request("mesh.seat.node-launcher.tool.secret.anchor", []byte(`{}`), 3*time.Second); err != nil {
|
||||
t.Errorf("the controller's call to the desk on today's path: %v", err)
|
||||
}
|
||||
<-heard
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// What a session may wait on through the mesh MCP server (novox/hq ADR 0367).
|
||||
//
|
||||
// **A grant apart from `consumes`, and the tool runner's alone.** The tool runner's module names in its
|
||||
// manifest the event families a session may wait on, one by one; the runtime's account is granted a plain
|
||||
// subscription on those subjects and nothing wider — no durable consumer, no queue of its own — and its
|
||||
// membership names the same list, from which `mesh_wait` refuses any other. Both are read from this one
|
||||
// composition, so the grant and what the tool runner offers cannot disagree, and nothing but the manifest
|
||||
// adds a family (§6).
|
||||
|
||||
// Wait is one event family the tool runner's module declares: its name as the manifest gives it, and the
|
||||
// emitter — a seat or a module — and the event, as the inventory resolved them.
|
||||
type Wait struct {
|
||||
// Name is the family as the manifest names it, `<emitter>.<event>`: what a session asks for.
|
||||
Name string
|
||||
Emitter string
|
||||
// Seat says the emitter is a seat, whose events live under the seat's namespace (novox/hq ADR 0121).
|
||||
Seat bool
|
||||
// Event is the event, with any further token the subject carries: `decided.claude-code`, the asker of a
|
||||
// seat that names it last (ADR 0259 §3).
|
||||
Event string
|
||||
// Last names what the seat's event subject ends in — "kind" for a kinded bench, "asker" for a seat naming
|
||||
// its caller (ADR 0259 §3) — and is empty for an event that ends at its name. A family needing it is waited
|
||||
// on with it given, and refused without.
|
||||
Last string
|
||||
}
|
||||
|
||||
// WaitIssued is one family as a membership issues it: the name a session asks for, and its subject.
|
||||
type WaitIssued struct {
|
||||
Event string `json:"event"`
|
||||
Subject string `json:"subject"`
|
||||
}
|
||||
|
||||
// WaitSubject is where a family's events land. Each token is checked before it becomes a subject: a
|
||||
// wildcard, a dot inside a name or an empty token would widen the grant.
|
||||
func WaitSubject(w Wait) (string, error) {
|
||||
if !safeSubject.MatchString(w.Emitter) {
|
||||
return "", fmt.Errorf("%q cannot name an emitter of a waited event: it would widen the subject", w.Emitter)
|
||||
}
|
||||
for _, t := range strings.Split(w.Event, ".") {
|
||||
if !safeSubject.MatchString(t) {
|
||||
return "", fmt.Errorf("%q cannot name a waited event: each of its names is plain, never a wildcard "+
|
||||
"(novox/hq ADR 0367 §2)", w.Event)
|
||||
}
|
||||
}
|
||||
if w.Last != "" && !strings.Contains(w.Event, ".") {
|
||||
return "", fmt.Errorf("%s names no %s: %s's events are said for each %s, so a family is %s.%s.<%s>; "+
|
||||
"without it no event would ever be heard (novox/hq ADR 0367 §5)",
|
||||
w.Name, w.Last, w.Emitter, w.Last, w.Emitter, w.Event, w.Last)
|
||||
}
|
||||
if w.Seat {
|
||||
return "mesh.seat." + w.Emitter + ".event." + w.Event, nil
|
||||
}
|
||||
return "mesh.mod." + w.Emitter + ".event." + w.Event, nil
|
||||
}
|
||||
|
||||
// waitGrants is the subscription the tool runner's account is granted for its module's families, and an
|
||||
// error for a carried module other than the tool runner's that declares any (ADR 0367 §2).
|
||||
func waitGrants(carries []Declared) ([]string, error) {
|
||||
var sub []string
|
||||
for _, d := range carries {
|
||||
if len(d.Waits) == 0 {
|
||||
continue
|
||||
}
|
||||
if d.Module != RuntimeModule {
|
||||
return nil, fmt.Errorf("%s declares what a session waits on; only %s, the tool runner's module, "+
|
||||
"does (novox/hq ADR 0367 §2)", d.Module, RuntimeModule)
|
||||
}
|
||||
for _, w := range d.Waits {
|
||||
s, err := WaitSubject(w)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sub = append(sub, s)
|
||||
}
|
||||
}
|
||||
return sub, nil
|
||||
}
|
||||
|
||||
// waitsIssuedFor is the families a membership names: the tool runner's module's, and nobody else's.
|
||||
func waitsIssuedFor(d Declared) []WaitIssued {
|
||||
if d.Module != RuntimeModule {
|
||||
return nil
|
||||
}
|
||||
var out []WaitIssued
|
||||
for _, w := range d.Waits {
|
||||
s, err := WaitSubject(w)
|
||||
if err != nil {
|
||||
continue // refused where the account is composed, which names it
|
||||
}
|
||||
out = append(out, WaitIssued{Event: w.Name, Subject: s})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The families ADR 0367 §5 starts the grant with, resolved as the inventory resolves a manifest: a seat's
|
||||
// event under the seat's namespace, a module's under the module's.
|
||||
var firstFamilies = []Wait{
|
||||
{Name: "mesh-knowledge.reviewed", Emitter: "mesh-knowledge", Event: "reviewed"},
|
||||
{Name: "mesh-delivery.transition", Emitter: "mesh-delivery", Event: "transition"},
|
||||
{Name: "gitea.forge.status", Emitter: "gitea", Event: "forge.status"},
|
||||
{Name: "mesh-controller.checked", Emitter: "mesh-controller", Seat: true, Event: "checked"},
|
||||
{Name: "operator-channel.decided.claude-code", Emitter: "operator-channel", Seat: true, Event: "decided.claude-code"},
|
||||
}
|
||||
|
||||
var firstSubjects = []string{
|
||||
"mesh.mod.gitea.event.forge.status",
|
||||
"mesh.mod.mesh-delivery.event.transition",
|
||||
"mesh.mod.mesh-knowledge.event.reviewed",
|
||||
"mesh.seat.mesh-controller.event.checked",
|
||||
"mesh.seat.operator-channel.event.decided.claude-code",
|
||||
}
|
||||
|
||||
func runtimeCarrying(waits []Wait) Principal {
|
||||
return Principal{Kind: KindNodeTools, Node: "desk", Module: RuntimeModule, PasswordHash: "x", Carries: []Declared{
|
||||
{Module: "shop", Emits: []string{"order.placed"}, Consumes: []string{"billing.paid"}, Serves: []string{"price"}},
|
||||
{Module: RuntimeModule, Invokes: []string{"*"}, Waits: waits},
|
||||
}}
|
||||
}
|
||||
|
||||
func without(all, some []string) []string {
|
||||
drop := map[string]bool{}
|
||||
for _, s := range some {
|
||||
drop[s] = true
|
||||
}
|
||||
var out []string
|
||||
for _, s := range all {
|
||||
if !drop[s] {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// novox/hq ADR 0367 "How it is checked": the account's wait grant equals the manifest's families. The tool
|
||||
// runner's account subscribes exactly the families' subjects beyond what it had without them — nothing
|
||||
// published, nothing wider — and the membership it is issued names the same subjects.
|
||||
func TestTheToolRunnersWaitGrantIsTheManifestsFamiliesAlone(t *testing.T) {
|
||||
before, err := PermissionsFor(runtimeCarrying(nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := PermissionsFor(runtimeCarrying(firstFamilies))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
added := without(after.Subscribe, before.Subscribe)
|
||||
sort.Strings(added)
|
||||
if !reflect.DeepEqual(added, firstSubjects) {
|
||||
t.Errorf("the wait grant is %v, want the families' subjects %v", added, firstSubjects)
|
||||
}
|
||||
if extra := without(after.Publish, before.Publish); len(extra) > 0 {
|
||||
t.Errorf("waiting granted a publish: %v", extra)
|
||||
}
|
||||
for _, s := range after.Subscribe {
|
||||
if strings.HasPrefix(s, "mesh.mod.*.event") || s == "mesh.>" || s == ">" || strings.HasSuffix(s, ".event.>") {
|
||||
t.Errorf("the runtime may subscribe every event: %s", s)
|
||||
}
|
||||
}
|
||||
for _, other := range []string{"mesh.seat.operator-channel.event.decided.shop",
|
||||
"mesh.mod.mesh-knowledge.event.proposed", "mesh.mod.gitea.event.forge.push"} {
|
||||
if MaySubscribe(after, other) {
|
||||
t.Errorf("the runtime may subscribe %s, which no family names", other)
|
||||
}
|
||||
}
|
||||
|
||||
issued := MembershipFor("desk", runtimeCarrying(firstFamilies).Carries[1], Placements{})
|
||||
var subjects []string
|
||||
for _, w := range issued.Waits {
|
||||
subjects = append(subjects, w.Subject)
|
||||
}
|
||||
sort.Strings(subjects)
|
||||
if !reflect.DeepEqual(subjects, firstSubjects) {
|
||||
t.Errorf("the membership names %v, want the grant %v", subjects, firstSubjects)
|
||||
}
|
||||
if issued.Waits[0].Event != "mesh-knowledge.reviewed" {
|
||||
t.Errorf("a family is issued under its manifest name: %+v", issued.Waits[0])
|
||||
}
|
||||
if shop := MembershipFor("desk", runtimeCarrying(firstFamilies).Carries[0], Placements{}); len(shop.Waits) > 0 {
|
||||
t.Errorf("a module beside the runtime is issued waits: %+v", shop.Waits)
|
||||
}
|
||||
}
|
||||
|
||||
// **A subscription for the call, never a durable consumer** (ADR 0367 §2): composing the users makes no
|
||||
// consumer that filters a family, and none for the runtime's module at all.
|
||||
func TestAWaitMakesNoDurableConsumer(t *testing.T) {
|
||||
for _, c := range ConsumersOf([]Principal{runtimeCarrying(firstFamilies)}) {
|
||||
if c.Module == RuntimeModule {
|
||||
t.Errorf("the runtime's module has a durable consumer: %+v", c.Consumer)
|
||||
}
|
||||
for _, f := range c.Consumer.Filters {
|
||||
for _, s := range firstSubjects {
|
||||
if f == s {
|
||||
t.Errorf("%s's consumer filters the family %s", c.Module, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only the tool runner's module waits (ADR 0367 §2): a carried module declaring waits is refused at
|
||||
// composition, naming it, and no name that would widen a subject is composed.
|
||||
func TestAWaitIsComposedOnlyForTheToolRunnerAndOnlyWhole(t *testing.T) {
|
||||
p := runtimeCarrying(nil)
|
||||
p.Carries[0].Waits = []Wait{{Name: "mesh-knowledge.reviewed", Emitter: "mesh-knowledge", Event: "reviewed"}}
|
||||
if _, err := PermissionsFor(p); err == nil || !strings.Contains(err.Error(), "shop") {
|
||||
t.Errorf("a carried module's waits were composed: %v", err)
|
||||
}
|
||||
for _, w := range []Wait{
|
||||
{Name: "x", Emitter: "mesh-knowledge", Event: "*"},
|
||||
{Name: "x", Emitter: "mesh-knowledge", Event: "a.>"},
|
||||
{Name: "x", Emitter: "*", Event: "reviewed"},
|
||||
{Name: "x", Emitter: "mesh.knowledge", Event: "reviewed"},
|
||||
{Name: "x", Emitter: "mesh-knowledge", Event: ""},
|
||||
{Name: "x", Emitter: "mesh-knowledge", Event: "a..b"},
|
||||
} {
|
||||
if _, err := PermissionsFor(runtimeCarrying([]Wait{w})); err == nil {
|
||||
t.Errorf("%+v was composed", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,13 @@ func ownModule(p Principal, pattern string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// ownProvisioner is a machine's provisioner client saying a report about its own machine: the third token of
|
||||
// `prov.applied.<node>.…` is its machine (novox/hq ADR 0349 §4, design 57 P3).
|
||||
func ownProvisioner(p Principal, pattern string) bool {
|
||||
tokens := strings.Split(pattern, ".")
|
||||
return p.Kind == KindProvisioner && len(tokens) > 3 && tokens[2] == p.Node
|
||||
}
|
||||
|
||||
// kvOf is a bucket's write subjects.
|
||||
func kvOf(bucket string) []string { return []string{"$KV." + bucket + ".>"} }
|
||||
|
||||
@@ -158,6 +165,26 @@ var WritersTable = []WriterRow{
|
||||
{State: "a message a consumer gave up on", Writer: "controller", KeptIn: "the bus, the stream " + DeadLettersStream,
|
||||
Others: "read, delivered again or dropped through the controller's dead-letters verb",
|
||||
Subjects: []string{deadLetterPrefix + ">", againPrefix + ">"}, Writes: isController},
|
||||
// The record mirrors (novox/hq ADR 0289 §9, design 54 §2): rows of this table, so a grant that lets
|
||||
// anything else write them is refused at composition. Built so far: the assignments and each registered
|
||||
// module's declared config (design 57 F3), and the list of predefined keys kept (design 57 K3).
|
||||
{State: "the assignments mirror", Writer: "controller (lease holder), from its store, only on a change",
|
||||
KeptIn: "key-value " + AssignmentsBucket, Others: "read through `reads` as mesh-controller." + MirrorAssignments,
|
||||
Subjects: kvOf(AssignmentsBucket), Writes: isController},
|
||||
{State: "the declared-config mirror", Writer: "controller (lease holder), from its store, only on a change",
|
||||
KeptIn: "key-value " + DeclaredConfigBucket, Others: "read through `reads` as mesh-controller." + MirrorDeclaredConfig,
|
||||
Subjects: kvOf(DeclaredConfigBucket), Writes: isController},
|
||||
{State: "the predefined-keys mirror", Writer: "controller (lease holder), from its store, only on a change",
|
||||
KeptIn: "key-value " + PredefinedBucket, Others: "read through `reads` as mesh-controller." + MirrorPredefined,
|
||||
Subjects: kvOf(PredefinedBucket), Writes: isController},
|
||||
// What a machine's provisioner client placed of a module's files (novox/hq ADR 0349 §4, design 57 P6): its own
|
||||
// machine's word alone, which a walk's end and a gate wait for (ADR 0362, ADR 0349 §15). Kept newest per module
|
||||
// in the stream ProvAppliedStream, and by the client in the provisioner server's `applied` bucket.
|
||||
{State: "a module's files placed on a machine", Writer: "that machine's provisioner client",
|
||||
KeptIn: "the bus, the stream " + ProvAppliedStream + ", newest per module; and key-value " +
|
||||
ProvisionerAppliedBucket,
|
||||
Others: "the controller reads it at a walk's end and a gate; a failure is that machine's failed apply",
|
||||
Subjects: []string{"prov.applied.*.>"}, Writes: ownProvisioner},
|
||||
}
|
||||
|
||||
// CheckWriters refuses a grant that lets a principal publish on a subject the writers table gives
|
||||
|
||||
@@ -34,6 +34,12 @@ var designRows = []string{
|
||||
"the operator-channel's open messages",
|
||||
"the facts snapshot",
|
||||
"a message a consumer gave up on",
|
||||
// The record mirrors, rows of to-be 45 §1's table by design 54 §2 (ADR 0289 §9).
|
||||
"the assignments mirror",
|
||||
"the declared-config mirror",
|
||||
"the predefined-keys mirror",
|
||||
// The provisioner client's word on what it placed (novox/hq ADR 0349 §4, design 57 P6).
|
||||
"a module's files placed on a machine",
|
||||
}
|
||||
|
||||
func TestTheWritersTableIsTheDesigns(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user