Feature 57 (config and provisioning): modules declare config and provisions, their files rendered from templates; synced with main (issues of feature 57, 874)
This commit is contained in:
@@ -33,7 +33,32 @@ func main() {
|
||||
return
|
||||
}
|
||||
var h *Harness
|
||||
if receives := os.Getenv("MESH_RECEIVES"); receives == "" {
|
||||
if consumers := handedConsumersFile(os.Getenv, os.Stat); consumers != "" {
|
||||
// Handed to the provisioner (novox/hq ADR 0373): the provider helper answers the consumers its
|
||||
// machine's client fills the file with; the grant directory is not read. Retirement is the
|
||||
// harness's, never run, so its guards and its tools stand (issue 780).
|
||||
h = &Harness{
|
||||
Resource: ProvisionName,
|
||||
Adapter: provisioner{pg: pg, announce: announce},
|
||||
Log: func(format string, args ...any) { fmt.Fprintf(os.Stderr, format+"\n", args...) },
|
||||
Announce: func(event string, body map[string]any) {
|
||||
if err := stdio.Emit(event, body); err != nil {
|
||||
say("emit %s failed: %v", event, err)
|
||||
}
|
||||
},
|
||||
Node: os.Getenv("MESH_NODE"),
|
||||
}
|
||||
run, p, bus, err := moved(consumers, h)
|
||||
if err != nil {
|
||||
say("%v", err)
|
||||
} else {
|
||||
go func() {
|
||||
if err := run.Run(context.Background(), p, bus); err != nil {
|
||||
say("the provider helper stopped: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
} else if receives := os.Getenv("MESH_RECEIVES"); receives == "" {
|
||||
say("MESH_RECEIVES is not set — the provisioner cannot run without it")
|
||||
} else {
|
||||
h = &Harness{
|
||||
@@ -52,6 +77,15 @@ func main() {
|
||||
}
|
||||
go h.Run(context.Background())
|
||||
}
|
||||
// Started on the grant path while the module is to be handed, it restarts itself when its client first fills
|
||||
// the consumers' file, so the first fill moves it onto the helper without a hand restart (issue 780): `restart-on`
|
||||
// names a service or a container, and the only one here is the database.
|
||||
if at := os.Getenv("MESH_CONSUMERS_FILE"); at != "" && handedConsumersFile(os.Getenv, os.Stat) == "" {
|
||||
go exitWhenFilled(at, os.Stat, 5*time.Second, func() {
|
||||
say("%s is filled: restarting to answer from it", at)
|
||||
os.Exit(0)
|
||||
})
|
||||
}
|
||||
go audit()
|
||||
// The store's reader is narrowed to what modules declare readable, at once and every minute, whether
|
||||
// or not the provisioner runs: without a contributions file it is given nothing (novox/hq issue 337).
|
||||
@@ -64,6 +98,31 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// handedConsumersFile is the consumers' file when postgres is handed to the provisioner: the file MESH_CONSUMERS_FILE
|
||||
// names, which the controller places only where it hands the module. Empty, where it is not set or the file is not
|
||||
// there, and postgres then reads the grant directory exactly as it always did (issue 780).
|
||||
func handedConsumersFile(getenv func(string) string, stat func(string) (os.FileInfo, error)) string {
|
||||
at := getenv("MESH_CONSUMERS_FILE")
|
||||
if at == "" {
|
||||
return ""
|
||||
}
|
||||
if _, err := stat(at); err != nil {
|
||||
return ""
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
// exitWhenFilled calls filled once the file is there, asking every so often.
|
||||
func exitWhenFilled(at string, stat func(string) (os.FileInfo, error), every time.Duration, filled func()) {
|
||||
for {
|
||||
if _, err := stat(at); err == nil {
|
||||
filled()
|
||||
return
|
||||
}
|
||||
time.Sleep(every)
|
||||
}
|
||||
}
|
||||
|
||||
// announce emits a lifecycle event without letting a broker hiccup fail the provisioning itself.
|
||||
func announce(event string, body map[string]string) {
|
||||
if err := stdio.Emit(event, body); err != nil {
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
package main
|
||||
|
||||
// postgres handed to the provisioner (novox/hq ADR 0373, ADR 0349 clause 12, issue 780): where its consumers file
|
||||
// is set, postgres runs on the SDK's provider helper instead of the harness reading the controller's grant files.
|
||||
//
|
||||
// The helper learns who binds postgres 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 database and role into being exactly as the harness's adapter does, and Gone
|
||||
// retires what was kept for a consumer that no longer binds, the reversible way (retire_pg.go): the login is
|
||||
// locked and marked to delete, nothing is dropped.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/provider"
|
||||
)
|
||||
|
||||
// ProvisionName is what postgres provides, as its manifest says.
|
||||
const ProvisionName = "postgres-database"
|
||||
|
||||
// servedPort is the port its manifest serves (`serves.postgres-database.port`).
|
||||
const servedPort = 5432
|
||||
|
||||
// movedRun is postgres 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 movedRun struct {
|
||||
h *Harness
|
||||
|
||||
mu sync.Mutex // guards assignments and loaded
|
||||
assignments map[string][]byte
|
||||
loaded bool
|
||||
verified map[string]time.Time
|
||||
}
|
||||
|
||||
func newMovedRun(h *Harness) *movedRun {
|
||||
h.mu.Lock()
|
||||
h.init()
|
||||
h.mu.Unlock()
|
||||
return &movedRun{h: h, assignments: map[string][]byte{}, verified: map[string]time.Time{}}
|
||||
}
|
||||
|
||||
// Make brings one consumer's database and role 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 server no longer holds it, which is
|
||||
// asked every VerifyEvery.
|
||||
func (m *movedRun) Make(ctx context.Context, c provider.Consumer) error {
|
||||
h, e := m.h, c.Entry
|
||||
if e.User == "" {
|
||||
return fmt.Errorf("%s has no login to make", e.Key())
|
||||
}
|
||||
if len(c.Credential) == 0 {
|
||||
return fmt.Errorf("%s has no credential to make its login with", e.Key())
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
as, password := e.User, string(c.Credential)
|
||||
p := Provision{As: as, Password: password, Values: orEmpty(e.Contributes), 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: the backend no longer holds it; applying again", as)
|
||||
}
|
||||
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 *movedRun) Gone(_ context.Context, e provider.Entry) error {
|
||||
h := m.h
|
||||
as := e.User
|
||||
if as == "" {
|
||||
return nil // refused: nothing was made for it
|
||||
}
|
||||
// The plans as they are now, not as the last retirement pass read them: with none read, nothing is let go.
|
||||
want, read := m.asked(h.Node)
|
||||
asked := want[as]
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
_, 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 postgres.
|
||||
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 postgres 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 *movedRun) 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 == "postgres" && b.Provision == ProvisionName {
|
||||
as := b.As
|
||||
if as == "" {
|
||||
as = provider.ConsumerIdentity(a.Node, a.Module) // a plan that names none
|
||||
}
|
||||
want[as] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return want, true
|
||||
}
|
||||
|
||||
// retire is one pass of the harness's retirement over what the plans bind now.
|
||||
func (m *movedRun) retire(ctx context.Context) {
|
||||
h := m.h
|
||||
want, ok := m.asked(h.Node)
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
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.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 *movedRun) 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.
|
||||
func (m *movedRun) Run(ctx context.Context, p *provider.Provider, bus provider.Bus) error {
|
||||
if err := m.watch(ctx, bus); err != nil {
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
for {
|
||||
m.retire(ctx)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(m.h.Every):
|
||||
}
|
||||
}
|
||||
}()
|
||||
return p.Run(ctx)
|
||||
}
|
||||
|
||||
// moved is postgres 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.
|
||||
func moved(consumersFile string, h *Harness) (*movedRun, *provider.Provider, provider.Bus, error) {
|
||||
if consumersFile == "" {
|
||||
return nil, nil, nil, errors.New("no consumers' file")
|
||||
}
|
||||
run := newMovedRun(h)
|
||||
bus := provider.StateBus("postgres")
|
||||
return run, provider.New(provider.Options{
|
||||
Node: h.Node,
|
||||
Module: "postgres",
|
||||
Offers: map[string]provider.Offer{ProvisionName: {
|
||||
Credential: provider.PerConsumer,
|
||||
Serves: map[string]any{"port": servedPort},
|
||||
}},
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/provider"
|
||||
)
|
||||
|
||||
// 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 *movedRun
|
||||
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, _ = moved(w.file, w.h)
|
||||
_ = 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.
|
||||
func TestTheGrantPathIsTakenWhereTheConsumersFileIsAbsent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
at := filepath.Join(dir, "consumers.json")
|
||||
env := func(k string) string {
|
||||
if k == "MESH_CONSUMERS_FILE" {
|
||||
return at
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if got := handedConsumersFile(env, os.Stat); got != "" {
|
||||
t.Fatalf("took %q for a file that is not there", got)
|
||||
}
|
||||
if got := handedConsumersFile(func(string) string { return "" }, os.Stat); got != "" {
|
||||
t.Fatalf("took %q with no environment", got)
|
||||
}
|
||||
if err := os.WriteFile(at, []byte("[]"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := handedConsumersFile(env, os.Stat); got != at {
|
||||
t.Fatalf("took %q, want %q", got, at)
|
||||
}
|
||||
}
|
||||
|
||||
// The grant path restarts itself when the consumers file is first filled.
|
||||
func TestTheGrantPathLeavesWhenTheConsumersFileIsFilled(t *testing.T) {
|
||||
at := filepath.Join(t.TempDir(), "consumers.json")
|
||||
done := make(chan struct{})
|
||||
go exitWhenFilled(at, os.Stat, time.Millisecond, func() { close(done) })
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("left before the file was there")
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
if err := os.WriteFile(at, []byte("[]"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("did not leave when the file was 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
${consumers:postgres-database}
|
||||
@@ -3,7 +3,7 @@ module postgres
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261015210729-d3e182503c19
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261015210729-d3e182503c19 h1:mAMt3KFmrHzDiBv4MLL94opWcG9y73ISMrBUyisapQE=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261015210729-d3e182503c19/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"module": "postgres",
|
||||
"version": "1",
|
||||
"moved": true,
|
||||
"upgrade": {
|
||||
"policy": "record",
|
||||
"why": "a provider whose restart drops every consumer on its machine, and whose new major version changes its data's format: a person takes each build, after a backup (hq ADR 0236)"
|
||||
@@ -102,6 +103,13 @@
|
||||
"type": "directory",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "consumers",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/consumers.json",
|
||||
"mode": "0600",
|
||||
"template": "files/consumers.json"
|
||||
},
|
||||
{
|
||||
"id": "store-data",
|
||||
"type": "directory",
|
||||
@@ -159,9 +167,21 @@
|
||||
"MESH_PROVISION_POSTGRES": "postgres://postgres@127.0.0.1:${port:5432}/postgres?sslmode=disable",
|
||||
"MESH_PROVISION_PASSWORD_FILE": "${dir:state}/superuser.secret",
|
||||
"MESH_RECEIVES": "${dir:grants}/mesh.json",
|
||||
"MESH_POSTGRES_READER_PASSWORD_FILE": "${dir:state}/reader.secret"
|
||||
"MESH_POSTGRES_READER_PASSWORD_FILE": "${dir:state}/reader.secret",
|
||||
"MESH_CONSUMERS_FILE": "${dir:state}/consumers.json"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"state": [
|
||||
"bound"
|
||||
],
|
||||
"reads": [
|
||||
"mesh-controller.assignments"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user