From 542c3214c61c92710fa2a67f73478ffadfa37ba0 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 29 Oct 2026 23:51:33 +0100 Subject: [PATCH] mesh-provisioner-client: read plans from the seat, record no needs, leave ${secret:} for the node-engine ADR 0570 clauses 1, 2 and 6 move the plan to mesh-provisioner_plans, fold needs into the seat and give the node-engine the secret's fill, so the client no longer holds a value (issue 1954, V3). --- modules/mesh-provisioner-client/README.md | 35 +-- .../asked_again_test.go | 12 +- .../cmd/mesh-provisioner-client/asks_test.go | 7 +- .../bound_gitea_test.go | 2 +- .../cmd/mesh-provisioner-client/client.go | 163 ++++------ .../mesh-provisioner-client/client_test.go | 66 ++-- .../mesh-provisioner-client/collect_test.go | 9 +- .../mesh-provisioner-client/consumers_test.go | 9 +- .../drift_integration_test.go | 30 +- .../cmd/mesh-provisioner-client/ended_test.go | 58 ---- .../moved_forge_test.go | 9 +- .../mesh-provisioner-client/pulled_in_test.go | 12 +- .../route_names_test.go | 30 +- .../cmd/mesh-provisioner-client/secrets.go | 21 -- .../mesh-provisioner-client/secrets_test.go | 282 ++---------------- .../testdata/accounts.conf | 2 +- modules/mesh-provisioner-client/go.mod | 2 +- modules/mesh-provisioner-client/go.sum | 4 +- 18 files changed, 217 insertions(+), 536 deletions(-) delete mode 100644 modules/mesh-provisioner-client/cmd/mesh-provisioner-client/ended_test.go diff --git a/modules/mesh-provisioner-client/README.md b/modules/mesh-provisioner-client/README.md index e4382024..b4188388 100644 --- a/modules/mesh-provisioner-client/README.md +++ b/modules/mesh-provisioner-client/README.md @@ -4,35 +4,24 @@ Holds the `node-provisioner` seat on every node: the provisioner's client (novox P5). Go, one binary (`cmd/mesh-provisioner-client`), run as a service of its own under the account `mesh-provisioner-client`, outside the tool runner, on a bus account of its own, `provisioner.`, delivered as its own secret `provisioner-broker` (mesh-controller `internal/broker/provisioner.go`). The bus grants that account -its own machine's keys and, to read alone, the whole assignments mirror, where a provider's consumers on any -machine are bound to it (ADR 0373); nothing else. +its own machine's keys and, to read alone, the seat mesh-provisioner's whole `plans`, where a provider's consumers on +any machine are bound to it (ADR 0373); nothing else. ## What it does -For each module the controller handed to the provisioner on its node — an assignment in -`mesh-controller_assignments` (`.`) whose value carries a `provisioning` plan: the templates archive +For each module placed on its node — a plan in the seat mesh-provisioner's `plans` (`mesh-provisioner_plans`, +`.`, novox/hq ADR 0570 clause 2), composed there from bus state: the templates archive by reference and digest, the files it fills (path, template, `owner-at-runtime`, how its program takes a change) and the placement values its templates name — the client: - fetches the templates archive and uses it only when it hashes to the plan's digest, keeping it by digest; -- records each placeholder as a need in `mesh-provisioner_needs`, `...` (a character a - key cannot hold written `=`); - watches each owner's keys for its node: placement in the plan, config in `mesh-config_resolved` (`..`), both named by `mesh-provisioner_registry`; -- asks the vault (`mesh-vault.get`, named by the registry) for each of the module's own secrets its plan names - (`provisioning.secrets`), in its own name on the subject naming its account, with the public half of a key it - made at start and keeps in memory alone; the vault answers the secret sealed to that key (ADR 0206's box), so only - the box crosses the bus. A secret is asked once per run, and again when the vault says `secret-changed` for it, - so a rotation reaches its file without a new declaration. A secret with a `path` is written to that file alone, - as an `owned` file. A secret the vault does not hold yet, or a vault that does not answer, is waited for; a - refusal fails the module with the vault's reason and is asked again after ten minutes, or at once when the - module's assignment changes or the node-engine touches its write socket, either of which may end it. A value is never written - to a bucket, a report or a log; -- asks the vault the same way for each credential of what the module requires that its plan names - (a `provisioning.provisions` entry with a `name`, issue 757; every entry names the provider serving that - provision, issue 760, and its offer's credential form, issue 768, which the vault acts on): the value a provider's software issued for this module on this machine, - `provision////` (the forge's read token for claude-code, issue 737), filled - wherever `${secret:}` names it and written to its `path` alone, and asked again on its `secret-changed`; +- leaves every `${secret:}` its plan names (a module's own secret, `provisioning.secrets`, or the credential + of what it requires, a `provisioning.provisions` entry with a `name`) as the placeholder, for the node-engine to + fill when it writes the file (novox/hq ADR 0570 clause 6): the client never asks the vault for it and never holds the + value. A secret with a `path` is handed as that file alone, holding the placeholder. A secret the plan does not name + fails the module; - fills each item the module contributes to a collection (`provisioning.items`: the collection and its template) from the same owners as a file, and records it in `mesh-provisioner_collect`, `..` (a character a key's token cannot hold written `=`: `shell:zsh:normal` is `shell=3Azsh=3Anormal`). An item @@ -58,13 +47,13 @@ and the placement values its templates name — the client: 4): every file of the module goes through the node-engine again, so an `owned` file changed on disk is put back; - reports `prov.applied..` and keeps the same report in `mesh-provisioner_applied` (`.`): `applied` with each file's action and digest, `waiting`, `failed` with the reason, or `removed`; -- when a module is taken off the node, removes its needs, its items and its report (the node-engine removes the files its +- when a module is taken off the node, removes its items and its report (the node-engine removes the files its declaration no longer names); and when the bus comes back, or the client starts, fills from the current values; - fills a provider's `${consumers:}` (novox/hq ADR 0373, issue 679) with one entry per consumer bound to - it for that provision, in the order of the assignments' keys and, within a plan, of its `provisions` entries: a + it for that provision, in the order of the plans' keys and, within a plan, of its `provisions` entries: a JSON list, each entry `{consumer, module, local, credential, value}`, the credential its own, `pair////[/]`, asked of the vault in the client's own name as a secret - is. Who binds a provider is read from every machine's plans, so the client watches the whole assignments mirror + is. Who binds a provider is read from every machine's plans, so the client watches the whole of `plans` (it carries no secret's value) and reads only its own machine's plans as its own. A consumer whose credential the vault does not hold yet, or will not hand, is left out and said in the log, so the others are served; the provider's helper waits for it (mesh-sdk `go/provider` ConsumersFile). It is filled again when a consumer binds or diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/asked_again_test.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/asked_again_test.go index fc5dcfae..329d4d04 100644 --- a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/asked_again_test.go +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/asked_again_test.go @@ -123,7 +123,7 @@ func (f fakeEntry) Operation() jetstream.KeyValueOp { return f.op } // afresh, whichever way it left or returned, so it is never filled with a generation held from before; a module // that stays placed keeps using what it holds. func TestAModulePlacedAgainAsksTheVaultAfreshAndAModuleThatStaysDoesNot(t *testing.T) { - const assignment = `{"node":"g14","module":"build","provisioning":{}}` + const assignment = `{"v":1,"node":"g14","module":"build","plan":{}}` // the seat's plan (ADR 0570 clause 2) own := VaultName("g14", "build", "bus-password") pair := PairName("g14", "db", "g14", "build", "") other := VaultName("g14", "gitea", "token") @@ -133,15 +133,15 @@ func TestAModulePlacedAgainAsksTheVaultAfreshAndAModuleThatStaysDoesNot(t *testi again int // asks of the vault after the first three: the module's own secret once more, or none }{ {name: "it stays placed: the assignment is put again", again: 0, steps: func(c *Client, s *state) { - c.take(s, update{bucket: AssignmentsBucket, entry: fakeEntry{key: "g14.build", val: []byte(assignment)}}) + c.take(s, update{bucket: PlansBucket, entry: fakeEntry{key: "g14.build", val: []byte(assignment)}}) }}, {name: "its assignment is deleted and put again", again: 1, steps: func(c *Client, s *state) { - c.take(s, update{bucket: AssignmentsBucket, entry: fakeEntry{key: "g14.build", op: jetstream.KeyValueDelete}}) - c.take(s, update{bucket: AssignmentsBucket, entry: fakeEntry{key: "g14.build", val: []byte(assignment)}}) + c.take(s, update{bucket: PlansBucket, entry: fakeEntry{key: "g14.build", op: jetstream.KeyValueDelete}}) + c.take(s, update{bucket: PlansBucket, entry: fakeEntry{key: "g14.build", val: []byte(assignment)}}) }}, {name: "it left and the client was away: only the later assignment arrives", again: 1, steps: func(c *Client, s *state) { delete(s.assigned, "build") - c.take(s, update{bucket: AssignmentsBucket, entry: fakeEntry{key: "g14.build", val: []byte(assignment)}}) + c.take(s, update{bucket: PlansBucket, entry: fakeEntry{key: "g14.build", val: []byte(assignment)}}) }}, {name: "it leaves the machine", again: 1, steps: func(c *Client, s *state) { c.leave(context.Background(), nil, s, "build") @@ -152,7 +152,7 @@ func TestAModulePlacedAgainAsksTheVaultAfreshAndAModuleThatStaysDoesNot(t *testi c := &Client{Node: "g14", Secrets: fake, Now: time.Now} s := &state{secrets: map[string]heldSecret{}, plans: map[string]Plan{}, refused: map[string]string{}, assigned: map[string]bool{"build": true}, binds: map[string]binding{}, providing: map[string]bool{}, - leftOut: map[string]string{}, needs: map[string][]byte{}, applied: map[string]bool{}, + leftOut: map[string]string{}, applied: map[string]bool{}, reported: map[string][]byte{}, items: map[string]map[string]itemValue{}, recorded: map[string][]byte{}, unplaced: map[string]bool{}} n := Need{Kind: "secret", Name: "bus-password"} diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/asks_test.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/asks_test.go index 9cf10e90..844f58a6 100644 --- a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/asks_test.go +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/asks_test.go @@ -73,10 +73,11 @@ func (r *toolRunner) hear() chan string { return heard } -// placed writes an assignment of a module the controller has not handed to the provisioner: placed, no plan. +// placed writes the plan of a module placed here with nothing to provision: an empty plan (ADR 0570: a placed +// module has a plan key, empty or not). func (b *aBus) placed(t *testing.T, module string) { t.Helper() - b.put(t, AssignmentsBucket, node+"."+module, fmt.Sprintf(`{"node":%q,"module":%q,"assigned":"2026-10-14T20:00:00Z"}`, + b.put(t, PlansBucket, node+"."+module, fmt.Sprintf(`{"node":%q,"module":%q,"provisioning":{}}`, node, module)) } @@ -119,7 +120,7 @@ func TestNatsAModulesCodeReadsItsConfigAndIsToldOfAChange(t *testing.T) { } // Taken off the machine: said, and no longer answered. - b.del(t, AssignmentsBucket, node+".claude-code") + b.del(t, PlansBucket, node+".claude-code") until(t, heard, `claude-code {"kind":"config"}`) if a := runner.askConfig("claude-code", "managed_settings"); !strings.Contains(a.Error, "not placed on g14") { t.Fatalf("a module taken off the machine was answered %+v", a) diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/bound_gitea_test.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/bound_gitea_test.go index 9794aa39..dc564cfd 100644 --- a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/bound_gitea_test.go +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/bound_gitea_test.go @@ -65,7 +65,7 @@ func TestNatsAHandedGiteasServerEnvIsFilledAsTheControllerComposedIt(t *testing. `"values":{"at":"10.7.0.1","from":"novox","port":5432},"vault-name":"pair/novox/postgres-database/g14/gitea"}`, "FROM=${bound:postgres-database:from}\n") eventually(t, "server.env is filled to the controller's text", func() bool { - return e.read(t, giteaEnvPath) == giteaBoundExpected("tok-never-on-the-bus", "pw-never-on-the-bus")+"FROM=novox\n" + return e.read(t, giteaEnvPath) == giteaBoundExpected("${secret:internal-token}", "${secret:postgres-database}")+"FROM=novox\n" }) } diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/client.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/client.go index beacf539..3fb44ab7 100644 --- a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/client.go +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/client.go @@ -1,6 +1,8 @@ package main import ( + "git.novox.be/novox/mesh-sdk/go/provisions" + "bytes" "context" "encoding/json" @@ -39,11 +41,12 @@ import ( // The buckets the client reads and writes, and the subject it reports on. const ( - AssignmentsBucket = "mesh-controller_assignments" - ConfigBucket = "mesh-config_resolved" - RegistryBucket = "mesh-provisioner_registry" - NeedsBucket = "mesh-provisioner_needs" - AppliedBucket = "mesh-provisioner_applied" + // PlansBucket is where each module's provisioning plan on each node is read: the seat mesh-provisioner's + // `plans` (novox/hq ADR 0570 clause 2), read as an assignment's value carried it (asAssignment). + PlansBucket = provisions.PlansBucket + ConfigBucket = "mesh-config_resolved" + RegistryBucket = "mesh-provisioner_registry" + AppliedBucket = "mesh-provisioner_applied" ) // AppliedSubject is where the client says what it applied for one module (ADR 0349 clauses 4 and 15). @@ -82,16 +85,6 @@ type FileApplied struct { Takes string `json:"takes,omitempty"` } -// NeedRecord is one need a module's templates name, as mesh-provisioner_needs keeps it at -// `...`: the key's name in a form the bus takes, and the name as written here. -type NeedRecord struct { - Node string `json:"node"` - Module string `json:"module"` - Kind string `json:"kind"` - Name string `json:"name"` - Files []string `json:"files"` -} - // Answer is a registry's entry, as mesh-provisioner keeps it: which domain answers a kind, and where. type Answer struct { Kind string `json:"kind"` @@ -136,7 +129,6 @@ type state struct { plans map[string]Plan refused map[string]string // a module whose plan cannot be acted on, and why config map[string]map[string]json.RawMessage - needs map[string][]byte // key → value recorded applied map[string]bool // module → a report is kept reported map[string][]byte // module → the last report said, undated secrets map[string]heldSecret // the vault's name → what this run holds of it; never written anywhere @@ -181,8 +173,7 @@ func (c *Client) Run(ctx context.Context) error { ctx, cancel := context.WithCancel(ctx) defer cancel() kvs := map[string]jetstream.KeyValue{} - for _, b := range []string{AssignmentsBucket, ConfigBucket, RegistryBucket, NeedsBucket, AppliedBucket, - CollectBucket} { + for _, b := range []string{PlansBucket, ConfigBucket, RegistryBucket, AppliedBucket, CollectBucket} { kv, err := c.JS.KeyValue(ctx, b) if err != nil { return fmt.Errorf("the bucket %s: %w", b, err) @@ -220,12 +211,12 @@ func (c *Client) Run(ctx context.Context) error { mine := c.Node + ".>" // The registry whole, and every machine's assignment: a provider here is bound by consumers on any machine, in // their plans (ADR 0373). Every other bucket, this machine's keys alone. - for _, b := range []string{RegistryBucket, AssignmentsBucket} { + for _, b := range []string{RegistryBucket, PlansBucket} { if err := watch(b, ">"); err != nil { return err } } - for _, b := range []string{ConfigBucket, NeedsBucket, AppliedBucket, CollectBucket} { + for _, b := range []string{ConfigBucket, AppliedBucket, CollectBucket} { if err := watch(b, mine); err != nil { return err } @@ -307,7 +298,7 @@ func (c *Client) Run(ctx context.Context) error { defer func() { _ = asks.Unsubscribe() }() s := &state{registry: map[string]Answer{}, plans: map[string]Plan{}, refused: map[string]string{}, - config: map[string]map[string]json.RawMessage{}, needs: map[string][]byte{}, applied: map[string]bool{}, + config: map[string]map[string]json.RawMessage{}, applied: map[string]bool{}, reported: map[string][]byte{}, secrets: map[string]heldSecret{}, items: map[string]map[string]itemValue{}, recorded: map[string][]byte{}, assigned: map[string]bool{}, binds: map[string]binding{}, providing: map[string]bool{}, leftOut: map[string]string{}, bound: map[string]map[string][]byte{}, @@ -315,7 +306,7 @@ func (c *Client) Run(ctx context.Context) error { // Every current value first: nothing is filled from half of them. current := map[string]bool{} var early []*nats.Msg - for len(current) < 6 { + for len(current) < 5 { // the registry, the plans, config, applied, collect select { case <-ctx.Done(): return ctx.Err() @@ -491,7 +482,7 @@ func (c *Client) told(u update) { if module, name, ok := strings.Cut(rest, "."); ok { c.tell(module, Changed{Kind: AskConfig, Key: name}) } - case AssignmentsBucket: + case PlansBucket: c.tell(rest, Changed{Kind: AskConfig}) } } @@ -539,16 +530,25 @@ func (c *Client) take(s *state, u update) []string { } // A kind's answer moves what every module waits on. return sortedKeys(s.plans) - case AssignmentsBucket: + case PlansBucket: + value := e.Value() + if !gone { + if v, err := asAssignment(value); err != nil { + logf("[provisioner-client] the plan %s cannot be read, so its module leaves: %v", key, err) + gone = true + } else { + value = v + } + } // A value that is not a standing assignment (ended, or in a state: ADR 0408 clause 4) is read as a removed // key, so the module leaves the machine as it does when the key goes (issue 1098). - gone = gone || !provider.Standing(e.Value()) + gone = gone || !provider.Standing(value) // Whom it binds, whichever machine's it is: a provider here listing its consumers is filled again. var providers []string if ended, had := s.binds[key]; gone && had { c.forgetBound(s, ended) } - if takeBinds(s, key, e.Value(), gone) { + if takeBinds(s, key, value, gone) { providers = sortedKeys(s.providing) } module, mine := strings.CutPrefix(key, c.Node+".") @@ -571,7 +571,7 @@ func (c *Client) take(s *state, u update) []string { logf("[provisioner-client] %s: its assignment changed, so the vault is asked again for %s", module, strings.Join(dropped, ", ")) } - m, p, err := planOf(c.Node, key, e.Value()) + m, p, err := planOf(c.Node, key, value) switch { case errors.Is(err, errNoPlan): case err != nil: @@ -597,12 +597,6 @@ func (c *Client) take(s *state, u update) []string { s.config[module][name] = r.Value } return []string{module} - case NeedsBucket: - if gone { - delete(s.needs, key) - } else { - s.needs[key] = e.Value() - } case AppliedBucket: module := strings.TrimPrefix(key, c.Node+".") s.applied[module] = !gone @@ -652,14 +646,9 @@ func (c *Client) take(s *state, u update) []string { return nil } -// leftModules are the modules with needs or a report kept and no assignment with a plan. +// leftModules are the modules with a report or items kept and no assignment with a plan. func (c *Client) leftModules(s *state) []string { left := map[string]bool{} - for key := range s.needs { - if parts := strings.SplitN(key, ".", 3); len(parts) == 3 && !hasPlan(s, parts[1]) && s.refused[parts[1]] == "" { - left[parts[1]] = true - } - } for m, kept := range s.applied { if kept && !hasPlan(s, m) && s.refused[m] == "" { left[m] = true @@ -673,9 +662,8 @@ func (c *Client) leftModules(s *state) []string { return sortedKeys(left) } -// leave removes a module's needs and its report: it is no longer the provisioner's on this node. +// leave removes a module's report and its items: it is no longer the provisioner's on this node. func (c *Client) leave(ctx context.Context, kvs map[string]jetstream.KeyValue, s *state, module string) { - prefix := c.Node + "." + module + "." delete(s.unplaced, module) // What it held of the vault goes with it: placed again, it asks afresh (issue 1945). c.forgetHeld(s, module) @@ -685,19 +673,8 @@ func (c *Client) leave(ctx context.Context, kvs map[string]jetstream.KeyValue, s delete(s.leftOut, k) } } - removed := 0 - for _, key := range sortedKeys(s.needs) { - if strings.HasPrefix(key, prefix) { - if err := kvs[NeedsBucket].Delete(ctx, key); err != nil { - logf("[provisioner-client] the need %s could not be removed: %v", key, err) - continue - } - delete(s.needs, key) - removed++ - } - } // Its items go: an item is in its collection only while its contributor is placed here. - removed += c.forgetItems(ctx, kvs, s, module, nil) + removed := c.forgetItems(ctx, kvs, s, module, nil) if !s.applied[module] && removed == 0 && s.reported[module] == nil { return } @@ -754,7 +731,7 @@ func (c *Client) reconcile(ctx context.Context, kvs map[string]jetstream.KeyValu for _, it := range items { named(it.id(), it.text) } - c.recordNeeds(ctx, kvs, s, module, order, needs) + _ = needs // the client records no need (ADR 0570 clause 1): the seat composes them values := map[Need]string{} var failed []string @@ -902,14 +879,14 @@ func (c *Client) valueOf(ctx context.Context, s *state, module string, p Plan, n } return collectionText(s.items[collection], col.Comment, where), "", "" case n.Kind == "secret": - if !known { - return "", n.String() + " (no domain answers secret in the registry yet)", "" + // Left for the node-engine, which opens the vault's value and fills it when it writes the file on the + // client's word (ADR 0570 clause 6): the client never holds the value. + if !slices.ContainsFunc(p.Secrets, func(sec PlannedSecret) bool { return sec.Name == n.Name }) && + !slices.ContainsFunc(p.Provisions, func(pr PlannedProvision) bool { return pr.Name == n.Name }) { + return "", "", fmt.Sprintf("%s: the plan names no secret %s, and the node-engine fills only what this "+ + "machine's plan names", n, n.Name) } - if answer.Ask != VaultSeat+"."+GetVerb { - return "", "", fmt.Sprintf("%s: the registry answers secret from %q, which this client does not ask", n, - answer.Ask) - } - return c.secretOf(ctx, s, module, p, n) + return "${secret:" + n.Name + "}", "", "" case n.Kind == ConsumersKind: // A provider's consumers' credentials, each the vault's (ADR 0373). if !known { @@ -930,52 +907,6 @@ func (c *Client) valueOf(ctx context.Context, s *state, module string, p Plan, n return "", "", fmt.Sprintf("%s: this client does not fill %s placeholders yet", n, n.Kind) } -// recordNeeds makes the module's needs in mesh-provisioner_needs what its templates name: each need written when it -// is new or names other files, each one no longer named removed. -func (c *Client) recordNeeds(ctx context.Context, kvs map[string]jetstream.KeyValue, s *state, module string, - order []Need, files map[Need][]string) { - want := map[string][]byte{} - for _, n := range order { - v, _ := json.Marshal(NeedRecord{Node: c.Node, Module: module, Kind: n.Kind, Name: n.Name, Files: files[n]}) - want[NeedKey(c.Node, module, n)] = v - } - for _, key := range sortedKeys(want) { - if bytes.Equal(s.needs[key], want[key]) { - continue - } - if _, err := kvs[NeedsBucket].Put(ctx, key, want[key]); err != nil { - logf("[provisioner-client] the need %s could not be recorded: %v", key, err) - continue - } - s.needs[key] = want[key] - } - prefix := c.Node + "." + module + "." - for _, key := range sortedKeys(s.needs) { - if _, still := want[key]; strings.HasPrefix(key, prefix) && !still { - if err := kvs[NeedsBucket].Delete(ctx, key); err != nil { - logf("[provisioner-client] the need %s could not be removed: %v", key, err) - continue - } - delete(s.needs, key) - } - } -} - -// NeedKey is a need's key in mesh-provisioner_needs, `...`: the name with every -// character a key cannot hold, and `=`, written `=`, so `${seat:mesh-broker:4222}` is `seat.mesh-broker=3A4222`. -func NeedKey(node, module string, n Need) string { - var b strings.Builder - for _, r := range []byte(n.Name) { - switch { - case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '/', r == '.': - b.WriteByte(r) - default: - fmt.Fprintf(&b, "=%02X", r) - } - } - return node + "." + module + "." + n.Kind + "." + b.String() -} - // report says a module's report on prov.applied and keeps it in mesh-provisioner_applied, when it says something // other than the last. func (c *Client) report(ctx context.Context, kvs map[string]jetstream.KeyValue, s *state, r Report) { @@ -1151,3 +1082,21 @@ func (c *Client) forgetItems(ctx context.Context, kvs map[string]jetstream.KeyVa } return removed } + +// asAssignment is a plan of the seat mesh-provisioner as the client's rules read an assignment's value: +// `{node, module, provisioning: plan}`. +func asAssignment(raw []byte) ([]byte, error) { + if _, err := provisions.DecodePlan(raw); err != nil { + return nil, err + } + // The plan as it is kept, its bytes unchanged: the client reads it strictly (planOf). + var kept struct { + Node string `json:"node"` + Module string `json:"module"` + Plan json.RawMessage `json:"plan"` + } + if err := json.Unmarshal(raw, &kept); err != nil { + return nil, err + } + return json.Marshal(map[string]any{"node": kept.Node, "module": kept.Module, "provisioning": kept.Plan}) +} diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/client_test.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/client_test.go index 1ad81773..303083ec 100644 --- a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/client_test.go +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/client_test.go @@ -81,7 +81,7 @@ func startBus(t *testing.T) *aBus { t.Fatal(err) } b := &aBus{url: s.ClientURL(), domain: js, conn: nc, kv: map[string]jetstream.KeyValue{}} - for _, name := range []string{AssignmentsBucket, ConfigBucket, RegistryBucket, NeedsBucket, AppliedBucket, + for _, name := range []string{PlansBucket, ConfigBucket, RegistryBucket, AppliedBucket, CollectBucket, delivery.HaveBucket} { kv, err := js.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: name}) if err != nil { @@ -98,13 +98,25 @@ func startBus(t *testing.T) *aBus { } for _, kind := range placementKinds { b.put(t, RegistryBucket, kind, `{"kind":"`+kind+`","domain":"mesh-controller","from":"bucket",`+ - `"bucket":"mesh-controller_assignments","key":"."}`) + `"bucket":"mesh-provisioner_plans","key":"."}`) } return b } func (b *aBus) put(t *testing.T, bucket, key, value string) { t.Helper() + if bucket == PlansBucket { + // The seat mesh-provisioner keeps a plan, not an assignment (ADR 0570 clause 2): a module with no plan, or an + // ended one, has no key. + raw, ok := planned(value) + if !ok { + if err := b.kv[bucket].Delete(context.Background(), key); err != nil { + t.Fatal(err) + } + return + } + value = string(raw) + } if _, err := b.kv[bucket].PutString(context.Background(), key, value); err != nil { t.Fatalf("writing %s in %s: %v", key, bucket, err) } @@ -131,7 +143,7 @@ func (b *aBus) assign(t *testing.T, module string, plan Plan) { if err != nil { t.Fatal(err) } - b.put(t, AssignmentsBucket, node+"."+module, fmt.Sprintf(`{"node":%q,"module":%q,"assigned":"2026-10-14T20:00:00Z",`+ + b.put(t, PlansBucket, node+"."+module, fmt.Sprintf(`{"node":%q,"module":%q,"assigned":"2026-10-14T20:00:00Z",`+ `"provisioning":%s}`, node, module, p)) } @@ -430,11 +442,6 @@ func TestNatsATemplateFillsFromEachOwnersKeys(t *testing.T) { if r.Templates == "" || len(r.Files) != 1 || r.Files[0].Action != "created" || r.Files[0].Takes != "reload-on" { t.Errorf("prov.applied said %+v", r) } - // The needs, one per placeholder, under the node's and the module's name. - eventually(t, "the needs are recorded", func() bool { - got := b.keys(t, NeedsBucket, node+".dunst.") - return len(got) == 2 && got[0] == "g14.dunst.config.font-size" && got[1] == "g14.dunst.machine.account-home" - }) kept, err := b.kv[AppliedBucket].Get(context.Background(), node+".dunst") if err != nil || !strings.Contains(string(kept.Value()), `"status":"applied"`) { t.Errorf("mesh-provisioner_applied keeps %v (%v)", kept, err) @@ -603,17 +610,16 @@ func TestNatsWithNothingOverTheBusItAsksForNoWriteNoSecretAndNoReport(t *testing } } -func TestNatsAModuleTakenOffTheNodeLosesItsNeedsAndItsReport(t *testing.T) { +func TestNatsAModuleTakenOffTheNodeLosesItsReport(t *testing.T) { b := startBus(t) heard := b.listen(t) e := startEngine(t, map[string]string{dunstrc: "owned"}) b.config(t, "dunst", "font-size", "13") b.assign(t, "dunst", dunst(t)) aClient(t, b, e, t.TempDir()) - eventually(t, "the needs are recorded", func() bool { return len(b.keys(t, NeedsBucket, node+".dunst.")) == 2 }) + eventually(t, "its report is kept", func() bool { return len(b.keys(t, AppliedBucket, node+".dunst")) == 1 }) - b.del(t, AssignmentsBucket, node+".dunst") - eventually(t, "its needs go", func() bool { return len(b.keys(t, NeedsBucket, node+".dunst.")) == 0 }) + b.del(t, PlansBucket, node+".dunst") eventually(t, "its report goes", func() bool { return len(b.keys(t, AppliedBucket, node+".dunst")) == 0 }) eventually(t, "prov.applied says it is removed", func() bool { r, _ := heard.last("dunst") @@ -640,14 +646,13 @@ func TestNatsANodeComingBackFillsFromTheCurrentValues(t *testing.T) { // Away: a value changes and a module leaves, and the artifact store is gone. stop() b.config(t, "dunst", "font-size", "17") - b.del(t, AssignmentsBucket, node+".zsh") + b.del(t, PlansBucket, node+".zsh") heard := b.listen(t) aClient(t, b, e, state) eventually(t, "dunstrc has the value set while it was away", func() bool { return strings.Contains(e.read(t, dunstrc), "Monospace 17") }) - eventually(t, "the module that left lost its needs", func() bool { return len(b.keys(t, NeedsBucket, node+".zsh.")) == 0 }) eventually(t, "and its report", func() bool { r, _ := heard.last("zsh") return r.Status == StatusRemoved && len(b.keys(t, AppliedBucket, node+".zsh")) == 0 @@ -662,7 +667,7 @@ func TestNatsWhatCannotBeFilledFailsWithTheReason(t *testing.T) { e := startEngine(t, map[string]string{dunstrc: "owned", "/etc/app.conf": "owned"}) aClient(t, b, e, t.TempDir()) - b.put(t, AssignmentsBucket, node+".broken", `{"node":"g14","module":"broken","provisioning":{"templates":`+ + b.put(t, PlansBucket, node+".broken", `{"node":"g14","module":"broken","provisioning":{"templates":`+ `{"source":"x","digest":"sha256:00"},"files":[],"surprise":1}}`) p := dunst(t) p.Templates.Digest = "sha256:" + strings.Repeat("0", 64) @@ -683,19 +688,6 @@ func TestNatsWhatCannotBeFilledFailsWithTheReason(t *testing.T) { } } -func TestANeedsKeyHoldsAnyName(t *testing.T) { - for n, want := range map[Need]string{ - {Kind: "config", Name: "font-size"}: "g14.dunst.config.font-size", - {Kind: "seat", Name: "mesh-broker:4222"}: "g14.dunst.seat.mesh-broker=3A4222", - {Kind: "config", Name: "a=b c"}: "g14.dunst.config.a=3Db=20c", - {Kind: "machine", Name: "account-home"}: "g14.dunst.machine.account-home", - } { - if got := NeedKey(node, "dunst", n); got != want { - t.Errorf("%v: %s, want %s", n, got, want) - } - } -} - // TestNatsResticsFilesFillFromTheBackupPlacement is the backup lines as placement (issue 794): restic's two files // are the plan's `backup:lines` and `backup:data`, each exactly the text the controller composed, trailing newline // and all, and a changed plan refills them. @@ -778,3 +770,21 @@ func TestNatsTheNodesOwnHaveMovingFillsEveryModuleAgain(t *testing.T) { t.Errorf("dunstrc was asked %d times for one change", after-before) } } + +// planned is an assignment's value as the seat mesh-provisioner keeps its plan, `{v, node, module, plan}`; false for one +// with no plan, or ended, or in a state, which has no key there. +func planned(assignment string) ([]byte, bool) { + var a struct { + Node string `json:"node"` + Module string `json:"module"` + Provisioning json.RawMessage `json:"provisioning"` + Ended json.RawMessage `json:"ended"` + State json.RawMessage `json:"state"` + } + if json.Unmarshal([]byte(assignment), &a) != nil || len(a.Provisioning) == 0 || string(a.Provisioning) == "null" || + len(a.Ended) > 0 || len(a.State) > 0 { + return nil, false + } + raw, _ := json.Marshal(map[string]any{"v": 1, "node": a.Node, "module": a.Module, "plan": a.Provisioning}) + return raw, true +} diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/collect_test.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/collect_test.go index 71c7503a..defb001a 100644 --- a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/collect_test.go +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/collect_test.go @@ -63,18 +63,13 @@ func TestNatsZshsFileHoldsEveryPlacedContributorsItemAndLosesOneWhoseContributor r, _ := heard.last("powerlevel10k") return r.Status == StatusApplied && len(r.Items) == 1 && r.Items[0] == "shell:zsh:normal" }) - // zsh records the collections it reads as its needs, like any value. - eventually(t, "zsh's needs are recorded", func() bool { - got := b.keys(t, NeedsBucket, node+".zsh.") - return len(got) == 2 && got[0] == "g14.zsh.collected.zsh-widgets" && got[1] == "g14.zsh.shell.zsh=3Anormal" - }) // A contributor's own value changes: its item, and so zshrc, follow. b.config(t, "fzf", "binding", `"^F"`) eventually(t, "zshrc follows the item", func() bool { return strings.Contains(e.read(t, zshrc), "bindkey ^F") }) // powerlevel10k leaves the node: its item goes, and zshrc without it. - b.del(t, AssignmentsBucket, node+".powerlevel10k") + b.del(t, PlansBucket, node+".powerlevel10k") without := "# zsh\n# mesh-cli\nalias nox=mesh-cli\n# then\nbindkey ^F fzf-file-widget\n# end\n" eventually(t, "zshrc loses the item of the contributor that left", func() bool { return e.read(t, zshrc) == without }) if got := b.keys(t, CollectBucket, node+".shell=3Azsh=3Anormal.powerlevel10k"); len(got) != 0 { @@ -98,7 +93,7 @@ func TestNatsACollectorStartingWithItsNodeHoldsTheItemsAlreadyThereAndOnesRecord eventually(t, "zshrc holds mesh-cli's item", func() bool { return strings.Contains(e.read(t, zshrc), "alias nox") }) stop() - b.del(t, AssignmentsBucket, node+".mesh-cli") + b.del(t, PlansBucket, node+".mesh-cli") b.assign(t, "zsh-autosuggestions", contributor(t, "shell:zsh:normal", "source suggestions.zsh\n")) aClient(t, b, e, state) want := "# zsh\n# zsh-autosuggestions\nsource suggestions.zsh\n# then\n# end\n" diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/consumers_test.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/consumers_test.go index e48d403b..048e218b 100644 --- a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/consumers_test.go +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/consumers_test.go @@ -35,7 +35,7 @@ func (b *aBus) bind(t *testing.T, at, module string, provisions ...PlannedProvis if err != nil { t.Fatal(err) } - b.put(t, AssignmentsBucket, at+"."+module, fmt.Sprintf(`{"node":%q,"module":%q,"assigned":"2026-10-14T20:00:00Z",`+ + b.put(t, PlansBucket, at+"."+module, fmt.Sprintf(`{"node":%q,"module":%q,"assigned":"2026-10-14T20:00:00Z",`+ `"provisioning":{"templates":{"source":"x","digest":"sha256:%s"},"files":[{"id":"c","path":"/etc/c",`+ `"template":"c","owner-at-runtime":"owned"}],"provisions":%s}}`, at, module, strings.Repeat("0", 64), ps)) } @@ -140,7 +140,7 @@ func TestNatsAProvidersConsumersAreFilledFromTheVaultAndFollowEachBindAndRemake( return sameList(consumersIn(t, e), letta, photos, gitea) }) // A consumer unbinds. - b.del(t, AssignmentsBucket, "shanks.gitea") + b.del(t, PlansBucket, "shanks.gitea") eventually(t, "a consumer that unbinds leaves the file", func() bool { return sameList(consumersIn(t, e), letta, photos) }) @@ -160,11 +160,6 @@ func TestNatsAProvidersConsumersAreFilledFromTheVaultAndFollowEachBindAndRemake( t.Errorf("a consumer's credential crossed the bus in the clear: %q", said) } } - // The need is recorded as any other, by name: never a value. - if keys := b.keys(t, NeedsBucket, node+".postgres."); len(keys) != 1 || - keys[0] != node+".postgres.consumers.postgres-database" { - t.Errorf("the provider's needs are %v", keys) - } } // A provider with no consumer bound is filled with an empty list, never left waiting. diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/drift_integration_test.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/drift_integration_test.go index 96f11991..0b6c5c78 100644 --- a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/drift_integration_test.go +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/drift_integration_test.go @@ -5,22 +5,36 @@ package main import ( "context" "encoding/json" + "errors" "os" "path/filepath" "strings" "testing" "time" + + "github.com/nats-io/nats.go/jetstream" ) // 1129/AC5 (integration): the client as main/serve would run it — reading its credential from disk, dialling a // real bus, and asking a real node-engine-shaped socket to write — fills a module once and then asks for no write -// for three seconds: there is no timer that asks again on its own (ADR 0349 clause 4). Compiled (`go vet -tags -// integration ./...`), never run here: the lead runs it against a real bus through mesh-controller.try. +// for three seconds: there is no timer that asks again on its own (ADR 0349 clause 4). Its plans are read from the +// seat mesh-provisioner's `plans` under the account the grants part composes, a module's own secret is left as its +// `${secret:}` placeholder for the node-engine with the vault never asked, and no need is recorded (ADR 0570 +// clauses 1, 2, 6). Run in the pull request's one check. func TestIntegrationServeFillsOnceAndThenAsksForNoWrite(t *testing.T) { b := startBus(t) - e := startEngine(t, map[string]string{dunstrc: "owned"}) + vault := startVault(t, b) + vault.keep("own/g14/app/app-token", "tok", false) + vault.keep("own/g14/app/db-password", "pw", false) + e := startEngine(t, map[string]string{dunstrc: "owned", appConf: "owned", appDB: "owned"}) b.config(t, "dunst", "font-size", "13") b.assign(t, "dunst", dunst(t)) + b.config(t, "app", "font-size", "13") + b.assign(t, "app", app(t)) + needs, err := b.domain.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: "mesh-provisioner_needs"}) + if err != nil { + t.Fatal(err) + } dir := t.TempDir() credential := filepath.Join(dir, "credential.json") @@ -41,6 +55,10 @@ func TestIntegrationServeFillsOnceAndThenAsksForNoWrite(t *testing.T) { }) eventually(t, "dunstrc is filled", func() bool { return strings.Contains(e.read(t, dunstrc), "Monospace 13") }) + eventually(t, "app.conf is filled but for its secret", func() bool { + return e.read(t, appConf) == "token = ${secret:app-token}\nfont = 13\n" + }) + eventually(t, "the secret's own file holds its placeholder", func() bool { return e.read(t, appDB) == "${secret:db-password}" }) _, asked := e.counts(dunstrc) // Well over any former drift interval (a minute by default, before this issue removed the --drift flag). @@ -49,4 +67,10 @@ func TestIntegrationServeFillsOnceAndThenAsksForNoWrite(t *testing.T) { t.Errorf("the node-engine was asked to write %s again %d time(s) within three seconds, with nothing over "+ "the bus: there is no timer", dunstrc, after-asked) } + if n := vault.times("own/g14/app/app-token") + vault.times("own/g14/app/db-password"); n != 0 { + t.Errorf("the vault was asked %d times for a module's own secrets", n) + } + if keys, err := needs.Keys(context.Background()); !errors.Is(err, jetstream.ErrNoKeysFound) { + t.Errorf("needs recorded: %v (%v)", keys, err) + } } diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/ended_test.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/ended_test.go deleted file mode 100644 index 996fdab9..00000000 --- a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/ended_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "testing" - "time" -) - -// Issue 1098: a value of the assignments bucket with `ended` set takes the module off the node exactly as a removed -// key does, and a value with the fields the controller adds to an assignment (build, commit, rollout, unknown ones) -// is read under the same key. -func TestNatsAnEndedAssignmentTakesAModuleOffTheNodeLikeARemovedKey(t *testing.T) { - b := startBus(t) - heard := b.listen(t) - e := startEngine(t, map[string]string{dunstrc: "owned"}) - b.config(t, "dunst", "font-size", "13") - p := dunst(t) - b.assign(t, "dunst", p) - aClient(t, b, e, t.TempDir()) - eventually(t, "the needs are recorded", func() bool { return len(b.keys(t, NeedsBucket, node+".dunst.")) == 2 }) - - b.put(t, AssignmentsBucket, node+".dunst", fmt.Sprintf(`{"node":%q,"module":"dunst","assigned":"2026-10-14T20:00:00Z",`+ - `"build":"b-1","commit":"abc","rollout":"r-1","ended":"2026-10-18T01:00:00Z",`+ - `"provisioning":%s}`, node, planJSON(t, p))) - eventually(t, "its needs go", func() bool { return len(b.keys(t, NeedsBucket, node+".dunst.")) == 0 }) - eventually(t, "its report goes", func() bool { return len(b.keys(t, AppliedBucket, node+".dunst")) == 0 }) - eventually(t, "prov.applied says it is removed", func() bool { - r, _ := heard.last("dunst") - return r.Status == StatusRemoved - }) -} - -func TestNatsAnAssignmentWithTheNewFieldsIsFilledUnderTheSameKey(t *testing.T) { - b := startBus(t) - e := startEngine(t, map[string]string{dunstrc: "owned"}) - b.config(t, "dunst", "font-size", "13") - p := dunst(t) - b.assign(t, "dunst", p) - aClient(t, b, e, t.TempDir()) - eventually(t, "the needs are recorded", func() bool { return len(b.keys(t, NeedsBucket, node+".dunst.")) == 2 }) - b.put(t, AssignmentsBucket, node+".dunst", fmt.Sprintf(`{"node":%q,"module":"dunst","assigned":"2026-10-14T20:00:00Z",`+ - `"build":"b-1","artifact":{"name":"dunst","version":"1"},"commit":"abc","rollout":"r-1",`+ - `"since":"2026-10-18T00:00:00Z","ended":"","state":"","future":1,"provisioning":%s}`, node, planJSON(t, p))) - time.Sleep(300 * time.Millisecond) - if got := b.keys(t, NeedsBucket, node+".dunst."); len(got) != 2 { - t.Errorf("a value with the new fields took the module's needs away: %v", got) - } -} - -func planJSON(t *testing.T, p Plan) []byte { - t.Helper() - raw, err := json.Marshal(p) - if err != nil { - t.Fatal(err) - } - return raw -} diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/moved_forge_test.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/moved_forge_test.go index 9eb14cbb..278a2a10 100644 --- a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/moved_forge_test.go +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/moved_forge_test.go @@ -95,11 +95,16 @@ func TestNatsAMovedGiteaAndClaudeCodeHaveTheirFilesFilledFromTheBus(t *testing.T aClient(t, b, e, t.TempDir()) for path, want := range map[string]string{ - serverEnv: giteaBoundExpected("tok-never-on-the-bus", "pw-never-on-the-bus"), + serverEnv: giteaBoundExpected("${secret:internal-token}", "${secret:postgres-database}"), origins: "https://git.example.org\n", facts: "{\n \"node\": \"g14\",\n \"console\": \"http://127.0.0.1:4270/mcp\"\n}\n", - readCred: credential, + readCred: "${secret:gitea-read}", } { eventually(t, path+" is filled to the controller's text", func() bool { return e.read(t, path) == want }) } + // The secrets are the node-engine's to fill at the write (ADR 0570 clause 6): the vault is not asked for them. + if n := vault.times("own/g14/gitea/internal-token") + vault.times("pair/novox/postgres-database/g14/gitea") + + vault.times(forgeHolder); n != 0 { + t.Errorf("the vault was asked %d times for the modules' own secrets", n) + } } diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/pulled_in_test.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/pulled_in_test.go index 23388bec..826853bd 100644 --- a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/pulled_in_test.go +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/pulled_in_test.go @@ -18,7 +18,7 @@ func (b *aBus) pullIn(t *testing.T, module, requiredBy string, plan Plan) { if err != nil { t.Fatal(err) } - b.put(t, AssignmentsBucket, node+"."+module, fmt.Sprintf(`{"node":%q,"module":%q,"required-by":%q,`+ + b.put(t, PlansBucket, node+"."+module, fmt.Sprintf(`{"node":%q,"module":%q,"required-by":%q,`+ `"provisioning":%s}`, node, module, requiredBy, p)) } @@ -40,7 +40,7 @@ func TestAPulledInModulesValueIsReadForItsPlan(t *testing.T) { } } -func TestNatsAPulledInModuleIsFilledWithItsSecretAndItsItemIsRecorded(t *testing.T) { +func TestNatsAPulledInModuleIsFilledAndItsItemIsRecorded(t *testing.T) { b := startBus(t) heard := b.listen(t) vault := startVault(t, b) @@ -54,10 +54,10 @@ func TestNatsAPulledInModuleIsFilledWithItsSecretAndItsItemIsRecorded(t *testing b.pullIn(t, "mesh-cli", "desk", contributor(t, "shell:zsh:normal", "alias nox=mesh-cli\n")) aClient(t, b, e, t.TempDir()) - eventually(t, "the pulled-in app.conf is filled with its secret", func() bool { - return e.read(t, appConf) == "token = "+token+"\nfont = 13\n" + eventually(t, "the pulled-in app.conf is filled, its secret left for the node-engine", func() bool { + return e.read(t, appConf) == "token = ${secret:app-token}\nfont = 13\n" }) - eventually(t, "its secret's own file holds it alone", func() bool { return e.read(t, appDB) == password }) + eventually(t, "its secret's own file holds it alone", func() bool { return e.read(t, appDB) == "${secret:db-password}" }) eventually(t, "the pulled-in contributor's item is recorded", func() bool { got := b.keys(t, CollectBucket, node+".") return len(got) == 1 && got[0] == "g14.shell=3Azsh=3Anormal.mesh-cli" @@ -70,7 +70,7 @@ func TestNatsAPulledInModuleIsFilledWithItsSecretAndItsItemIsRecorded(t *testing return r.Status == StatusApplied && len(r.Files) == 2 }) // What required it lets it go: its item leaves the collection. - b.del(t, AssignmentsBucket, node+".mesh-cli") + b.del(t, PlansBucket, node+".mesh-cli") eventually(t, "zshrc loses the item of a module no longer pulled in", func() bool { return e.read(t, zshrc) == "# zsh\n# then\n# end\n" }) diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/route_names_test.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/route_names_test.go index 08ca9042..53831d21 100644 --- a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/route_names_test.go +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/route_names_test.go @@ -2,12 +2,14 @@ package main import ( "context" + "encoding/json" "os" "strings" "sync" "testing" "git.novox.be/novox/mesh-sdk/go/provider" + "git.novox.be/novox/mesh-sdk/go/provisions" "github.com/nats-io/nats.go/jetstream" ) @@ -49,12 +51,32 @@ func (m *memBus) Delete(_ context.Context, bucket, key string) error { return nil } -// routeEntryFor is the entry route-proxy's helper writes for module's plan entry on the route provision. +// routeEntryFor is the entry route-proxy's helper writes for module's plan entry on the route provision, the plan +// as the seat mesh-provisioner keeps it (ADR 0570 clause 2). func routeEntryFor(t *testing.T, module, planEntry string) string { t.Helper() - bus := &memBus{buckets: map[string]map[string][]byte{provider.AssignmentsBucket: { - node + "." + module: []byte(`{"node":"` + node + `","module":"` + module + `","provisioning":{"provisions":[` + - planEntry + `]}}`)}}} + bus := &memBus{buckets: map[string]map[string][]byte{provisions.PlansBucket: { + node + "." + module: []byte(`{"v":1,"node":"` + node + `","module":"` + module + `","plan":{"provisions":[` + + planEntry + `]}}`)}, provisions.ProvisionsBucket: {}}} + // The key the seat composed for that entry, provided-for: asked of route-proxy (ADR 0570 clause 3). + var b struct { + Provision string `json:"provision"` + Local string `json:"local"` + } + if err := json.Unmarshal([]byte(planEntry), &b); err != nil { + t.Fatal(err) + } + pr := provisions.Provision{Provision: b.Provision, Local: b.Local, State: provisions.Asked, + Consumer: provisions.End{Node: node, Module: module}, Provider: provisions.End{Node: "novox", Module: "route-proxy"}} + key, err := pr.Key() + if err != nil { + t.Fatal(err) + } + raw, err := provisions.Encode(pr) + if err != nil { + t.Fatal(err) + } + bus.buckets[provisions.ProvisionsBucket][key] = raw p := provider.New(provider.Options{Node: "novox", Module: "route-proxy", Bus: bus, Logf: t.Logf, Offers: map[string]provider.Offer{"route": {Credential: provider.NoCredential, Serves: map[string]any{}}}}) out, err := p.Pass(context.Background()) diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/secrets.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/secrets.go index ff2a5e0b..5be64d0f 100644 --- a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/secrets.go +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/secrets.go @@ -160,27 +160,6 @@ type changedSecret struct { Generation int `json:"generation"` } -// secretOf is a module's secret, from what the run holds or from the vault: its value, or what it waits for, or -// why it cannot be filled. -func (c *Client) secretOf(ctx context.Context, s *state, module string, p Plan, n Need) (value, wait, fail string) { - name := "" - if slices.ContainsFunc(p.Secrets, func(sec PlannedSecret) bool { return sec.Name == n.Name }) { - name = VaultName(c.Node, module, n.Name) - } else if i := slices.IndexFunc(p.Provisions, func(pr PlannedProvision) bool { return pr.Name == n.Name }); i >= 0 { - // A credential of what the module requires, issued for this module on this machine (issue 757). - pr := p.Provisions[i] - name = HolderName(pr.Provider, pr.Provision, c.Node, module) - if pr.CredentialForm == "pair" { - // This consumer's own login from a provider that gives each consumer one (issue 779, ADR 0158 §11). - name = PairName(pr.Provider, pr.Provision, c.Node, module, pr.Local) - } - } else { - return "", "", fmt.Sprintf("%s: the plan names no secret %s, and the vault hands only what this machine's "+ - "declaration names", n, n.Name) - } - return c.vaultValue(ctx, s, n, name) -} - // vaultValue is the vault's secret name for the need n, from what the run holds or from the vault: its value, or what // it waits for, or why it cannot be filled. func (c *Client) vaultValue(ctx context.Context, s *state, n Need, name string) (value, wait, fail string) { diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/secrets_test.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/secrets_test.go index 1538edb5..2e8d7805 100644 --- a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/secrets_test.go +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/secrets_test.go @@ -1,7 +1,6 @@ package main import ( - "context" "encoding/json" "strings" "sync" @@ -149,161 +148,41 @@ func app(t *testing.T) Plan { Secrets: []PlannedSecret{{Name: "app-token"}, {Name: "db-password", Path: appDB}}} } -func TestNatsAMovedModulesFileIsFilledWithItsSecretFromTheVault(t *testing.T) { - b := startBus(t) - heard := b.listen(t) - wire := sniff(t, b) - vault := startVault(t, b) - const token, password = "tok-3f9a1c-never-on-the-bus", "pw-77b2e0-never-on-the-bus" - vault.keep("own/g14/app/app-token", token, false) - vault.keep("own/g14/app/db-password", password, false) - e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned"}) - b.config(t, "app", "font-size", "13") - b.assign(t, "app", app(t)) - aClient(t, b, e, t.TempDir()) - - eventually(t, "app.conf is filled with its secret", func() bool { - return e.read(t, appConf) == "token = "+token+"\nfont = 13\n" - }) - eventually(t, "the secret's own file holds it alone", func() bool { return e.read(t, appDB) == password }) - eventually(t, "prov.applied says applied", func() bool { - r, _ := heard.last("app") - return r.Status == StatusApplied && len(r.Files) == 2 - }) - eventually(t, "the needs name the secrets, never their values", func() bool { - got := b.keys(t, NeedsBucket, node+".app.") - return strings.Join(got, " ") == "g14.app.config.font-size g14.app.secret.app-token g14.app.secret.db-password" - }) - if wire.heard() == 0 { - t.Fatal("the sniffer heard nothing: it proves nothing") - } - for _, value := range []string{token, password} { - if said := wire.carrying(value); len(said) > 0 { - t.Errorf("the value crossed the bus in the clear: %q", said) - } - } - if vault.times("own/g14/app/app-token") != 1 || vault.times("own/g14/app/db-password") != 1 { - t.Errorf("the vault was asked %d and %d times, want once each", vault.times("own/g14/app/app-token"), - vault.times("own/g14/app/db-password")) - } -} - -func TestNatsASecretRotatedInTheVaultReachesItsFileWithoutANewDeclaration(t *testing.T) { - b := startBus(t) - heard := b.listen(t) - vault := startVault(t, b) - vault.keep("own/g14/app/app-token", "first", false) - vault.keep("own/g14/app/db-password", "pw", false) - e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned"}) - b.config(t, "app", "font-size", "13") - b.assign(t, "app", app(t)) - aClient(t, b, e, t.TempDir()) - eventually(t, "app.conf is filled", func() bool { return strings.Contains(e.read(t, appConf), "token = first\n") }) - assigned, err := b.kv[AssignmentsBucket].Get(context.Background(), node+".app") - if err != nil { - t.Fatal(err) - } - - vault.keep("own/g14/app/app-token", "second", true) - eventually(t, "the rotated value reaches its file", func() bool { - return strings.Contains(e.read(t, appConf), "token = second\n") - }) - eventually(t, "prov.applied says it was updated", func() bool { - r, _ := heard.last("app") - return r.Status == StatusApplied && r.Files[0].Action == "updated" - }) - now, err := b.kv[AssignmentsBucket].Get(context.Background(), node+".app") - if err != nil || now.Revision() != assigned.Revision() { - t.Errorf("the module's assignment was written again: revision %d, was %d", now.Revision(), assigned.Revision()) - } - if moved, _ := e.counts(appConf); moved != 2 { - t.Errorf("app.conf moved %d times, want 2: the program restarts once for the rotation", moved) - } - if moved, _ := e.counts(appDB); moved != 1 { - t.Errorf("the other secret's file moved %d times: only what changed is written", moved) - } - // What another machine's secret, or a secret no plan here names, changes is no reason to ask. - vault.keep("own/ace/app/app-token", "elsewhere", true) - vault.keep("own/g14/other/token", "unplanned", true) - time.Sleep(300 * time.Millisecond) - if n := vault.times("own/g14/app/app-token"); n != 2 { - t.Errorf("the vault was asked for app-token %d times, want 2", n) - } - if vault.times("own/ace/app/app-token")+vault.times("own/g14/other/token") != 0 { - t.Error("a change of a secret this machine's plans do not name made the client ask") - } -} - -func TestNatsASecretTheVaultDoesNotHoldYetIsWaitedFor(t *testing.T) { - b := startBus(t) - heard := b.listen(t) - vault := startVault(t, b) - vault.keep("own/g14/app/db-password", "pw", false) - vault.allowed["own/g14/app/app-token"] = true // the plan names it; the vault does not hold it yet - e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned"}) - b.config(t, "app", "font-size", "13") - b.assign(t, "app", app(t)) - aClient(t, b, e, t.TempDir()) - - eventually(t, "prov.applied says it waits for the secret", func() bool { - r, _ := heard.last("app") - return r.Status == StatusWaiting && len(r.WaitsFor) == 1 && - strings.HasPrefix(r.WaitsFor[0], "secret:app-token (the vault holds no") - }) - if _, asked := e.counts(appConf); asked != 0 { - t.Error("a file waiting for its secret was written") - } - // 1553: a file whose own values are all there is filled, though another file of the module waits. - eventually(t, "the secret's own file is filled while the template waits", func() bool { return e.read(t, appDB) == "pw" }) - vault.keep("own/g14/app/app-token", "arrived", true) - eventually(t, "it is filled once the vault holds it", func() bool { - return strings.Contains(e.read(t, appConf), "token = arrived\n") && e.read(t, appDB) == "pw" - }) -} - -// 1129/AC4: a secret the vault refuses is asked for once and not again while nothing about it changes, even -// though several unrelated changes over the bus each reconcile the module again (never a timer). -func TestNatsARefusalFailsTheModuleWithTheVaultsReasonAndIsNotAskedEveryPass(t *testing.T) { +// ADR 0570 clause 6: a template's `${secret:}` placeholder, a secret's own file and a required credential's file are +// handed to the node-engine with the placeholder left in, for it to fill at the write; the client never asks the vault +// for them, so no value reaches it. The template's other values are filled. +func TestNatsAModulesSecretsAreLeftForTheNodeEngineAndTheVaultIsNotAsked(t *testing.T) { b := startBus(t) heard := b.listen(t) vault := startVault(t, b) vault.keep("own/g14/app/app-token", "tok", false) vault.keep("own/g14/app/db-password", "pw", false) - vault.refuse = "provisioner.g14 may not ask for own/g14/app/app-token: app's plan on g14 names no secret app-token" - e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned"}) + vault.keep(forgeHolder, "forge", false) + e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned", forgeFile: "owned", settingsFile: "owned"}) b.config(t, "app", "font-size", "13") b.assign(t, "app", app(t)) + b.assign(t, "claude-code", claudeCode(t)) aClient(t, b, e, t.TempDir()) - eventually(t, "prov.applied says it failed, with the vault's reason", func() bool { - r, _ := heard.last("app") - return r.Status == StatusFailed && strings.Contains(r.Reason, "names no secret app-token") + eventually(t, "app.conf is filled but for its secret", func() bool { + return e.read(t, appConf) == "token = ${secret:app-token}\nfont = 13\n" }) - // Several changes reach the module, each a reason to reconcile it again. - for _, size := range []string{"14", "15", "16"} { - b.config(t, "app", "font-size", size) - time.Sleep(100 * time.Millisecond) - } - time.Sleep(200 * time.Millisecond) - if n := vault.times("own/g14/app/app-token"); n != 1 { - t.Errorf("a refused secret was asked %d times across several reconciles, want once", n) - } - if _, asked := e.counts(appConf); asked != 0 { - t.Error("a file whose secret was refused was written") - } -} - -func TestNatsAVaultThatDoesNotAnswerIsWaitedFor(t *testing.T) { - b := startBus(t) - heard := b.listen(t) - e := startEngine(t, map[string]string{appConf: "owned", appDB: "owned"}) - b.config(t, "app", "font-size", "13") - b.assign(t, "app", app(t)) - aClient(t, b, e, t.TempDir()) - eventually(t, "prov.applied says it waits for the vault", func() bool { - r, _ := heard.last("app") - return r.Status == StatusWaiting && len(r.WaitsFor) == 2 && strings.Contains(r.WaitsFor[0], "nothing answers") + eventually(t, "the secret's own file holds its placeholder alone", func() bool { + return e.read(t, appDB) == "${secret:db-password}" }) + eventually(t, "the credential's file holds its placeholder alone", func() bool { + return e.read(t, forgeFile) == "${secret:gitea-read}" + }) + eventually(t, "the template naming the credential keeps its placeholder", func() bool { + return e.read(t, settingsFile) == `{"forge":"${secret:gitea-read}"}` + }) + eventually(t, "prov.applied says applied", func() bool { + r, _ := heard.last("app") + return r.Status == StatusApplied && len(r.Files) == 2 + }) + if n := vault.times("own/g14/app/app-token") + vault.times("own/g14/app/db-password") + vault.times(forgeHolder); n != 0 { + t.Errorf("the vault was asked %d times for a module's own secrets, want none", n) + } } // The plan's secrets are read strictly: a name that is not a secret's, one named twice, and a file path that is not @@ -351,71 +230,6 @@ func claudeCode(t *testing.T) Plan { Path: forgeFile}}} } -func TestNatsAModulesRequiredCredentialIssuedForItIsFilledFromTheVaultAndFollowsARotation(t *testing.T) { - b := startBus(t) - heard := b.listen(t) - wire := sniff(t, b) - vault := startVault(t, b) - const first = "https://agent-g14:gta-first-never-on-the-bus@forge.example" - const second = "https://agent-g14:gta-second-never-on-the-bus@forge.example" - vault.keep(forgeHolder, first, false) - e := startEngine(t, map[string]string{forgeFile: "owned", settingsFile: "owned"}) - b.assign(t, "claude-code", claudeCode(t)) - aClient(t, b, e, t.TempDir()) - - eventually(t, "gitea-read's file holds the value issued for claude-code here, alone", func() bool { - return e.read(t, forgeFile) == first - }) - eventually(t, "the template naming it is filled with it", func() bool { - return e.read(t, settingsFile) == `{"forge":"`+first+`"}` - }) - eventually(t, "prov.applied says applied", func() bool { - r, _ := heard.last("claude-code") - return r.Status == StatusApplied && len(r.Files) == 2 - }) - vault.keep(forgeHolder, second, true) - eventually(t, "a value the forge issued anew reaches the file without a new declaration", func() bool { - return e.read(t, forgeFile) == second - }) - // Another machine's or another module's holder value changing is no reason to ask. - vault.keep("provision/novox/gitea-read/ace/claude-code", "elsewhere", true) - vault.keep("provision/novox/gitea-read/"+node+"/zsh", "unplanned", true) - time.Sleep(300 * time.Millisecond) - if n := vault.times(forgeHolder); n != 2 { - t.Errorf("the vault was asked for the holder value %d times, want 2", n) - } - if vault.times("provision/novox/gitea-read/ace/claude-code")+vault.times("provision/novox/gitea-read/"+node+"/zsh") != 0 { - t.Error("a change of a value no plan here names made the client ask") - } - if wire.heard() == 0 { - t.Fatal("the sniffer heard nothing: it proves nothing") - } - for _, value := range []string{first, second, "gta-first-never-on-the-bus", "gta-second-never-on-the-bus"} { - if said := wire.carrying(value); len(said) > 0 { - t.Errorf("the value crossed the bus in the clear: %q", said) - } - } -} - -// A required credential the vault refuses fails the module with the vault's reason, and nothing is written. -func TestNatsARequiredCredentialTheVaultRefusesFailsTheModule(t *testing.T) { - b := startBus(t) - heard := b.listen(t) - vault := startVault(t, b) - vault.keep(forgeHolder, "https://agent-g14:tok@forge.example", false) - vault.refuse = "provisioner.g14 may not ask for " + forgeHolder + ": claude-code's plan on g14 names no credential of gitea-read from novox" - e := startEngine(t, map[string]string{forgeFile: "owned", settingsFile: "owned"}) - b.assign(t, "claude-code", claudeCode(t)) - aClient(t, b, e, t.TempDir()) - eventually(t, "prov.applied says it failed, with the vault's reason", func() bool { - r, _ := heard.last("claude-code") - return r.Status == StatusFailed && strings.Contains(r.Reason, "names no credential of gitea-read") - }) - if _, asked := e.counts(forgeFile); asked != 0 { - t.Error("a file whose credential was refused was written") - } -} - // The plan's provisions are read strictly. func TestAPlansProvisionsAreReadStrictly(t *testing.T) { base := func() Plan { @@ -466,50 +280,6 @@ func nextcloud(t *testing.T) Plan { ProviderModule: "postgres", CredentialForm: "pair", Path: dbFile}}} } -// A consumer's own login from a provider that gives each consumer one (issue 779) is the vault's pair row for this -// module on this machine: its secrets file holds it alone, and it is asked for again when the vault says it changed. -func TestNatsAModulesOwnLoginFromAPerLoginProviderIsFilledFromTheVaultAndFollowsARotation(t *testing.T) { - b := startBus(t) - heard := b.listen(t) - wire := sniff(t, b) - vault := startVault(t, b) - const first, second = "login-first-never-on-the-bus", "login-second-never-on-the-bus" - vault.keep(dbHolder, first, false) - e := startEngine(t, map[string]string{dbFile: "owned", dbConf: "owned"}) - b.assign(t, "nextcloud", nextcloud(t)) - aClient(t, b, e, t.TempDir()) - - eventually(t, "the secrets file holds the module's own login, alone", func() bool { - return e.read(t, dbFile) == first - }) - eventually(t, "the template naming it is filled with it", func() bool { - return e.read(t, dbConf) == "dbpass="+first - }) - eventually(t, "prov.applied says applied", func() bool { - r, _ := heard.last("nextcloud") - return r.Status == StatusApplied && len(r.Files) == 2 - }) - vault.keep(dbHolder, second, true) - eventually(t, "a login rotated in the vault reaches the file without a new declaration", func() bool { - return e.read(t, dbFile) == second - }) - // Another consumer's login changing is no reason to ask. - other := "pair/novox/postgres-database/ace/nextcloud" - vault.keep(other, "elsewhere", true) - time.Sleep(300 * time.Millisecond) - if n := vault.times(dbHolder); n != 2 { - t.Errorf("the vault was asked for the login %d times, want 2", n) - } - if vault.times(other) != 0 { - t.Error("a change of another consumer's login made the client ask") - } - for _, value := range []string{first, second} { - if said := wire.carrying(value); len(said) > 0 { - t.Errorf("the login crossed the bus in the clear: %q", said) - } - } -} - func TestAConsumersLoginIsNamedWithItsLocalName(t *testing.T) { if got := PairName("novox", "postgres-database", "g14", "umami", "stats"); got != "pair/novox/postgres-database/g14/umami/stats" { t.Errorf("a login under a local name: %s", got) @@ -529,7 +299,7 @@ func TestNatsAKnownSecretsFileIsFilledWhileATemplateWaits1553(t *testing.T) { b.assign(t, "app", app(t)) aClient(t, b, e, t.TempDir()) - eventually(t, "the secret's file is filled", func() bool { return e.read(t, appDB) == "pw" }) + eventually(t, "the secret's file is filled", func() bool { return e.read(t, appDB) == "${secret:db-password}" }) eventually(t, "prov.applied says waiting for the value, with the file it filled", func() bool { r, _ := heard.last("app") return r.Status == StatusWaiting && len(r.WaitsFor) == 1 && strings.HasPrefix(r.WaitsFor[0], "config:font-size") && @@ -553,7 +323,7 @@ func TestNatsATemplateWithAWaitingValueKeepsItsFileAsItIs1553(t *testing.T) { aClient(t, b, e, t.TempDir()) eventually(t, "it waits", func() bool { r, _ := heard.last("app"); return r.Status == StatusWaiting }) - eventually(t, "the other file is filled, so the pass is over", func() bool { return e.read(t, appDB) == "pw" }) + eventually(t, "the other file is filled, so the pass is over", func() bool { return e.read(t, appDB) == "${secret:db-password}" }) time.Sleep(200 * time.Millisecond) if _, asked := e.counts(appConf); asked != 0 { t.Errorf("Fill was called %d times for a template with a waiting value", asked) diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/accounts.conf b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/accounts.conf index 23fcc2cb..37551ed3 100644 --- a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/accounts.conf +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/testdata/accounts.conf @@ -11,7 +11,7 @@ accounts { users = [ { user: "domain", password: "x" } { user: "provisioner.g14", password: "x", permissions: { - publish: { allow: ["$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.g14.>", "$JS.API.CONSUMER.CREATE.KV_mesh-controller_assignments.>", "$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_applied.*.$KV.mesh-provisioner_applied.g14.>", "$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_collect.*.$KV.mesh-provisioner_collect.g14.>", "$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_needs.*.$KV.mesh-provisioner_needs.g14.>", "$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_registry.>", "$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.g14.node", "$JS.API.CONSUMER.DELETE.KV_node-engine_have.>", "$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.g14.node", "$JS.API.STREAM.INFO.KV_node-engine_have", "$JS.FC.KV_node-engine_have.>", "$JS.API.CONSUMER.CREATE.KV_postgres_bound.*.$KV.postgres_bound.g14.>", "$JS.API.CONSUMER.CREATE.KV_route-adapter_bound.*.$KV.route-adapter_bound.g14.>", "$JS.API.CONSUMER.CREATE.KV_route-proxy_bound.*.$KV.route-proxy_bound.g14.>", "$JS.API.CONSUMER.CREATE.KV_node-tools_bound.*.$KV.node-tools_bound.g14.>", "$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.>", "$JS.API.CONSUMER.DELETE.KV_mesh-controller_assignments.>", "$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_applied.>", "$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_collect.>", "$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_needs.>", "$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_registry.>", "$JS.API.CONSUMER.DELETE.KV_postgres_bound.>", "$JS.API.CONSUMER.DELETE.KV_route-adapter_bound.>", "$JS.API.CONSUMER.DELETE.KV_route-proxy_bound.>", "$JS.API.CONSUMER.DELETE.KV_node-tools_bound.>", "$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.g14.>", "$JS.API.DIRECT.GET.KV_mesh-controller_assignments.>", "$JS.API.DIRECT.GET.KV_mesh-provisioner_applied.$KV.mesh-provisioner_applied.g14.>", "$JS.API.DIRECT.GET.KV_mesh-provisioner_collect.$KV.mesh-provisioner_collect.g14.>", "$JS.API.DIRECT.GET.KV_mesh-provisioner_needs.$KV.mesh-provisioner_needs.g14.>", "$JS.API.DIRECT.GET.KV_mesh-provisioner_registry.>", "$JS.API.DIRECT.GET.KV_postgres_bound.$KV.postgres_bound.g14.>", "$JS.API.DIRECT.GET.KV_route-adapter_bound.$KV.route-adapter_bound.g14.>", "$JS.API.DIRECT.GET.KV_route-proxy_bound.$KV.route-proxy_bound.g14.>", "$JS.API.DIRECT.GET.KV_node-tools_bound.$KV.node-tools_bound.g14.>", "$JS.API.STREAM.INFO.KV_mesh-config_resolved", "$JS.API.STREAM.INFO.KV_mesh-controller_assignments", "$JS.API.STREAM.INFO.KV_mesh-provisioner_applied", "$JS.API.STREAM.INFO.KV_mesh-provisioner_collect", "$JS.API.STREAM.INFO.KV_mesh-provisioner_needs", "$JS.API.STREAM.INFO.KV_mesh-provisioner_registry", "$JS.API.STREAM.INFO.KV_postgres_bound", "$JS.API.STREAM.INFO.KV_route-adapter_bound", "$JS.API.STREAM.INFO.KV_route-proxy_bound", "$JS.API.STREAM.INFO.KV_node-tools_bound", "$JS.FC.KV_mesh-config_resolved.>", "$JS.FC.KV_mesh-controller_assignments.>", "$JS.FC.KV_mesh-provisioner_applied.>", "$JS.FC.KV_mesh-provisioner_collect.>", "$JS.FC.KV_mesh-provisioner_needs.>", "$JS.FC.KV_mesh-provisioner_registry.>", "$JS.FC.KV_postgres_bound.>", "$JS.FC.KV_route-adapter_bound.>", "$JS.FC.KV_route-proxy_bound.>", "$JS.FC.KV_node-tools_bound.>", "$KV.mesh-provisioner_applied.g14.>", "$KV.mesh-provisioner_collect.g14.>", "$KV.mesh-provisioner_needs.g14.>", "mesh.seat.mesh-vault.call.get.provisioner~g14", "prov.applied.g14.>", "prov.changed.g14.>"] } + publish: { allow: ["$JS.API.CONSUMER.CREATE.KV_mesh-config_resolved.*.$KV.mesh-config_resolved.g14.>", "$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_plans.>", "$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_applied.*.$KV.mesh-provisioner_applied.g14.>", "$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_collect.*.$KV.mesh-provisioner_collect.g14.>", "$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_registry.>", "$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.g14.node", "$JS.API.CONSUMER.DELETE.KV_node-engine_have.>", "$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.g14.node", "$JS.API.STREAM.INFO.KV_node-engine_have", "$JS.FC.KV_node-engine_have.>", "$JS.API.CONSUMER.CREATE.KV_postgres_bound.*.$KV.postgres_bound.g14.>", "$JS.API.CONSUMER.CREATE.KV_route-adapter_bound.*.$KV.route-adapter_bound.g14.>", "$JS.API.CONSUMER.CREATE.KV_route-proxy_bound.*.$KV.route-proxy_bound.g14.>", "$JS.API.CONSUMER.CREATE.KV_node-tools_bound.*.$KV.node-tools_bound.g14.>", "$JS.API.CONSUMER.DELETE.KV_mesh-config_resolved.>", "$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_plans.>", "$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_applied.>", "$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_collect.>", "$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_registry.>", "$JS.API.CONSUMER.DELETE.KV_postgres_bound.>", "$JS.API.CONSUMER.DELETE.KV_route-adapter_bound.>", "$JS.API.CONSUMER.DELETE.KV_route-proxy_bound.>", "$JS.API.CONSUMER.DELETE.KV_node-tools_bound.>", "$JS.API.DIRECT.GET.KV_mesh-config_resolved.$KV.mesh-config_resolved.g14.>", "$JS.API.DIRECT.GET.KV_mesh-provisioner_plans.>", "$JS.API.DIRECT.GET.KV_mesh-provisioner_applied.$KV.mesh-provisioner_applied.g14.>", "$JS.API.DIRECT.GET.KV_mesh-provisioner_collect.$KV.mesh-provisioner_collect.g14.>", "$JS.API.DIRECT.GET.KV_mesh-provisioner_registry.>", "$JS.API.DIRECT.GET.KV_postgres_bound.$KV.postgres_bound.g14.>", "$JS.API.DIRECT.GET.KV_route-adapter_bound.$KV.route-adapter_bound.g14.>", "$JS.API.DIRECT.GET.KV_route-proxy_bound.$KV.route-proxy_bound.g14.>", "$JS.API.DIRECT.GET.KV_node-tools_bound.$KV.node-tools_bound.g14.>", "$JS.API.STREAM.INFO.KV_mesh-config_resolved", "$JS.API.STREAM.INFO.KV_mesh-provisioner_plans", "$JS.API.STREAM.INFO.KV_mesh-provisioner_applied", "$JS.API.STREAM.INFO.KV_mesh-provisioner_collect", "$JS.API.STREAM.INFO.KV_mesh-provisioner_registry", "$JS.API.STREAM.INFO.KV_postgres_bound", "$JS.API.STREAM.INFO.KV_route-adapter_bound", "$JS.API.STREAM.INFO.KV_route-proxy_bound", "$JS.API.STREAM.INFO.KV_node-tools_bound", "$JS.FC.KV_mesh-config_resolved.>", "$JS.FC.KV_mesh-provisioner_plans.>", "$JS.FC.KV_mesh-provisioner_applied.>", "$JS.FC.KV_mesh-provisioner_collect.>", "$JS.FC.KV_mesh-provisioner_registry.>", "$JS.FC.KV_postgres_bound.>", "$JS.FC.KV_route-adapter_bound.>", "$JS.FC.KV_route-proxy_bound.>", "$JS.FC.KV_node-tools_bound.>", "$KV.mesh-provisioner_applied.g14.>", "$KV.mesh-provisioner_collect.g14.>", "mesh.seat.mesh-vault.call.get.provisioner~g14", "prov.applied.g14.>", "prov.changed.g14.>"] } subscribe: { allow: ["_INBOX.provisioner.g14.>", "mesh.mod.mesh-vault.event.secret-changed", "prov.ask.g14"] } allow_responses: { max: 1, ttl: "1m" } } } diff --git a/modules/mesh-provisioner-client/go.mod b/modules/mesh-provisioner-client/go.mod index a0fda0b8..4f216f2d 100644 --- a/modules/mesh-provisioner-client/go.mod +++ b/modules/mesh-provisioner-client/go.mod @@ -3,7 +3,7 @@ module mesh-provisioner-client go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024234636-10b6e202320f + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 golang.org/x/sys v0.48.0 diff --git a/modules/mesh-provisioner-client/go.sum b/modules/mesh-provisioner-client/go.sum index c03eccc2..9ca8e0a6 100644 --- a/modules/mesh-provisioner-client/go.sum +++ b/modules/mesh-provisioner-client/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024234636-10b6e202320f h1:lUEyNPPF3w4Q0LIgcUUD6ofkKVhCF0n7dVjMoixQmXo= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024234636-10b6e202320f/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=