From 377fd6d67da9aab78731299162ca7b7be5732c63 Mon Sep 17 00:00:00 2001 From: fix-sdk-pas Date: Fri, 16 Oct 2026 19:34:02 +0200 Subject: [PATCH] Move the moved path's shared driving into harness.Moved, so postgres and keycloak keep only what differs (issue 849) --- go/provider/harness/early_test.go | 62 +++++ go/provider/harness/moved.go | 339 +++++++++++++++++++++++++++ go/provider/harness/moved_test.go | 367 ++++++++++++++++++++++++++++++ 3 files changed, 768 insertions(+) create mode 100644 go/provider/harness/early_test.go create mode 100644 go/provider/harness/moved.go create mode 100644 go/provider/harness/moved_test.go diff --git a/go/provider/harness/early_test.go b/go/provider/harness/early_test.go new file mode 100644 index 0000000..bb75652 --- /dev/null +++ b/go/provider/harness/early_test.go @@ -0,0 +1,62 @@ +package harness + +// The moved path started before its runtime answers asks again until it does (issue 943). + +import ( + "context" + "errors" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/provider" +) + +// earlyBus refuses its first watches as the runtime does before Serve runs, then answers as the bucket bus does. +type earlyBus struct { + *bucketBus + refuse int +} + +func (b *earlyBus) Watch(ctx context.Context, bucket string, handle func(string, []byte)) error { + if b.refuse > 0 { + b.refuse-- + return errors.New("before Serve: a bundle reaches the bus through the runtime that launched it") + } + return b.bucketBus.Watch(ctx, bucket, handle) +} + +func TestAMovedPathStartedTooEarlyAsksAgainUntilTheRuntimeAnswers(t *testing.T) { + w := newMovedWorld(t, map[[2]string]string{}) + w.run.FirstRetry = time.Millisecond + w.run.loaded = false + bus := &earlyBus{bucketBus: w.bus, refuse: 3} + p := provider.New(provider.Options{Node: "novox", Module: "postgres", Bus: w.bus, + Vault: provider.ConsumersFile{Paths: []string{w.file}}, Make: w.run.Make, Gone: w.run.Gone, + Every: time.Hour, Logf: func(string, ...any) {}}) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- w.run.Run(ctx, p, bus) }() + deadline := time.After(time.Second) + for { + w.run.mu.Lock() + loaded := w.run.loaded + w.run.mu.Unlock() + if loaded { + break + } + select { + case err := <-done: + cancel() + t.Fatalf("Run stopped before the runtime answered: %v", err) + case <-deadline: + cancel() + t.Fatal("the plans were never watched") + case <-time.After(time.Millisecond): + } + } + cancel() + <-done + if bus.refuse != 0 { + t.Fatalf("%d refusals left", bus.refuse) + } +} diff --git a/go/provider/harness/moved.go b/go/provider/harness/moved.go new file mode 100644 index 0000000..392db1a --- /dev/null +++ b/go/provider/harness/moved.go @@ -0,0 +1,339 @@ +package harness + +// A provider handed to the provisioner (novox/hq ADR 0373, ADR 0349 clause 12, issues 780 and 805): where its +// consumers file is set, it runs on the provider helper (package provider) instead of the harness reading the +// controller's grant files. Moved is that path, shared by every provider that keeps a resource per consumer; a module +// says only what differs, in a MovedSpec. +// +// The helper learns who binds the provider from the plans, composes each consumer's entry and reads its credential +// through ConsumersFile, the file this machine's provisioner client fills. It leaves the rest to the two hooks +// here: Make brings the consumer's resource into being exactly as the harness's adapter does, and Gone leaves +// retirement to the harness's guards (retirement.go): the resource is disabled and marked to delete, never removed. +// +// **A consumer not moved is still the grant file's** (issue 937, ADR 0349 clause 17), for a provider whose spec says +// WithGrants: only a moved consumer's plan names its provider, so the controller still gives every other consumer +// through the grant file (MESH_RECEIVES) until R1 (issue 682). Each login it gives is asked for, and one no plan +// names is made and checked by the harness's grant pass exactly as on the grant path; a login a plan names is the +// helper's alone. A grant file that cannot be read is not nobody asking (issue 241): nothing is retired until it is +// read again. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "time" + + "git.novox.be/novox/mesh-sdk/go/provider" +) + +// MovedSpec is what differs per provider on the moved path. +type MovedSpec struct { + // Module and Provision are the provider module's name and what it provides, as its manifest says. + Module, Provision string + // Offer is what it serves: its credential form and served values. + Offer provider.Offer + // Login is the identity a consumer is made under at the provider's end (postgres: the entry's User; keycloak: its + // As); an entry with none is not made. + Login func(e provider.Entry) string + // Values are the values the adapter is handed for a consumer; the entry's contribution when not set. + Values func(e provider.Entry) map[string]any + // Noun names what Login is in words ("login", "client id"), Made what is made ("login", "client"), and Backend + // the service in "no longer holds it" ("the backend", "Keycloak"). + Noun, Made, Backend string + // WithGrants makes the logins the grant file gives, that no plan names, asked for and made by the harness's grant + // pass (issue 937). + WithGrants bool +} + +// Moved is a provider on the provider helper, with the harness's own memory and retirement behind it: what is held, +// what is counted unasked, what waits for a person (retirement.go). The harness is never Run here; the helper's Make +// and Gone and the loop below drive the same state, so the guards are the harness's, not a copy. +type Moved struct { + h *Harness + spec MovedSpec + + // FirstRetry is the first wait before a watch asked too early is asked again (issue 943); a second when zero. + FirstRetry time.Duration + + mu sync.Mutex // guards assignments and loaded + assignments map[string][]byte + loaded bool + verified map[string]time.Time +} + +func newMoved(h *Harness, spec MovedSpec) *Moved { + h.mu.Lock() + h.init() + h.mu.Unlock() + return &Moved{h: h, spec: spec, assignments: map[string][]byte{}, verified: map[string]time.Time{}} +} + +// Make brings one consumer's resource into being under its login and the vault's credential, as the harness does: +// at once when it is new or changed, and otherwise only when the backend no longer holds it, which is asked every +// VerifyEvery. +func (m *Moved) Make(ctx context.Context, c provider.Consumer) error { + h, e := m.h, c.Entry + as := m.spec.Login(e) + if as == "" { + return fmt.Errorf("%s has no %s to make", e.Key(), m.spec.Noun) + } + if len(c.Credential) == 0 { + return fmt.Errorf("%s has no credential to make its %s with", e.Key(), m.spec.Made) + } + h.mu.Lock() + defer h.mu.Unlock() + password := string(c.Credential) + values := orEmpty(e.Contributes) + if m.spec.Values != nil { + values = m.spec.Values(e) + } + p := Provision{As: as, Password: password, Values: values, Consumer: e.Consumer} + hash := hashOf(as, password, p.Values, nil) + if was, ok := h.applied[as]; ok && was.hash == hash { + if h.Now().Sub(m.verified[as]) < h.VerifyEvery { + return nil + } + m.verified[as] = h.Now() + hctx, cancel := context.WithTimeout(ctx, h.HoldsTimeout) + held, err := h.Adapter.Holds(hctx, p) + cancel() + if err != nil { + text := scrub(err, password) + h.failed(as, e.Consumer, h.classOf(err, text), text) + return errors.New(text) + } + if held { + h.succeeded(as) + return nil + } + h.say("%s: %s no longer holds it; applying again", as, m.spec.Backend) + } + if err := h.Adapter.Create(ctx, p); err != nil { + text := scrub(err, password) + h.failed(as, e.Consumer, h.classOf(err, text), text) + return errors.New(text) + } + h.succeeded(as) + h.applied[as] = appliedEntry{hash: hash, node: e.Consumer} + m.verified[as] = h.Now() + h.reenabled(as, e.Consumer) + return nil +} + +// Gone is the helper asking whether it may forget a consumer that no longer binds. **It never retires**: retiring is +// the harness's rule, the same unasked set over StablePasses passes and StableFor, at most RetireAtOnce at a time +// (retirement.go, ADR 0230), run by retire below. So a consumer missing from one read of the plans loses nothing: +// the helper is told no, and asks again at its next pass, until the login is retired, or asked for again. +func (m *Moved) Gone(_ context.Context, e provider.Entry) error { + h := m.h + as := m.spec.Login(e) + if as == "" { + return nil // refused: nothing was made for it + } + // The plans and the grant file as they are now, not as the last retirement pass read them: with either not + // read, nothing is let go. + want, read := m.asked(h.Node) + h.mu.Lock() + defer h.mu.Unlock() + asked := want[as] + if m.spec.WithGrants { + _, granted, ok := m.granted(want) + read = read && ok + asked = asked || granted[as] + } + _, held := h.applied[as] + _, retired := h.r.retired[as] + switch { + case !read: + case asked: + return nil // another holder of the same login still binds: nothing to retire + case retired, !held && h.r.seeded: + return nil + } + return fmt.Errorf("%s is kept until its retirement is stable (%d passes and %s) and within the bound, or a "+ + "person approves it", as, h.StablePasses, h.StableFor) +} + +// plan is as much of an assignment as is read to learn who binds the provider. +type plan struct { + Node string `json:"node"` + Module string `json:"module"` + Provisioning *struct { + Provisions []struct { + Provision string `json:"provision"` + Provider string `json:"provider"` + ProviderModule string `json:"provider-module"` + // As is who the consumer is at its provider's end, its slug first (issue 788). + As string `json:"as"` + } `json:"provisions"` + } `json:"provisioning"` +} + +// asked is the logins the plans bind the provider for, and whether they could all be read: a plan that is not the +// controller's, or no read yet, is not an answer, so nothing is counted unasked from it (novox/hq issue 241). +func (m *Moved) asked(node string) (map[string]bool, bool) { + m.mu.Lock() + defer m.mu.Unlock() + if !m.loaded || len(m.assignments) == 0 { + return nil, false + } + want := map[string]bool{} + for key, raw := range m.assignments { + var a plan + if err := json.Unmarshal(raw, &a); err != nil || a.Node+"."+a.Module != key { + return nil, false + } + if a.Provisioning == nil { + continue + } + for _, b := range a.Provisioning.Provisions { + if b.Provider == node && b.ProviderModule == m.spec.Module && b.Provision == m.spec.Provision { + as := b.As + if as == "" { + as = provider.ConsumerIdentity(a.Node, a.Module) // a plan that names none + } + want[as] = true + } + } + } + return want, true +} + +// granted is what the grant file gives that no plan names (issue 937), and every login it gives; and whether it +// could be read. The caller holds h.mu. With no grant file named there is nothing given, and that is a read. +func (m *Moved) granted(planned map[string]bool) ([]contribution, map[string]bool, bool) { + h := m.h + if h.Receives == "" { + return nil, map[string]bool{}, true + } + given := h.readContributions() + if given == nil { + return nil, nil, false + } + var own []contribution + logins := map[string]bool{} + for _, g := range given { + logins[g.As] = true + if !planned[g.As] { + own = append(own, g) + } + } + return own, logins, true +} + +// retire is one pass of the harness's retirement over what the plans bind and the grant file gives now; the logins +// only the grant file gives are made and checked first, as the grant path does. +func (m *Moved) retire(ctx context.Context) { + h := m.h + want, ok := m.asked(h.Node) + h.mu.Lock() + defer h.mu.Unlock() + var given []contribution + if ok && m.spec.WithGrants { + var granted map[string]bool + given, granted, ok = m.granted(want) + for as := range granted { + want[as] = true + } + } + if !ok { + // Not a result: the passes that must agree before anything is retired start again. + h.r.key, h.r.count = "", 0 + return + } + h.seed(ctx, want) + h.apply(ctx, given) + h.retireUnasked(ctx, want) + h.r.lastWant = want + for as := range h.failing { + if !want[as] { + delete(h.failing, as) + } + } + for as := range h.trouble { + if _, held := h.applied[as]; !want[as] && !held { + h.recovered(as, "no longer asked for") + } + } +} + +// watch keeps the plans as the bus holds them, from the first read on. +func (m *Moved) watch(ctx context.Context, bus provider.Bus) error { + err := bus.Watch(ctx, provider.AssignmentsBucket, func(key string, value []byte) { + m.mu.Lock() + defer m.mu.Unlock() + if value == nil { + delete(m.assignments, key) + } else { + m.assignments[key] = append([]byte(nil), value...) + } + }) + if err == nil { + m.mu.Lock() + m.loaded = true + m.mu.Unlock() + } + return err +} + +// Run answers the consumers through the helper and retires through the harness's rules, until ctx ends. +// +// The bus is reached through the runtime that launched this bundle, which answers only once Serve runs, and Run starts +// beside Serve: a watch asked too early is asked again, after a wait that doubles from FirstRetry up to a minute, +// until it is answered or ctx ends, so a start that loses the race never leaves the consumers unanswered (issue 943). +func (m *Moved) Run(ctx context.Context, p *provider.Provider, bus provider.Bus) error { + wait := m.FirstRetry + if wait <= 0 { + wait = time.Second + } + for tries := 1; ; tries++ { + err := m.watch(ctx, bus) + if err == nil { + break + } + if tries == 4 || tries%20 == 0 { + m.h.say("the plans cannot be watched yet (%d tries), asking again: %v", tries, err) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(wait): + } + wait = min(wait*2, time.Minute) + } + go func() { + for { + m.retire(ctx) + select { + case <-ctx.Done(): + return + case <-time.After(m.h.Every): + } + } + }() + return p.Run(ctx) +} + +// NewMoved is a provider handed to the provisioner: the helper answering its consumers, and the harness (never run) +// whose retirement and tools it shares. consumersFile is where its machine's client fills its consumers. Run the +// result with Moved.Run, passing the provider and the bus returned with it. +func NewMoved(consumersFile string, h *Harness, spec MovedSpec) (*Moved, *provider.Provider, provider.Bus, error) { + if consumersFile == "" { + return nil, nil, nil, errors.New("no consumers' file") + } + run := newMoved(h, spec) + bus := provider.StateBus(spec.Module) + return run, provider.New(provider.Options{ + Node: h.Node, + Module: spec.Module, + Offers: map[string]provider.Offer{spec.Provision: spec.Offer}, + Bus: bus, + Vault: provider.ConsumersFile{Paths: []string{consumersFile}}, + Make: run.Make, + Gone: run.Gone, + Every: h.Every, + Logf: func(f string, a ...any) { h.say(f, a...) }, + }), bus, nil +} diff --git a/go/provider/harness/moved_test.go b/go/provider/harness/moved_test.go new file mode 100644 index 0000000..16e6219 --- /dev/null +++ b/go/provider/harness/moved_test.go @@ -0,0 +1,367 @@ +package harness + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "sync" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/provider" +) + +const ( + ProvisionName = "postgres-database" + servedPort = 5432 +) + +// postgresSpec is what a postgres-like provider says: a login per consumer, and the grant file's logins. +var postgresSpec = MovedSpec{Module: "postgres", Provision: ProvisionName, + Offer: provider.Offer{Credential: provider.PerConsumer, Serves: map[string]any{"port": servedPort}}, + Login: func(e provider.Entry) string { return e.User }, + Noun: "login", Made: "login", Backend: "the backend", WithGrants: true} + +// bucketBus is the helper's bus over in-memory buckets. +type bucketBus struct { + mu sync.Mutex + buckets map[string]map[string][]byte +} + +func (b *bucketBus) Watch(_ context.Context, bucket string, handle func(string, []byte)) error { + b.mu.Lock() + defer b.mu.Unlock() + for k, v := range b.buckets[bucket] { + handle(k, v) + } + return nil +} + +func (b *bucketBus) Put(_ context.Context, bucket, key string, value []byte) error { + b.mu.Lock() + defer b.mu.Unlock() + if b.buckets[bucket] == nil { + b.buckets[bucket] = map[string][]byte{} + } + b.buckets[bucket][key] = append([]byte(nil), value...) + return nil +} + +func (b *bucketBus) Delete(_ context.Context, bucket, key string) error { + b.mu.Lock() + defer b.mu.Unlock() + delete(b.buckets[bucket], key) + return nil +} + +// bind is one consumer's assignment, whose plan names this machine's postgres as the provider and carries what the +// consumer contributes (issue 776). +func (b *bucketBus) bind(node, module string, contributes map[string]any) { + plan, _ := json.Marshal(map[string]any{"node": node, "module": module, "provisioning": map[string]any{ + "provisions": []any{map[string]any{"provision": ProvisionName, "provider": "novox", "provider-module": "postgres", + "contributes": contributes}}}}) + if err := b.Put(context.Background(), provider.AssignmentsBucket, node+"."+module, plan); err != nil { + panic(err) + } +} + +// consumersFile writes the file the machine's client fills: one credential per consumer. +func consumersFile(t *testing.T, bound map[[2]string]string) string { + var list []provider.ConsumerCredential + for k, password := range bound { + list = append(list, provider.ConsumerCredential{Consumer: k[0], Module: k[1], + Credential: provider.PairName("novox", ProvisionName, k[0], k[1], ""), Value: password}) + } + raw, _ := json.Marshal(list) + at := filepath.Join(t.TempDir(), "consumers.json") + if err := os.WriteFile(at, raw, 0o600); err != nil { + t.Fatal(err) + } + return at +} + +// movedWorld is postgres on the helper over an in-memory bus, with a recorder for a server and a clock to move. +type movedWorld struct { + t *testing.T + a *recorder + bus *bucketBus + h *Harness + run *Moved + p *provider.Provider + now time.Time + file string +} + +func newMovedWorld(t *testing.T, bound map[[2]string]string) *movedWorld { + w := &movedWorld{t: t, a: &recorder{}, bus: &bucketBus{buckets: map[string]map[string][]byte{}}, + now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)} + w.file = consumersFile(t, bound) + w.h = &Harness{Resource: ProvisionName, Adapter: w.a, Node: "novox", Now: func() time.Time { return w.now }, + Log: func(string, ...any) {}} + var bus provider.Bus + w.run, w.p, bus, _ = NewMoved(w.file, w.h, postgresSpec) + _ = bus + if err := w.run.watch(ctx, w.bus); err != nil { + t.Fatal(err) + } + return w +} + +// pass is one helper pass and one retirement pass, five seconds after the last. +func (w *movedWorld) pass() []provider.Outcome { + w.now = w.now.Add(5 * time.Second) + // the plans as the bus holds them now, as the watch would hand them + w.run.assignments = map[string][]byte{} + for k, v := range w.bus.buckets[provider.AssignmentsBucket] { + w.run.assignments[k] = v + } + // the helper reads its buckets from the in-memory bus + w.p = provider.New(provider.Options{Node: "novox", Module: "postgres", Bus: w.bus, + Vault: provider.ConsumersFile{Paths: []string{w.file}}, + Offers: map[string]provider.Offer{ProvisionName: {Credential: provider.PerConsumer, + Serves: map[string]any{"port": servedPort}}}, + Make: w.run.Make, Gone: w.run.Gone, Logf: func(string, ...any) {}}) + out, err := w.p.Pass(ctx) + if err != nil { + w.t.Fatal(err) + } + w.run.retire(ctx) + return out +} + +func bindings(n int) (map[[2]string]string, []string) { + bound := map[[2]string]string{} + var logins []string + for i := 1; i <= n; i++ { + module := fmt.Sprintf("app%d", i) + bound[[2]string{"ace", module}] = "pw-" + module + logins = append(logins, provider.ConsumerIdentity("ace", module)) + } + return bound, logins +} + +// The consumers' file makes the same calls on the adapter as the equivalent grant files do (the harness). +func TestTheConsumersFileMakesWhatTheGrantFilesMake(t *testing.T) { + letta := provider.ConsumerIdentity("ace", "letta") + plain := provider.ConsumerIdentity("ace", "plain") + + // Today: the grant files, through the harness. + g := newWorld(t) + g.give( + map[string]any{"as": letta, "node": "ace", "values": map[string]any{"name": "letta", "extensions": []any{"vector"}}}, + map[string]any{"as": plain, "node": "ace", "values": map[string]any{"name": "plain"}}, + ) + g.h.Reconcile(ctx) + + // The consumers' file, through the provider helper. + w := newMovedWorld(t, map[[2]string]string{{"ace", "letta"}: "pw-" + letta, {"ace", "plain"}: "pw-" + plain}) + w.bus.bind("ace", "letta", map[string]any{"name": "letta", "extensions": []any{"vector"}}) + w.bus.bind("ace", "plain", map[string]any{"name": "plain"}) + for _, o := range w.pass() { + if o.Status != provider.Answered { + t.Fatalf("%+v", o) + } + } + byAs := map[string]Provision{} + for _, c := range w.a.created { + byAs[c.As] = c + } + if len(w.a.created) != 2 { + t.Fatalf("created %d, want 2", len(w.a.created)) + } + for _, want := range g.a.created { + c := byAs[want.As] + if c.As != want.As || c.Password != want.Password || c.Consumer != want.Consumer || + !reflect.DeepEqual(orEmpty(c.Values), orEmpty(want.Values)) { + t.Fatalf("made %+v, the grant files make %+v", c, want) + } + } + // Unchanged, and held: nothing is made again, within a minute or after it. + w.a.held = true + w.pass() + w.now = w.now.Add(2 * time.Minute) + w.pass() + if len(w.a.created) != 2 { + t.Fatalf("an unchanged pass created again: %d", len(w.a.created)) + } + // No longer held: made again. + w.a.held = false + w.now = w.now.Add(2 * time.Minute) + w.pass() + if len(w.a.created) != 4 { + t.Fatalf("not made again: %d", len(w.a.created)) + } +} + +// One read without a consumer retires nothing: it takes the harness's stable passes and time, and then the entry +// goes and the login is retired, never dropped. +func TestAConsumerMissingFromOneReadRetiresNothing(t *testing.T) { + bound, logins := bindings(2) + w := newMovedWorld(t, bound) + w.bus.bind("ace", "app1", nil) + w.bus.bind("ace", "app2", nil) + w.pass() + + // One read without app2. + saved := w.bus.buckets[provider.AssignmentsBucket]["ace.app2"] + w.bus.Delete(ctx, provider.AssignmentsBucket, "ace.app2") + w.pass() + if len(w.a.removed) != 0 { + t.Fatalf("retired %v on one read", w.a.removed) + } + if _, kept := w.bus.buckets[provider.BucketOf("postgres")]["ace.app2.postgres-database"]; !kept { + t.Fatal("the entry went on one read") + } + // It is back: nothing was lost, and the entry never left the bucket. + w.bus.Put(ctx, provider.AssignmentsBucket, "ace.app2", saved) + for i := 0; i < 8; i++ { + w.pass() + } + w.now = w.now.Add(15 * time.Minute) + w.pass() + if len(w.a.removed) != 0 || len(w.bus.buckets[provider.BucketOf("postgres")]) != 2 { + t.Fatalf("retired %v, entries %d", w.a.removed, len(w.bus.buckets[provider.BucketOf("postgres")])) + } + + // Gone for good: not retired before StablePasses and StableFor, then retired and its entry removed. + w.bus.Delete(ctx, provider.AssignmentsBucket, "ace.app2") + for i := 0; i < 6; i++ { + w.pass() + } + if len(w.a.removed) != 0 { + t.Fatalf("retired %v before the time was out", w.a.removed) + } + w.now = w.now.Add(11 * time.Minute) + w.pass() + w.pass() + if !reflect.DeepEqual(w.a.removed, []string{logins[1]}) || len(w.a.deleted) != 0 { + t.Fatalf("retired %v, deleted %v", w.a.removed, w.a.deleted) + } + if _, kept := w.bus.buckets[provider.BucketOf("postgres")]["ace.app2.postgres-database"]; kept { + t.Fatal("the entry stayed after the login was retired") + } +} + +// More than three at once waits for a person, as the harness's bound says. +func TestMoreThanTheBoundWaitsForAPerson(t *testing.T) { + bound, _ := bindings(5) + w := newMovedWorld(t, bound) + for i := 1; i <= 5; i++ { + w.bus.bind("ace", fmt.Sprintf("app%d", i), nil) + } + w.pass() + for i := 1; i <= 4; i++ { + w.bus.Delete(ctx, provider.AssignmentsBucket, fmt.Sprintf("ace.app%d", i)) + } + for i := 0; i < 6; i++ { + w.pass() + } + w.now = w.now.Add(11 * time.Minute) + w.pass() + w.pass() + if len(w.a.removed) != 0 || w.h.r.waiting == nil { + t.Fatalf("retired %v, waiting %v", w.a.removed, w.h.r.waiting) + } +} + +// A plan list that was never read is not nobody asking. +func TestNoPlansReadRetiresNothing(t *testing.T) { + bound, _ := bindings(1) + w := newMovedWorld(t, bound) + w.bus.bind("ace", "app1", nil) + w.pass() + w.bus.Delete(ctx, provider.AssignmentsBucket, "ace.app1") + for i := 0; i < 8; i++ { + w.pass() + } + w.now = w.now.Add(20 * time.Minute) + w.pass() + if len(w.a.removed) != 0 { + t.Fatalf("retired %v with no plans read", w.a.removed) + } +} + +// A consumer whose credential is not in the file yet is waited for: nothing is made for it. +func TestAConsumerWithoutACredentialIsWaitedFor(t *testing.T) { + w := newMovedWorld(t, map[[2]string]string{}) + w.bus.bind("ace", "letta", map[string]any{"name": "letta"}) + outcomes := w.pass() + if len(outcomes) != 1 || outcomes[0].Status != provider.Waiting || len(w.a.created) != 0 { + t.Fatalf("%+v, created %v", outcomes, w.a.created) + } +} + +// Where the file is not there, postgres takes the grant directory, as before; where it is, the helper. +// The grant path restarts itself when the consumers file is first filled. +// A consumer whose module has a slug keeps the login its plan names, the slug first (issue 788): the adapter makes it +// under that login, and the retirement counts it as asked for, so a pass retires nothing. +func TestASluggedConsumerKeepsTheLoginItsPlanNames(t *testing.T) { + agent := provider.ConsumerIdentity("ace", "agent") + w := newMovedWorld(t, map[[2]string]string{{"ace", "claude-code"}: "pw-agent"}) + plan, _ := json.Marshal(map[string]any{"node": "ace", "module": "claude-code", "provisioning": map[string]any{ + "provisions": []any{map[string]any{"provision": ProvisionName, "provider": "novox", + "provider-module": "postgres", "as": agent}}}}) + if err := w.bus.Put(ctx, provider.AssignmentsBucket, "ace.claude-code", plan); err != nil { + t.Fatal(err) + } + for _, o := range w.pass() { + if o.Status != provider.Answered { + t.Fatalf("%+v", o) + } + } + if len(w.a.created) != 1 || w.a.created[0].As != agent { + t.Fatalf("made %+v, want one under %s", w.a.created, agent) + } + for i := 0; i < 8; i++ { + w.pass() + } + w.now = w.now.Add(15 * time.Minute) + w.pass() + w.pass() + if len(w.a.removed) != 0 { + t.Fatalf("retired %v: the plan's login was counted unasked", w.a.removed) + } +} + +// A provider whose spec names the plan's `as` as the login and no grant file (the keycloak shape) makes the consumer +// under that name with the values its spec composes, and a grant file that cannot be read does not hold up its +// retirement pass. +func TestASpecWithoutGrantsMakesUnderThePlansAsAndIgnoresTheGrantFile(t *testing.T) { + a := &recorder{} + bus := &bucketBus{buckets: map[string]map[string][]byte{}} + now := time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC) + h := &Harness{Resource: ProvisionName, Adapter: a, Node: "novox", Now: func() time.Time { return now }, + Receives: filepath.Join(t.TempDir(), "unreadable-or-missing.json"), Log: func(string, ...any) {}} + spec := MovedSpec{Module: "keycloak", Provision: ProvisionName, + Offer: provider.Offer{Credential: provider.PerConsumer}, + Login: func(e provider.Entry) string { return e.As }, Noun: "client id", Made: "client", Backend: "Keycloak", + Values: func(e provider.Entry) map[string]any { return map[string]any{"label": e.Contributes["label"]} }} + file := consumersFile(t, map[[2]string]string{{"ace", "wiki"}: "pw-wiki"}) + run, _, _, err := NewMoved(file, h, spec) + if err != nil { + t.Fatal(err) + } + plan, _ := json.Marshal(map[string]any{"node": "ace", "module": "wiki", "provisioning": map[string]any{ + "provisions": []any{map[string]any{"provision": ProvisionName, "provider": "novox", + "provider-module": "keycloak", "as": "wiki-client", "contributes": map[string]any{"label": "Wiki"}}}}}) + _ = bus.Put(ctx, provider.AssignmentsBucket, "ace.wiki", plan) + if err := run.watch(ctx, bus); err != nil { + t.Fatal(err) + } + p := provider.New(provider.Options{Node: "novox", Module: "keycloak", Bus: bus, + Vault: provider.ConsumersFile{Paths: []string{file}}, + Offers: map[string]provider.Offer{ProvisionName: spec.Offer}, Make: run.Make, Gone: run.Gone, + Logf: func(string, ...any) {}}) + out, err := p.Pass(ctx) + if err != nil || len(out) != 1 || out[0].Status != provider.Answered { + t.Fatalf("%v %+v", err, out) + } + run.retire(ctx) + if len(a.created) != 1 || a.created[0].As != "wiki-client" || a.created[0].Password != "pw-wiki" || + a.created[0].Values["label"] != "Wiki" { + t.Fatalf("made %+v", a.created) + } +}