Judge each provider by what its manifest declares and fill what a provision depends on, so a templated pair provider is provided only after its credential (issue 1954)

A provider declaring bound is judged by its entries, any other by its own have, and only once every provision it depends on, its secret-<provision> first, is provided. The credential is the vault's own provision. One integration test runs the holder on a real bus over mesh-sdk's in-memory mesh: a want composes the key, the outcomes provide it, leaving the node asks. Pins mesh-sdk 6b8c694.
This commit is contained in:
2026-10-29 22:49:26 +01:00
parent bc2e8b1fd2
commit 451d790edd
17 changed files with 549 additions and 42 deletions
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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=
+1 -1
View File
@@ -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
)
+2 -2
View File
@@ -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=
+1 -1
View File
@@ -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
)
+2 -2
View File
@@ -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=
@@ -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, `<module>_bound`, read as `<module>.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, `<module>.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)
}
@@ -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 `<module>.<name>`.
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)
}
}
@@ -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"}`)
@@ -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)
@@ -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)
}
+18 -1
View File
@@ -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
)
+15 -2
View File
@@ -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=
+1 -1
View File
@@ -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
)
+2 -2
View File
@@ -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=