diff --git a/checks/health/go.mod b/checks/health/go.mod index 8c3f521e..f6437708 100644 --- a/checks/health/go.mod +++ b/checks/health/go.mod @@ -2,4 +2,4 @@ module checks/health go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214150-692dbf9a1791 +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a diff --git a/checks/health/go.sum b/checks/health/go.sum index 387b26bd..ea2abb0e 100644 --- a/checks/health/go.sum +++ b/checks/health/go.sum @@ -1,2 +1,2 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214150-692dbf9a1791 h1:HVKR7kENzltQVkfBN+2zhU9JOPZme7MjDkTvr/oiJ24= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214150-692dbf9a1791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a h1:elC2IjB+qbeXG+pWItLz71DenegbrdsqbcH5kyGo56Q= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= diff --git a/modules/mesh-config/go.mod b/modules/mesh-config/go.mod index e6d2dae0..ab232f12 100644 --- a/modules/mesh-config/go.mod +++ b/modules/mesh-config/go.mod @@ -2,7 +2,7 @@ module mesh-config go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214150-692dbf9a1791 +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a require ( github.com/klauspost/compress v1.20.0 // indirect diff --git a/modules/mesh-config/go.sum b/modules/mesh-config/go.sum index b8d81e43..041bd525 100644 --- a/modules/mesh-config/go.sum +++ b/modules/mesh-config/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214150-692dbf9a1791 h1:HVKR7kENzltQVkfBN+2zhU9JOPZme7MjDkTvr/oiJ24= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214150-692dbf9a1791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a h1:elC2IjB+qbeXG+pWItLz71DenegbrdsqbcH5kyGo56Q= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-hand-acts/go.mod b/modules/mesh-hand-acts/go.mod index df0875c3..295b1813 100644 --- a/modules/mesh-hand-acts/go.mod +++ b/modules/mesh-hand-acts/go.mod @@ -3,7 +3,7 @@ module mesh-hand-acts go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214150-692dbf9a1791 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-hand-acts/go.sum b/modules/mesh-hand-acts/go.sum index a0208db4..9ca8e0a6 100644 --- a/modules/mesh-hand-acts/go.sum +++ b/modules/mesh-hand-acts/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214150-692dbf9a1791 h1:HVKR7kENzltQVkfBN+2zhU9JOPZme7MjDkTvr/oiJ24= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214150-692dbf9a1791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a h1:elC2IjB+qbeXG+pWItLz71DenegbrdsqbcH5kyGo56Q= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-healers/go.mod b/modules/mesh-healers/go.mod index 6351b3a4..ec4ba312 100644 --- a/modules/mesh-healers/go.mod +++ b/modules/mesh-healers/go.mod @@ -3,7 +3,7 @@ module mesh-healers go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214150-692dbf9a1791 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-healers/go.sum b/modules/mesh-healers/go.sum index a0208db4..9ca8e0a6 100644 --- a/modules/mesh-healers/go.sum +++ b/modules/mesh-healers/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214150-692dbf9a1791 h1:HVKR7kENzltQVkfBN+2zhU9JOPZme7MjDkTvr/oiJ24= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214150-692dbf9a1791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a h1:elC2IjB+qbeXG+pWItLz71DenegbrdsqbcH5kyGo56Q= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/holder.go b/modules/mesh-provisioner/cmd/mesh-provisioner/holder.go index a97e47c7..b916e3c1 100644 --- a/modules/mesh-provisioner/cmd/mesh-provisioner/holder.go +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/holder.go @@ -23,6 +23,7 @@ import ( "encoding/json" "errors" "fmt" + "slices" "sort" "strings" "sync" @@ -51,9 +52,10 @@ const ( ResolvedRead = "mesh-config.resolved" ) -// Providers are the providing modules that keep an entry per consumer, `_bound`, read as `.bound`: the -// outcome of each provision they make. A provider not named here keeps none. -var Providers = []string{"gitea", "keycloak", "postgres", "route-adapter", "mesh-vault"} +// BoundReads are the providers' entries this module's manifest reads, `.bound`, whose changes it hears. Which +// path judges a provider is its manifest's word (keepsEntries), not this list: a provider that declares `bound` and is +// not read here leaves its keys asked, saying its entries are not read. +var BoundReads = []string{"gitea", "keycloak", "postgres", "route-adapter", "mesh-vault"} // boundRead is a provider's entries as this module reads them. func boundRead(module string) string { return module + ".bound" } @@ -111,12 +113,15 @@ func (h *Holder) ComposeNode(ctx context.Context, node string) (Composed, error) out.Problems = append(out.Problems, err.Error()) } needed := map[string]provisions.Provision{} + elsewhere := map[string]provisions.Provision{} + vaultNode := h.vaultNode() for _, plans := range []map[string]catalogue.Plan{atWants, newest} { for module, plan := range plans { for _, p := range plan.Provisions { - pv := provisions.Provision{Provision: p.Provision, Local: p.Local, - Consumer: provisions.End{Module: module, Node: node}, - Provider: provisions.End{Module: p.ProviderModule, Node: p.Provider}} + consumer := provisions.End{Module: module, Node: node} + provider := provisions.End{Module: p.ProviderModule, Node: p.Provider} + pv := provisions.Provision{Provision: p.Provision, Local: p.Local, Consumer: consumer, Provider: provider, + DependsOn: provisions.DependsOn(consumer, p, h.providerPlan(p, node, atWants))} key, err := pv.Key() if err != nil { out.Problems = append(out.Problems, err.Error()) @@ -125,13 +130,117 @@ func (h *Holder) ComposeNode(ctx context.Context, node string) (Composed, error) if _, seen := needed[key]; !seen { needed[key] = pv } + // Its credential is a provision of its own, the vault's (clause 6): on the consumer's node for a value of the + // consumer's own, on the provider's for the provider's one shared value. + ck, ok := provisions.CredentialKey(p.CredentialForm, consumer, provider, p.Provision, p.Local) + if !ok { + continue + } + cn, cm, cp, cl, err := provisions.ParseKey(ck) + if err != nil { + continue + } + if vaultNode == "" { + out.Problems = append(out.Problems, "no node runs mesh-vault, so the credential "+ck+" has no provider") + continue + } + cred := provisions.Provision{Provision: cp, Local: cl, Consumer: provisions.End{Module: cm, Node: cn}, + Provider: provisions.End{Module: provisions.VaultModule, Node: vaultNode}} + if cn == node { + if _, seen := needed[ck]; !seen { + needed[ck] = cred + } + } else { + elsewhere[ck] = cred + } } } } + h.judgeElsewhere(elsewhere, &out) h.judgeNode(node, needed, &out) return out, nil } +// providerPlan is the plan of a provision's provider on its node: from this composition when it is on this node, else +// as kept in `plans`; nil when the provider is not handed. +func (h *Holder) providerPlan(p catalogue.PlanProvision, node string, here map[string]catalogue.Plan) *catalogue.Plan { + if p.Provider == node { + if plan, ok := here[p.ProviderModule]; ok { + return &plan + } + return nil + } + key, err := provisions.PlanKey(p.Provider, p.ProviderModule) + if err != nil { + return nil + } + e, err := h.Store.Get(PlansState, key) + if err != nil || e == nil { + return nil + } + plan, err := provisions.DecodePlan(e.Value) + if err != nil { + return nil + } + return &plan.Plan +} + +// vaultNode is the node mesh-vault is placed on: the provider of every credential. +func (h *Holder) vaultNode() string { + nodes := h.placedOn(provisions.VaultModule) + if len(nodes) == 0 { + return "" + } + return nodes[0] +} + +// dependedOn is every key of a node that a provision of another node depends on. +func (h *Holder) dependedOn(node string) []string { + var out []string + keys, err := h.Store.Keys(ProvisionsState) + if err != nil { + return nil + } + for _, k := range keys { + if strings.HasPrefix(k, node+".") { + continue + } + e, err := h.Store.Get(ProvisionsState, k) + if err != nil || e == nil { + continue + } + if p, err := provisions.Decode(e.Value); err == nil { + for _, d := range p.DependsOn { + if strings.HasPrefix(d, node+".") { + out = append(out, d) + } + } + } + } + return out +} + +// judgeElsewhere writes each needed key of another node (a provider's shared credential), its state from its outcome; +// that node's own composition keeps it while a provision depends on it. +func (h *Holder) judgeElsewhere(keys map[string]provisions.Provision, out *Composed) { + for _, key := range sortedKeys(keys) { + next := keys[key] + var have provisions.Provision + e, err := h.Store.Get(ProvisionsState, key) + if err == nil && e != nil { + if have, err = provisions.Decode(e.Value); err == nil { + next.Reference = have.Reference + } + } + h.outcome(key, &next) + if e != nil && same(have, next) { + continue + } + h.put(key, next) + out.Changed = append(out.Changed, key+" "+next.State) + } +} + // wantedOn is the fingerprint each want on a node names, by module. func (h *Holder) wantedOn(node string) (map[string]string, error) { out := map[string]string{} @@ -202,6 +311,15 @@ func (h *Holder) writePlans(node string, plans map[string]catalogue.Plan, out *C // judgeNode brings the node's provision keys to what is needed and what each provider said. func (h *Holder) judgeNode(node string, needed map[string]provisions.Provision, out *Composed) { held := h.held(node) + // A key of this node another node's provision depends on is needed (a provider's one shared credential). + for _, dep := range h.dependedOn(node) { + if have, ok := held[dep]; ok { + if _, n := needed[dep]; !n { + have.State, have.Why, have.Ask, have.Warrant = "", "", "", nil + needed[dep] = have + } + } + } for _, key := range unionKeys(needed, held) { want, isNeeded := needed[key] have, isHeld := held[key] @@ -305,10 +423,20 @@ type entry struct { // asked, said so. func (h *Holder) outcome(key string, p *provisions.Provision) { p.Ask, p.Warrant = "", nil - if !keepsEntries(p.Provider.Module) { + keeps, err := h.keepsEntries(p.Provider.Module) + if err != nil { + p.State, p.Why = provisions.Asked, "the manifest of "+p.Provider.Module+" cannot be read: "+err.Error() + return + } + if !keeps { h.haveOutcome(p) return } + if !slices.Contains(BoundReads, p.Provider.Module) { + p.State, p.Why = provisions.Asked, p.Provider.Module+" keeps its outcome in its entries, which mesh-provisioner's "+ + "manifest does not read yet" + return + } e, err := h.Store.Get(boundRead(p.Provider.Module), key) if err != nil { p.State, p.Why = provisions.Asked, "the provider's entries cannot be read: "+err.Error() @@ -348,6 +476,19 @@ func (h *Holder) outcome(key string, p *provisions.Provision) { // provided; failed is failed with the have's words; anything else is asked, waiting for the provider to run. func (h *Holder) haveOutcome(p *provisions.Provision) { where := p.Provider.Module + " on " + p.Provider.Node + // It makes what the consumer needs only once all it depends on is provided (its credential first). + for _, dep := range p.DependsOn { + state := "not composed yet" + if e, err := h.Store.Get(ProvisionsState, dep); err == nil && e != nil { + if d, err := provisions.Decode(e.Value); err == nil { + state = d.State + } + } + if state != provisions.Provided { + p.State, p.Why = provisions.Asked, "waits for "+dep+" ("+state+") before "+where+" can provide it" + return + } + } key := p.Provider.Node + "." + p.Provider.Module e, err := h.Store.Get(HaveRead, key) if err != nil { @@ -380,13 +521,53 @@ func (h *Holder) haveOutcome(p *provisions.Provision) { } } -func keepsEntries(module string) bool { - for _, m := range Providers { - if m == module { - return true +// keepsEntries says a provider's manifest at the trunk declares the state `bound`: its outcome per consumer is its +// entries there; any other provider's is its own have. +func (h *Holder) keepsEntries(module string) (bool, error) { + mk, err := delivery.ModulesKey(module) + if err != nil { + return false, err + } + e, err := h.Store.Get(ModulesRead, mk) + if err != nil || e == nil { + return false, fmt.Errorf("the indexer holds no modules.%s (%v)", module, err) + } + m, err := delivery.DecodeModule(e.Value) + if err != nil || m.Manifest == "" { + return false, fmt.Errorf("the indexer's modules.%s names no manifest", module) + } + key, err := delivery.ManifestsKey(m.Manifest) + if err != nil { + return false, err + } + me, err := h.Store.Get(ManifestsRead, key) + if err != nil || me == nil { + return false, fmt.Errorf("the indexer holds no manifest of %s (%v)", module, err) + } + man, err := delivery.DecodeManifest(me.Value) + if err != nil { + return false, err + } + var decl struct { + State []json.RawMessage `json:"state"` + } + if err := json.Unmarshal([]byte(man.JSON), &decl); err != nil { + return false, err + } + for _, raw := range decl.State { + var name string + if json.Unmarshal(raw, &name) != nil { + var full struct { + Name string `json:"name"` + } + _ = json.Unmarshal(raw, &full) + name = full.Name + } + if name == "bound" { + return true, nil } } - return false + return false, nil } // retired says a retiring key the operator approved is retired: its provider's entry is gone. @@ -394,7 +575,7 @@ func (h *Holder) retired(key string, p provisions.Provision) (bool, string) { if p.Warrant == nil || p.Warrant.Option != OptionApprove { return false, "" } - if !keepsEntries(p.Provider.Module) { + if keeps, err := h.keepsEntries(p.Provider.Module); err != nil || !keeps { return false, "" } e, err := h.Store.Get(boundRead(p.Provider.Module), key) @@ -557,6 +738,24 @@ func NodesOfWant(key string) []string { // NodesOfModule is every node a module is placed on, and every node whose plans name it as a provider: what a change // of the module's manifest touches. func (h *Holder) NodesOfModule(module string) []string { + nodes := map[string]bool{} + for _, n := range h.placedOn(module) { + nodes[n] = true + } + if keys, err := h.Store.Keys(ProvisionsState); err == nil { + for _, k := range keys { + if e, err := h.Store.Get(ProvisionsState, k); err == nil && e != nil { + if p, err := provisions.Decode(e.Value); err == nil && p.Provider.Module == module { + nodes[p.Consumer.Node] = true + } + } + } + } + return sortedKeys(nodes) +} + +// placedOn is every node a module is placed on, sorted. +func (h *Holder) placedOn(module string) []string { nodes := map[string]bool{} if keys, err := h.Store.Keys(PlacementRead); err == nil { for _, k := range keys { @@ -577,15 +776,6 @@ func (h *Holder) NodesOfModule(module string) []string { } } } - if keys, err := h.Store.Keys(ProvisionsState); err == nil { - for _, k := range keys { - if e, err := h.Store.Get(ProvisionsState, k); err == nil && e != nil { - if p, err := provisions.Decode(e.Value); err == nil && p.Provider.Module == module { - nodes[p.Consumer.Node] = true - } - } - } - } return sortedKeys(nodes) } diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/holder_integration_test.go b/modules/mesh-provisioner/cmd/mesh-provisioner/holder_integration_test.go new file mode 100644 index 00000000..9aa766c3 --- /dev/null +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/holder_integration_test.go @@ -0,0 +1,229 @@ +//go:build integration + +package main + +// The holder against a real bus (novox/hq ADR 0570 clauses 1-3, 5; issue 1954 H5): its states and every input it reads +// are a real nats-server's key-value buckets, its plans composed by mesh-sdk's composition from bus state over the +// in-memory mesh frombustest gives (restic on n1, requiring postgres-database from postgres there). A placement and a +// want compose the key; the providers' outcomes (the vault's entry for the credential, postgres's own have) turn it +// provided; restic leaving the node makes it retiring and raises the ask. + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-sdk/go/asks" + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/compose/frombus" + "git.novox.be/novox/mesh-sdk/go/compose/frombus/frombustest" + "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/provisions" + "git.novox.be/novox/mesh-sdk/go/testbus" +) + +// kvStore is the Store over a real bus: this module's states by their local names, another's as `.`. +type kvStore struct { + ctx context.Context + js jetstream.JetStream +} + +func bucketOf(state string) string { + if owner, name, ok := strings.Cut(state, "."); ok { + return owner + "_" + name + } + return Module + "_" + state +} + +func (s kvStore) kv(state string) (jetstream.KeyValue, error) { + kv, err := s.js.KeyValue(s.ctx, bucketOf(state)) + if errors.Is(err, jetstream.ErrBucketNotFound) { + return s.js.CreateKeyValue(s.ctx, jetstream.KeyValueConfig{Bucket: bucketOf(state)}) + } + return kv, err +} + +func (s kvStore) Get(state, key string) (*Entry, error) { + kv, err := s.kv(state) + if err != nil { + return nil, err + } + e, err := kv.Get(s.ctx, key) + if errors.Is(err, jetstream.ErrKeyNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &Entry{Key: key, Value: e.Value(), Revision: e.Revision()}, nil +} + +func (s kvStore) Put(state, key string, value any) error { + kv, err := s.kv(state) + if err != nil { + return err + } + raw, err := json.Marshal(value) + if err != nil { + return err + } + _, err = kv.Put(s.ctx, key, raw) + return err +} + +func (s kvStore) Delete(state, key string) error { + kv, err := s.kv(state) + if err != nil { + return err + } + return kv.Delete(s.ctx, key) +} + +func (s kvStore) Keys(state string) ([]string, error) { + kv, err := s.kv(state) + if err != nil { + return nil, err + } + keys, err := kv.Keys(s.ctx) + if errors.Is(err, jetstream.ErrNoKeysFound) { + return nil, nil + } + return keys, err +} + +type asked struct{ ids []string } + +func (a *asked) Publish(_ string, body any, id string) error { + a.ids = append(a.ids, id) + _ = body.(asks.Ask) + return nil +} +func (a *asked) Record(string) ([]byte, bool, error) { return nil, false, nil } + +// 1954/H5. +func TestTheHolderComposesProvidesAndAsksOnTheBus(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + nc, err := nats.Connect(testbus.URL(t)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(nc.Close) + js, err := jetstream.New(nc) + if err != nil { + t.Fatal(err) + } + s := kvStore{ctx: ctx, js: js} + mesh, err := frombustest.Provisioned() + if err != nil { + t.Fatal(err) + } + node := frombustest.Node + // The bus as the holder reads it: the indexer's modules and manifests, a placement naming where the vault runs, the + // want of restic on n1. + for module, m := range mesh.Module { + m.V = 1 + raw, _ := json.Marshal(m) + if err := s.Put(ModulesRead, module, json.RawMessage(raw)); err != nil { + t.Fatal(err) + } + } + for digest, raw := range mesh.Manifests { + var named struct { + Module string `json:"module"` + } + _ = json.Unmarshal(raw, &named) + value, _ := delivery.EncodeManifest(delivery.Manifest{Module: named.Module, JSON: string(raw)}) + key, _ := delivery.ManifestsKey(digest) + if err := s.Put(ManifestsRead, key, json.RawMessage(value)); err != nil { + t.Fatal(err) + } + } + // The vault keeps its outcomes as entries: its manifest declares `bound`. + vaultManifest := `{"module":"mesh-vault","version":"1","state":["bound"]}` + vaultDigest := delivery.ManifestDigest([]byte(vaultManifest)) + vaultValue, _ := delivery.EncodeManifest(delivery.Manifest{Module: provisions.VaultModule, JSON: vaultManifest}) + vaultKey, _ := delivery.ManifestsKey(vaultDigest) + if err := s.Put(ManifestsRead, vaultKey, json.RawMessage(vaultValue)); err != nil { + t.Fatal(err) + } + if err := s.Put(ModulesRead, provisions.VaultModule, map[string]any{"v": 1, "none2": "the test's", "manifest": vaultDigest, + "repository": "novox/mesh-catalog", "commit": "c1"}); err != nil { + t.Fatal(err) + } + vault, _ := json.Marshal(map[string]any{"v": 1, "node": "n2", "decided": map[string]any{}, + "modules": []map[string]any{{"module": provisions.VaultModule}}}) + if err := s.Put(PlacementRead, "placement.n2", json.RawMessage(vault)); err != nil { + t.Fatal(err) + } + fp := frombustest.FPs()["restic"] + if err := s.Put(WantsRead, "want."+node+".restic", map[string]any{"v": 1, "fp": fp, "self": true}); err != nil { + t.Fatal(err) + } + a := &asked{} + h := &Holder{Store: s, Asks: a, Now: time.Now, Logf: t.Logf, + Compose: func(ctx context.Context, node string, keptAt map[string]string) (map[string]catalogue.Plan, error) { + return frombus.Plans(ctx, mesh, frombus.Mesh{}, frombus.Scope{Node: node, KeptAt: keptAt}) + }} + state := func(key string) provisions.Provision { + e, _ := s.Get(ProvisionsState, key) + if e == nil { + return provisions.Provision{} + } + p, _ := provisions.Decode(e.Value) + return p + } + key := node + ".restic.postgres-database" + if _, err := h.ComposeNode(ctx, node); err != nil { + t.Fatal(err) + } + if p := state(key); p.State != provisions.Asked || p.Provider.Module != "postgres" { + t.Fatalf("the placement and the want composed %+v", p) + } + if e, _ := s.Get(PlansState, node+".restic"); e == nil { + t.Fatal("restic's plan is not on the bus") + } + // The providers' outcomes: the vault provides the credential where restic has one, postgres runs healthy. + for _, dep := range state(key).DependsOn { + if err := s.Put(boundRead(provisions.VaultModule), dep, map[string]any{"state": "provided"}); err != nil { + t.Fatal(err) + } + } + if err := s.Put(HaveRead, node+".postgres", map[string]any{"v": 1, "fp": frombustest.FPs()["postgres"], "phase": "running", + "health": "ok"}); err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { // the credential judged first, then what depends on it + if _, err := h.ComposeNode(ctx, node); err != nil { + t.Fatal(err) + } + } + if p := state(key); p.State != provisions.Provided { + t.Fatalf("the outcomes did not turn it provided: %+v", p) + } + // restic leaves the node and its want goes: retiring, one ask. + p := mesh.Placement[node] + var kept []delivery.PlacedModule + for _, m := range p.Modules { + if m.Module != "restic" { + kept = append(kept, m) + } + } + p.Modules = kept + mesh.Placement[node] = p + if err := s.Delete(WantsRead, "want."+node+".restic"); err != nil { + t.Fatal(err) + } + if _, err := h.ComposeNode(ctx, node); err != nil { + t.Fatal(err) + } + if r := state(key); r.State != provisions.Retiring || r.Ask == "" || len(a.ids) == 0 { + t.Fatalf("removing the want did not ask: %+v, asked %v", r, a.ids) + } +} diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/holder_test.go b/modules/mesh-provisioner/cmd/mesh-provisioner/holder_test.go index 4c319ee1..0f3c5609 100644 --- a/modules/mesh-provisioner/cmd/mesh-provisioner/holder_test.go +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/holder_test.go @@ -14,6 +14,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/provisions" ) @@ -71,9 +72,27 @@ func newMesh() *mesh { } return m.atNewest[node], nil }} + // What each provider's manifest declares: postgres, keycloak and the vault keep entries, the others report by their + // have. + for module, states := range map[string]string{"postgres": `["bound"]`, "keycloak": `["bound"]`, "mesh-vault": `["bound"]`, + "systemd-resolved": `[]`, "minio": `[]`} { + raw := `{"module":"` + module + `","version":"1","state":` + states + `}` + digest := delivery.ManifestDigest([]byte(raw)) + value, _ := delivery.EncodeManifest(delivery.Manifest{Module: module, JSON: raw}) + _ = m.s.Put(ManifestsRead, mustKey(delivery.ManifestsKey(digest)), json.RawMessage(value)) + _ = m.s.Put(ModulesRead, mustKey(delivery.ModulesKey(module)), + json.RawMessage(`{"v":1,"none2":"a test","manifest":"`+digest+`","repository":"novox/mesh-catalog","commit":"c1"}`)) + } return m } +func mustKey(k string, err error) string { + if err != nil { + panic(err) + } + return k +} + // needs makes a module on a node require provisions in its wanted build (and the same in its newest unless set). func planOf(provs ...catalogue.PlanProvision) catalogue.Plan { return catalogue.Plan{Provisions: provs} @@ -167,6 +186,45 @@ func TestAKeysStateFollowsItsProvidersOutcome(t *testing.T) { if c := m.compose(t, "g14"); len(c.Plans)+len(c.Changed)+len(c.Asked) != 0 { t.Errorf("a quiet pass wrote %+v", c) } + // A provision's credential is the vault's own provision, on the consumer's node for a login of its own, and the + // provision depends on it (clauses 3, 6). + c := newMesh() + c.s.write(t, PlacementRead, "placement.novox", `{"v":1,"node":"novox","decided":{},"modules":[{"module":"mesh-vault"}]}`) + pair := pgDB + pair.CredentialForm = "pair" + c.atWant["g14"] = map[string]catalogue.Plan{"gitea": planOf(pair)} + c.atNewest["g14"] = c.atWant["g14"] + c.compose(t, "g14") + cred := c.state(t, "g14.gitea.secret-postgres-database") + if cred.Provider.Module != "mesh-vault" || cred.Provider.Node != "novox" || cred.State != provisions.Asked { + t.Errorf("the credential's provision is %+v", cred) + } + if d := c.state(t, key).DependsOn; !slices.Equal(d, []string{"g14.gitea.secret-postgres-database"}) { + t.Errorf("the provision depends on %v", d) + } + // A pair provider applying the vault's value through its templates (no entries) is provided only once its credential + // key is provided and it runs healthy (the lead's refinement of clause 3). + bucket := catalogue.PlanProvision{Provision: "s3-bucket", Provider: "g14", ProviderModule: "minio", CredentialForm: "pair"} + c.atWant["g14"] = map[string]catalogue.Plan{"gitea": planOf(bucket)} + c.atNewest["g14"] = c.atWant["g14"] + c.s.write(t, HaveRead, "g14.minio", `{"v":1,"fp":"src2:`+strings.Repeat("cc", 32)+`","phase":"running","health":"ok"}`) + s3, s3cred := "g14.gitea.s3-bucket", "g14.gitea.secret-s3-bucket" + for _, row := range []struct { + name, entry, cred, state string + }{ + {"its credential still asked", "", provisions.Asked, provisions.Asked}, + {"its credential provided by the vault", `{"state":"provided","vault-name":"pair/g14/s3-bucket/g14/gitea","generation":1}`, + provisions.Provided, provisions.Provided}, + } { + if row.entry != "" { + c.s.write(t, "mesh-vault.bound", s3cred, row.entry) + } + c.compose(t, "g14") + c.compose(t, "g14") // the credential judged first, then what depends on it + if got, dep := c.state(t, s3), c.state(t, s3cred); got.State != row.state || dep.State != row.cred { + t.Errorf("%s: %s is %s (%s), its credential %s", row.name, s3, got.State, got.Why, dep.State) + } + } // An entry's change judges its key alone, no composition. before := len(m.composed) m.s.write(t, "postgres.bound", key, `{"refused":"the login is locked"}`) diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/main.go b/modules/mesh-provisioner/cmd/mesh-provisioner/main.go index 583072b0..b411037b 100644 --- a/modules/mesh-provisioner/cmd/mesh-provisioner/main.go +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/main.go @@ -129,7 +129,7 @@ func main() { } return nil }) - for _, provider := range Providers { + for _, provider := range BoundReads { go watched(boundRead(provider), func(c stdio.StateChange) error { if said, err := h.Rejudge(c.Key); err != nil { logf("[%s] %s is not judged again: %v", Module, c.Key, err) diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/provisioner_test.go b/modules/mesh-provisioner/cmd/mesh-provisioner/provisioner_test.go index 6d48aadb..94bf9195 100644 --- a/modules/mesh-provisioner/cmd/mesh-provisioner/provisioner_test.go +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/provisioner_test.go @@ -329,7 +329,7 @@ func TestTheManifestDeclaresWhatTheCodeKeeps(t *testing.T) { t.Errorf("the manifest does not read %s, which the composition reads", r) } } - for _, p := range Providers { + for _, p := range BoundReads { if !slices.Contains(m.Reads, boundRead(p)) { t.Errorf("the manifest does not read %s's entries", p) } diff --git a/modules/mesh-provisioner/go.mod b/modules/mesh-provisioner/go.mod index b380be43..0d804dd9 100644 --- a/modules/mesh-provisioner/go.mod +++ b/modules/mesh-provisioner/go.mod @@ -2,4 +2,21 @@ module mesh-provisioner go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214150-692dbf9a1791 +require ( + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a + github.com/nats-io/nats.go v1.54.0 +) + +require ( + github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect + github.com/google/go-tpm v0.9.8 // indirect + github.com/klauspost/compress v1.20.0 // indirect + github.com/minio/highwayhash v1.0.4 // indirect + github.com/nats-io/jwt/v2 v2.8.1 // indirect + github.com/nats-io/nats-server/v2 v2.11.17 // indirect + github.com/nats-io/nkeys v0.4.16 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/time v0.15.0 // indirect +) diff --git a/modules/mesh-provisioner/go.sum b/modules/mesh-provisioner/go.sum index d2e21659..9ca8e0a6 100644 --- a/modules/mesh-provisioner/go.sum +++ b/modules/mesh-provisioner/go.sum @@ -1,7 +1,17 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214150-692dbf9a1791 h1:HVKR7kENzltQVkfBN+2zhU9JOPZme7MjDkTvr/oiJ24= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214150-692dbf9a1791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a h1:elC2IjB+qbeXG+pWItLz71DenegbrdsqbcH5kyGo56Q= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= +github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= +github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= +github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= +github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= +github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= @@ -10,5 +20,8 @@ github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= diff --git a/modules/mesh-self-check/go.mod b/modules/mesh-self-check/go.mod index fd63c2a8..273063e8 100644 --- a/modules/mesh-self-check/go.mod +++ b/modules/mesh-self-check/go.mod @@ -3,7 +3,7 @@ module mesh-self-check go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214150-692dbf9a1791 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-self-check/go.sum b/modules/mesh-self-check/go.sum index a0208db4..9ca8e0a6 100644 --- a/modules/mesh-self-check/go.sum +++ b/modules/mesh-self-check/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214150-692dbf9a1791 h1:HVKR7kENzltQVkfBN+2zhU9JOPZme7MjDkTvr/oiJ24= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214150-692dbf9a1791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a h1:elC2IjB+qbeXG+pWItLz71DenegbrdsqbcH5kyGo56Q= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=