An answer no entry of which carries binding gets, for a key-value or object-store entry whose settings hold the bus object its bus-name names, the binding composed from the answer's own fields; a stream, a ledger and an entry not held stay unbound and are logged once per answer; grants_first_views shows composed and unbound (1966/AC203-3..n). An answer where any entry carries binding is applied by §4 alone. Builds: design 25 bindings-first-view §9, §4, §6, §7, §8 Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
591 lines
27 KiB
Go
591 lines
27 KiB
Go
//go:build integration
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"maps"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/nats-io/nats-server/v2/server"
|
|
"github.com/nats-io/nats.go"
|
|
"github.com/nats-io/nats.go/jetstream"
|
|
|
|
"git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
|
|
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
|
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
|
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
|
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
|
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
|
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
|
"git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate"
|
|
"git.novox.be/novox/mesh-sdk/go/keys"
|
|
"git.novox.be/novox/mesh-sdk/go/membership"
|
|
"git.novox.be/novox/mesh-sdk/go/process"
|
|
"git.novox.be/novox/mesh-sdk/go/seal"
|
|
"git.novox.be/novox/mesh-sdk/go/seatlist"
|
|
"git.novox.be/novox/mesh-sdk/go/views"
|
|
|
|
"mesh-grants/internal/grants"
|
|
)
|
|
|
|
// TestTheGrantsPartWhole starts the program the way the node does (its entry, run, with its environment and a broker
|
|
// file) against an embedded bus that holds no controller at all: no lease bucket, no controller placed (novox/hq ADR 0590,
|
|
// issue 1126). Given a module placed with an event it consumes, it publishes the module's membership in its own state and
|
|
// on no subject, makes the module's durable consumer on EVENTS, and writes the sealed list's key for the bus node to follow
|
|
// alone; and it creates no lease, nor any state of a lease write or a take-over.
|
|
func TestTheGrantsPartWhole(t *testing.T) {
|
|
dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "grants")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { os.RemoveAll(dir) })
|
|
srv, err := server.NewServer(&server.Options{Host: "127.0.0.1", Port: -1, JetStream: true, StoreDir: filepath.Join(dir, "js"), NoLog: true, NoSigs: true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
go srv.Start()
|
|
if !srv.ReadyForConnections(10 * time.Second) {
|
|
t.Fatal("the bus did not come up")
|
|
}
|
|
t.Cleanup(srv.Shutdown)
|
|
nc, err := nats.Connect(srv.ClientURL())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(nc.Close)
|
|
js, _ := jetstream.New(nc)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
defer cancel()
|
|
|
|
kvs := map[string]jetstream.KeyValue{}
|
|
for _, state := range append(append([]string(nil), grants.Reads...), grants.ViewReads...) {
|
|
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: bucketOf(state), History: 5})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
kvs[state] = kv
|
|
}
|
|
for _, state := range grants.States {
|
|
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: grants.Module + "_" + state, History: 5})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
kvs[grants.Module+"."+state] = kv
|
|
}
|
|
for _, c := range []jetstream.StreamConfig{
|
|
{Name: "EVENTS", Subjects: []string{"mesh.mod.*.event.>", "mesh.seat.*.event.>"}},
|
|
{Name: "CONDITIONS", Subjects: []string{"mesh.seat.operator-channel.accept.condition.>"}},
|
|
} {
|
|
if _, err := js.CreateStream(ctx, c); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
put := func(state, key string, v any) {
|
|
t.Helper()
|
|
raw, err := json.Marshal(v)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := kvs[state].Put(ctx, key, raw); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
manifest := func(module, raw string) {
|
|
digest := meshcatalogclient.ManifestDigest([]byte(raw))
|
|
put(grants.ReadManifests, digest, meshcatalog.ManifestsByDigestValue{V: keys.Version, Module: module, Manifest: raw})
|
|
put(grants.ReadModules, module, meshcatalog.ModulesByModuleValue{V: keys.Version, Fp2: "src2:" + strings.Repeat("ab", 32), Manifest: digest,
|
|
Repository: "novox/x", Commit: "c"})
|
|
verdict, _ := meshcatalogclient.EncodeVerdict(meshcatalog.VerdictsByDigestValue{Module: module, State: meshcatalogclient.VerdictAccepted, Judge: "j", At: new(time.Now())})
|
|
put(grants.ReadVerdicts, digest, json.RawMessage(verdict))
|
|
}
|
|
manifest("nats", `{"module":"nats","version":"1","claims":[{"name":"mesh-broker"}],"own-secrets":{"broker":"/var/lib/nats/broker"},`+
|
|
`"emits":["created","done","dropped","recreated"],"streams":[{"name":"broker-events","events":["created","dropped","recreated"],"max-age-days":30,"max-mib":256}]}`)
|
|
manifest("mod-c", `{"module":"mod-c","version":"1","own-secrets":{"broker":"/var/lib/c/broker"},"consumes":["nats.done"]}`)
|
|
// The part itself, placed as on the mesh, so its own consumer on EVENTS hears the seat mesh-broker's events (DLV-G1).
|
|
own, err := os.ReadFile("../../module.json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
manifest(grants.Module, string(own))
|
|
values, _ := meshmoduleassignerclient.AssignmentsValues(meshmoduleassigner.Assignments{Node: "a", Modules: []meshmoduleassigner.PlacedModule{{Module: "nats"}, {Module: "mod-c"},
|
|
{Module: grants.Module}}})
|
|
for _, k := range slices.Sorted(maps.Keys(values)) { // a key per module, then the node key
|
|
if _, err := kvs[grants.ReadAssignments].Put(ctx, k, values[k]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
busKey, _ := seal.GenerateKeyPair()
|
|
put(grants.ReadHave, "a.node", map[string]any{"v": 1})
|
|
put(grants.ReadHave, "a.nats", nodeengine.HaveByNodeModuleValue{V: keys.Version, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK,
|
|
UserList: &nodeengine.UserListWritten{Source: grantsstate.GrantsUserListSource, Revision: 1, Digest: "sha256:aa", Users: 1,
|
|
Key: busKey.PublicKey, Follows: grantsstate.FollowGrants}})
|
|
|
|
// The bus module's read tools, as nats-tools answers them: the streams with their consumers as the bus holds them now.
|
|
answer := func(m *nats.Msg, result any) {
|
|
raw, _ := json.Marshal(map[string]any{"result": result, "node": "a"})
|
|
_ = m.Respond(raw)
|
|
}
|
|
sub, err := nc.Subscribe("mesh.mod.nats.>", func(m *nats.Msg) {
|
|
switch {
|
|
case strings.Contains(m.Subject, ".nats_users"):
|
|
answer(m, map[string]any{"users": []any{}})
|
|
case strings.Contains(m.Subject, ".nats_streams"):
|
|
var args struct{ Stream string }
|
|
_ = json.Unmarshal(m.Data, &args)
|
|
detail := []any{}
|
|
if st, err := js.Stream(ctx, args.Stream); err == nil {
|
|
consumers := []any{}
|
|
names := st.ConsumerNames(ctx)
|
|
for name := range names.Name() {
|
|
c, _ := st.Consumer(ctx, name)
|
|
cfg := c.CachedInfo().Config
|
|
consumers = append(consumers, map[string]any{"name": name, "filters": cfg.FilterSubjects, "max_deliver": cfg.MaxDeliver})
|
|
}
|
|
detail = append(detail, map[string]any{"name": args.Stream, "consumer_detail": consumers})
|
|
}
|
|
answer(m, map[string]any{"detail": detail})
|
|
default:
|
|
answer(m, map[string]any{})
|
|
}
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
|
// DLV-F4r, DLV-F5r (ADR 0615 §4, §6, ADR 0621 §3): the owners answer their first-view tools at once from what they
|
|
// hold, with its revision, as the indexer's and the assigner's runtimes of their own do; the part never reads their
|
|
// buckets. Started before its owners answer (answering false: each ask refused, as an owner not yet connected), the
|
|
// part waits, says so, and composes nothing it would read from them.
|
|
var firstViewAsks, refusedAsks atomic.Int32
|
|
var answering atomic.Bool
|
|
for _, owner := range []string{grants.IndexerModule, grants.AssignerModule} {
|
|
owner := owner
|
|
osub, err := nc.Subscribe("mesh.mod."+owner+".>", func(m *nats.Msg) {
|
|
for _, fv := range grants.FirstViews {
|
|
if fv.Owner != owner || !strings.Contains(m.Subject, "."+fv.State) {
|
|
continue
|
|
}
|
|
if !answering.Load() {
|
|
refusedAsks.Add(1)
|
|
raw, _ := json.Marshal(map[string]any{"error": "not connected yet", "node": "a"})
|
|
_ = m.Respond(raw)
|
|
return
|
|
}
|
|
firstViewAsks.Add(1)
|
|
values, rev := map[string]json.RawMessage{}, uint64(0)
|
|
if keys, err := kvs[fv.Read].ListKeys(ctx); err == nil {
|
|
for k := range keys.Keys() {
|
|
if e, err := kvs[fv.Read].Get(ctx, k); err == nil {
|
|
values[k] = e.Value()
|
|
rev = max(rev, e.Revision())
|
|
}
|
|
}
|
|
}
|
|
answer(m, views.Answer{V: views.Version, Owner: owner, State: fv.State, Revision: rev,
|
|
Created: time.Date(2026, 11, 4, 0, 0, 0, 0, time.UTC), Values: values, At: time.Now()})
|
|
return
|
|
}
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = osub.Unsubscribe() })
|
|
}
|
|
// D-30 (novox/hq ADR 0636 §1): the seat mesh-catalog's holder answers the seat list with no trunk seat named, so the reader
|
|
// has heard the seat and holds the bootstrapped definitions, and every module is composed. (The unit case holds a module
|
|
// while the list is unread.)
|
|
var seatAnswering atomic.Bool
|
|
seatAnswering.Store(false)
|
|
for _, subject := range []string{"mesh.seat.mesh-catalog.call.seat-definitions", "mesh.seat.mesh-catalog.call.seat-definitions.>"} {
|
|
if _, err := nc.Subscribe(subject, func(m *nats.Msg) {
|
|
if !seatAnswering.Load() {
|
|
_ = m.Respond([]byte(`{"error":"not connected yet","node":"a"}`))
|
|
return
|
|
}
|
|
answer(m, map[string]any{"revision": 1, "seats": []any{}})
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
// Design 25 bindings-first-view §8: the bus module answers the seat mesh-broker's list with an entry whose created was never
|
|
// said, once it is answering; until then the ask is refused as of a holder not yet connected.
|
|
var listing atomic.Bool
|
|
// A gate on the bus module's answer: while set, the answer waits (after saying it was asked) until the test releases it.
|
|
type listGate struct{ asked, release chan struct{} }
|
|
var gate atomic.Pointer[listGate]
|
|
var answeredGated, listAsked, listAnswered atomic.Int32
|
|
// While set, the bus module answers as a build from before the field `binding` (design 25 bindings-first-view §9).
|
|
var oldBuild atomic.Bool
|
|
for _, subject := range []string{"mesh.seat.mesh-broker.call.list.>", "mesh.seat.mesh-broker.tool.list"} {
|
|
if _, err := nc.Subscribe(subject, func(m *nats.Msg) {
|
|
if !listing.Load() {
|
|
_ = m.Respond([]byte(`{"error":"not connected yet","node":"a"}`))
|
|
return
|
|
}
|
|
listAsked.Add(1)
|
|
defer listAnswered.Add(1)
|
|
var more []any
|
|
if oldBuild.Load() {
|
|
answer(m, map[string]any{"entries": []any{
|
|
map[string]any{"module": "mod-c", "local-name": "late", "storage-type": "key-value", "template": "state", "bus-name": "mod-c_late",
|
|
"settings": map[string]any{"KV_mod-c_late": map[string]any{}}},
|
|
map[string]any{"module": "mod-c", "local-name": "feed", "storage-type": "stream", "bus-name": "mod-c_feed",
|
|
"settings": map[string]any{"mod-c_feed": map[string]any{}}},
|
|
}})
|
|
return
|
|
}
|
|
if g := gate.Load(); g != nil {
|
|
g.asked <- struct{}{}
|
|
<-g.release
|
|
defer answeredGated.Add(1)
|
|
// The held answer lists one entry more (waiting, no binding), so its taking shows in the kept status.
|
|
more = append(more, map[string]any{"module": "mod-c", "local-name": "also-waiting", "storage-type": "key-value",
|
|
"template": "state", "bus-name": "mod-c_also-waiting", "state": "waiting"})
|
|
}
|
|
answer(m, map[string]any{"entries": append([]any{
|
|
map[string]any{"module": "mod-c", "local-name": "late", "storage-type": "key-value", "template": "state", "bus-name": "mod-c_late",
|
|
"settings": map[string]any{"mod-c_late": map[string]any{}},
|
|
"binding": map[string]any{"local": "late", "storage": "key-value", "template": "state", "bucket": "mod-c_late"}},
|
|
map[string]any{"module": "mod-c", "local-name": "waiting", "storage-type": "key-value", "template": "state", "bus-name": "mod-c_waiting",
|
|
"state": "waiting"},
|
|
}, more...)})
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
// The conditions the part said, by key, as last said.
|
|
conditionsSaid := func() map[string]string {
|
|
out := map[string]string{}
|
|
st, err := js.Stream(ctx, "CONDITIONS")
|
|
if err != nil {
|
|
return out
|
|
}
|
|
info, _ := st.Info(ctx)
|
|
for seq := info.State.FirstSeq; seq != 0 && seq <= info.State.LastSeq; seq++ {
|
|
raw, err := st.GetMsg(ctx, seq)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if c, err := occ.DecodeCondition(raw.Data); err == nil {
|
|
out[c.Key] = string(c.State)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
bindingsBoundAndWaits := func() {
|
|
bindingsTiming.bound, bindingsTiming.wait, bindingsTiming.max = 10*time.Second, 50*time.Millisecond, 100*time.Millisecond
|
|
}
|
|
bindingsBoundAndWaits()
|
|
t.Cleanup(func() { bindingsTiming.bound, bindingsTiming.wait, bindingsTiming.max = 0, 0, 0 })
|
|
// A membership is never said on its subject any more (issue 1126).
|
|
var said atomic.Int32
|
|
if _, err := nc.Subscribe("mesh.assignment.>", func(*nats.Msg) { said.Add(1) }); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
broker := filepath.Join(dir, "broker")
|
|
cred, _ := json.Marshal(process.Account{URL: srv.ClientURL(), Node: "a", Module: grants.Module, User: "a." + grants.Module, Password: "pw"})
|
|
if err := os.WriteFile(broker, cred, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
env := map[string]string{"MESH_BROKER_FILE": broker}
|
|
runCtx, stop := context.WithCancel(ctx)
|
|
done := make(chan error, 1)
|
|
logged := &lockedLog{}
|
|
go func() { done <- run(runCtx, func(k string) string { return env[k] }, logged) }()
|
|
t.Cleanup(func() { stop(); <-done })
|
|
|
|
until := func(what string, ok func() bool) {
|
|
t.Helper()
|
|
for deadline := time.Now().Add(30 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
|
|
if ok() {
|
|
return
|
|
}
|
|
}
|
|
t.Fatalf("never saw: %s\n%s", what, logged.tail("binding"))
|
|
}
|
|
until("every first view asked and refused once", func() bool { return refusedAsks.Load() >= int32(len(grants.FirstViews)) })
|
|
until("the part saying it waits on the indexer and the assigner", func() bool {
|
|
return logged.has("mesh-delivery-indexer.manifests") && logged.has("mesh-delivery-assigner.assignments") && logged.has("is not answered")
|
|
})
|
|
if _, err := kvs[grants.Module+"."+grants.StateMemberships].Get(ctx, "a.mod-c"); err == nil {
|
|
t.Error("a membership composed before the owners answered its first views")
|
|
}
|
|
// ADR 0636 §1, the unread phase: the seat was silent when the part started and speaks now, and says so on its event
|
|
// before the part's own consumer exists (the part makes it from its first composition, which waits on its owners'
|
|
// first views). The event is lost to that consumer, which starts at the stream's end, so the part reads the list again
|
|
// when its consumer is heard; mod-c, which the unread list held, is composed after that and not before.
|
|
seatAnswering.Store(true)
|
|
if _, err := js.Publish(ctx, "mesh.seat.mesh-catalog.event.seat-definition-changed", []byte(`{"seat":"mesh-broker","source":"trunk"}`)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
answering.Store(true)
|
|
until("a.mod-c's membership in the part's own state memberships", func() bool {
|
|
e, err := kvs[grants.Module+"."+grants.StateMemberships].Get(ctx, "a.mod-c")
|
|
if err != nil {
|
|
return false
|
|
}
|
|
m, err := membership.DecodeGrantsMembership(e.Value())
|
|
return err == nil && m.Module == "mod-c"
|
|
})
|
|
until("mod-c's durable consumer on EVENTS, made by the part", func() bool {
|
|
_, err := js.Consumer(ctx, "EVENTS", "a_mod-c")
|
|
return err == nil
|
|
})
|
|
until("the sealed list's key, followed by the bus node alone", func() bool {
|
|
e, err := kvs[grants.Module+"."+grants.StateUserList].Get(ctx, grants.KeySealed)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
g, err := grantsstate.DecodeGrantsUserList(e.Value())
|
|
return err == nil && g.Follow == grantsstate.FollowGrants
|
|
})
|
|
if n := said.Load(); n != 0 {
|
|
t.Errorf("%d memberships were said on their subject", n)
|
|
}
|
|
if firstViewAsks.Load() < int32(len(grants.FirstViews)) {
|
|
t.Errorf("the part composed with %d first-view asks, want one of each of the %d", firstViewAsks.Load(), len(grants.FirstViews))
|
|
}
|
|
// DLV-G1, 1966/AC34 (novox/hq ADR 0595 §1, §7): the bus module's created, said on the seat mesh-broker's subject on
|
|
// EVENTS like every seat's event (the lead's ruling, registry milestone 13:0x), reaches the part on its durable consumer
|
|
// there, and the binding it carries is in the module's membership; dropped takes it out.
|
|
until("the part's durable consumer on EVENTS hearing mesh-broker's created", func() bool {
|
|
cons, err := js.Consumer(ctx, "EVENTS", "a_"+grants.Module)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
info, err := cons.Info(ctx)
|
|
return err == nil && slices.Contains(info.Config.FilterSubjects, "mesh.seat.mesh-broker.event.created")
|
|
})
|
|
bindingOf := func() []membership.BusBinding {
|
|
e, err := kvs[grants.Module+"."+grants.StateMemberships].Get(ctx, "a.mod-c")
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
m, _ := membership.DecodeGrantsMembership(e.Value())
|
|
return m.Bindings
|
|
}
|
|
sayBroker := func(event string, bind membership.BusBinding) {
|
|
body, _ := json.Marshal(grants.BrokerEvent{V: 1, Module: "mod-c", Local: bind.Local, Storage: "key-value", Template: "state", Binding: bind})
|
|
if _, err := js.Publish(ctx, "mesh.seat.mesh-broker.event."+event, body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
// With list unanswered the part has composed every account (mod-c's membership is above), says past its bound that the
|
|
// bindings' first view is unanswered, naming the seat and the verb, and applies the created heard meanwhile.
|
|
if said := conditionsSaid()[grants.FirstViewUnanswered+".mesh-broker.bindings"]; said != "" {
|
|
t.Fatalf("first-view-unanswered %q before the created is said: the bound (10s) passed first", said)
|
|
}
|
|
sayBroker("created", membership.BusBinding{Local: "kept", Storage: "key-value", Template: "state", Bucket: "mod-c_kept"})
|
|
// The created is held until the bound (§3): when it is applied, first-view-unanswered is already raised.
|
|
until("mod-c's binding in its membership after created, list unanswered", func() bool {
|
|
b := bindingOf()
|
|
return len(b) == 1 && b[0].Bucket == "mod-c_kept"
|
|
})
|
|
if said := conditionsSaid()[grants.FirstViewUnanswered+".mesh-broker.bindings"]; said != "raised" {
|
|
t.Fatalf("the created held while list is unanswered was applied before the bound: first-view-unanswered %q, want raised", said)
|
|
}
|
|
// The bus module answers: the entry whose created was never said gets its binding; the one the answer does not list (kept)
|
|
// stays and raises binding-unlisted; an entry listed without a binding changes nothing; the answer clears the first
|
|
// view's condition.
|
|
listing.Store(true)
|
|
until("the binding of the entry created never said, kept beside the one the answer does not list", func() bool {
|
|
b := bindingOf()
|
|
return len(b) == 2 && b[0].Bucket == "mod-c_kept" && b[1].Bucket == "mod-c_late"
|
|
})
|
|
until("binding-unlisted raised for the kept binding and first-view-unanswered cleared", func() bool {
|
|
said := conditionsSaid()
|
|
return said[grants.BindingUnlisted+".mod-c.kept"] == "raised" && said[grants.FirstViewUnanswered+".mesh-broker.bindings"] == "cleared" &&
|
|
said[grants.BindingUnlisted+".mod-c.late"] == "" && said[grants.BindingUnlisted+".mod-c.waiting"] == ""
|
|
})
|
|
// A person asks again by hand (state bindings): asked once more, the same answer changes nothing it holds.
|
|
askedBefore := listAsked.Load()
|
|
askedAgain, _ := json.Marshal(grants.Ask{By: "someone@desk", At: time.Now(), FirstView: grants.FirstViewBindings})
|
|
if _, err := kvs[grants.Module+"."+grants.StateUserList].Put(ctx, grants.KeyAsked, askedAgain); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
until("the bindings asked once more by hand", func() bool {
|
|
return logged.has("the first view of the bindings (" + grants.CallBrokerList + ") once more by hand")
|
|
})
|
|
sayBroker("dropped", membership.BusBinding{Local: "kept"})
|
|
until("mod-c's kept binding gone after dropped, its unlisted condition cleared", func() bool {
|
|
b := bindingOf()
|
|
return len(b) == 1 && b[0].Bucket == "mod-c_late" && conditionsSaid()[grants.BindingUnlisted+".mod-c.kept"] == "cleared"
|
|
})
|
|
until("the ask by hand answered", func() bool { return listAsked.Load() > askedBefore && listAnswered.Load() == listAsked.Load() })
|
|
// An answer that comes after a dropped heard since the ask does not write the dropped binding back (§4): the bus module
|
|
// holds the answer, which still lists the entry, while the dropped is said and heard.
|
|
g := &listGate{asked: make(chan struct{}, 1), release: make(chan struct{})}
|
|
gate.Store(g)
|
|
askedAgain, _ = json.Marshal(grants.Ask{By: "someone@desk", At: time.Now().Add(time.Second), FirstView: grants.FirstViewBindings})
|
|
if _, err := kvs[grants.Module+"."+grants.StateUserList].Put(ctx, grants.KeyAsked, askedAgain); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case <-g.asked:
|
|
case <-time.After(20 * time.Second):
|
|
t.Fatalf("the bus module was not asked again by hand\n%s", logged.tail("by hand")+logged.tail("bindings"))
|
|
}
|
|
sayBroker("dropped", membership.BusBinding{Local: "late"})
|
|
until("mod-c's late binding gone after dropped, the answer held", func() bool { return len(bindingOf()) == 0 })
|
|
close(g.release)
|
|
gate.Store(nil)
|
|
until("the held answer taken, as the kept status of the first views says", func() bool {
|
|
e, err := kvs[grants.Module+"."+grants.StateUserList].Get(ctx, grants.KeyFirstViews)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
var st grants.FirstViewsState
|
|
return json.Unmarshal(e.Value(), &st) == nil && st.Bindings != nil && st.Bindings.Listed == 3 && st.Bindings.Error == ""
|
|
})
|
|
if b := bindingOf(); len(b) != 0 {
|
|
t.Fatalf("after an answer that lists a binding dropped since its ask: %+v", b)
|
|
}
|
|
// 1966/AC203-3..: a bus module's build from before the field answers no binding on any entry (design 25 bindings-first-view
|
|
// §9): the key-value entry the bus holds gets its binding composed from the answer's fields, the stream is left unbound.
|
|
oldBuild.Store(true)
|
|
askedAgain, _ = json.Marshal(grants.Ask{By: "someone@desk", At: time.Now().Add(2 * time.Second), FirstView: grants.FirstViewBindings})
|
|
if _, err := kvs[grants.Module+"."+grants.StateUserList].Put(ctx, grants.KeyAsked, askedAgain); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
until("the binding of the entry composed from an answer with no binding, the stream left unbound", func() bool {
|
|
e, err := kvs[grants.Module+"."+grants.StateUserList].Get(ctx, grants.KeyFirstViews)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
var st grants.FirstViewsState
|
|
b := bindingOf()
|
|
return json.Unmarshal(e.Value(), &st) == nil && st.Bindings != nil && st.Bindings.Composed == 1 &&
|
|
slices.Equal(st.Bindings.Unbound, []string{"mod-c/feed (stream)"}) && len(b) == 1 && b[0].Bucket == "mod-c_late"
|
|
})
|
|
for _, bucket := range []string{"mesh-controller_lease", grants.Module + "_lease", grants.Module + "_takeover"} {
|
|
if _, err := js.KeyValue(ctx, bucket); !errors.Is(err, jetstream.ErrBucketNotFound) {
|
|
t.Errorf("the bucket %s: %v, want none", bucket, err)
|
|
}
|
|
}
|
|
// Issue 1966: the seat list's again tool answers an operator's session, which the mesh MCP server proves as its
|
|
// machine's tool runner (issue 627), and the healers; no other module.
|
|
t.Run("the seat list's again tool answers an operator's session", func(t *testing.T) {
|
|
for caller, refused := range map[string]bool{"person.jochen": false, "shanks.node-tools": false, "ace.mesh-healers": false,
|
|
"ace.messenger": true} {
|
|
if err := seatlist.Refusal(grants.ToolSeatListAgain, seatListAgainCallers, caller); (err != nil) != refused {
|
|
t.Errorf("%s: refused=%v, err %v", caller, refused, err)
|
|
}
|
|
}
|
|
})
|
|
// ADR 0640 §5: the real additions change the subjects and the filters and nothing else, and recreate nothing.
|
|
t.Run("adding subjects and filters changes only those", func(t *testing.T) {
|
|
stream, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "ADDS", Subjects: []string{"a.>"}, MaxAge: time.Hour, MaxMsgs: 100,
|
|
Retention: jetstream.LimitsPolicy, Discard: jetstream.DiscardNew, MaxBytes: 1 << 20})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cons, err := stream.CreateConsumer(ctx, jetstream.ConsumerConfig{Durable: "idx", AckPolicy: jetstream.AckExplicitPolicy, FilterSubject: "a.one", MaxDeliver: 7})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for range 3 {
|
|
if _, err := js.Publish(ctx, "a.one", []byte("x")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
got, err := cons.Fetch(2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for m := range got.Messages() {
|
|
_ = m.Ack()
|
|
}
|
|
before, _ := stream.Info(ctx)
|
|
cbefore, _ := cons.Info(ctx)
|
|
if cbefore.Delivered.Stream != 2 {
|
|
t.Fatalf("the consumer delivered %d, want 2", cbefore.Delivered.Stream)
|
|
}
|
|
c := creating{ctx: ctx, js: js}
|
|
if err := c.AddSubjects("ADDS", []string{"b.>"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := c.AddFilters("ADDS", "idx", []string{"a.two"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
after, _ := stream.Info(ctx)
|
|
cafter, _ := cons.Info(ctx)
|
|
b, a := before.Config, after.Config
|
|
if !slices.Equal(a.Subjects, []string{"a.>", "b.>"}) || a.MaxAge != b.MaxAge || a.MaxMsgs != b.MaxMsgs || a.Retention != b.Retention ||
|
|
a.Discard != b.Discard || !after.Created.Equal(before.Created) || after.State.Msgs != before.State.Msgs || after.State.LastSeq != before.State.LastSeq {
|
|
t.Errorf("the stream after: %+v created %v, before %+v created %v", a, after.Created, b, before.Created)
|
|
}
|
|
cb, ca := cbefore.Config, cafter.Config
|
|
if !slices.Equal(ca.FilterSubjects, []string{"a.one", "a.two"}) || ca.FilterSubject != "" || ca.MaxDeliver != cb.MaxDeliver || ca.AckPolicy != cb.AckPolicy ||
|
|
cafter.Delivered.Stream != cbefore.Delivered.Stream || cafter.AckFloor.Stream != cbefore.AckFloor.Stream || !cafter.Created.Equal(cbefore.Created) {
|
|
t.Errorf("the consumer after: %+v delivered %d created %v, before %+v delivered %d created %v", ca, cafter.Delivered.Stream, cafter.Created, cb, cbefore.Delivered.Stream, cbefore.Created)
|
|
}
|
|
// A size cap is only ever raised, and nothing else changes with it (the operator, 2026-11-09).
|
|
for _, row := range []struct {
|
|
to, from, want int64
|
|
raised bool
|
|
}{{4 << 20, 1 << 20, 4 << 20, true}, {2 << 20, 4 << 20, 4 << 20, false}} {
|
|
from, raised, err := c.RaiseMaxBytes("ADDS", row.to)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
now, _ := stream.Info(ctx)
|
|
if from != row.from || raised != row.raised || now.Config.MaxBytes != row.want || now.Config.MaxAge != b.MaxAge ||
|
|
!slices.Equal(now.Config.Subjects, []string{"a.>", "b.>"}) || !now.Created.Equal(before.Created) || now.State.Msgs != before.State.Msgs {
|
|
t.Errorf("raise to %d: from %d raised %v, config %+v", row.to, from, raised, now.Config)
|
|
}
|
|
}
|
|
// Nothing is removed: a second addition of the same and of a subset keeps what is there.
|
|
if err := c.AddSubjects("ADDS", []string{"a.>"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if again, _ := stream.Info(ctx); !slices.Equal(again.Config.Subjects, []string{"a.>", "b.>"}) {
|
|
t.Errorf("subjects after adding a held one: %v", again.Config.Subjects)
|
|
}
|
|
})
|
|
|
|
// And what the part composes holds on a server that loads it (one integration test per module, the test budget).
|
|
t.Run("the composed grants hold on a server that loads them", theBusNodeFollowsTheSealedListUnderTheComposedGrants)
|
|
}
|
|
|
|
// lockedLog is the part's log, read by the test while the part writes it.
|
|
type lockedLog struct {
|
|
mu sync.Mutex
|
|
buf strings.Builder
|
|
}
|
|
|
|
func (l *lockedLog) Write(p []byte) (int, error) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
return l.buf.Write(p)
|
|
}
|
|
|
|
func (l *lockedLog) has(s string) bool {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
return strings.Contains(l.buf.String(), s)
|
|
}
|
|
|
|
// tail is the lines of the log that contain s, for a failure to name what the part said of it.
|
|
func (l *lockedLog) tail(s string) string {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
var out []string
|
|
for _, line := range strings.Split(l.buf.String(), "\n") {
|
|
if strings.Contains(line, s) {
|
|
out = append(out, line)
|
|
}
|
|
}
|
|
return strings.Join(out, "\n")
|
|
}
|