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).
This commit is contained in:
2026-10-29 23:51:33 +01:00
parent 63023ca5fe
commit 542c3214c6
18 changed files with 217 additions and 536 deletions
+12 -23
View File
@@ -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.<node>`, 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` (`<node>.<module>`) 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`,
`<node>.<module>`, 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`, `<node>.<module>.<kind>.<name>` (a character a
key cannot hold written `=<hex>`);
- watches each owner's keys for its node: placement in the plan, config in `mesh-config_resolved`
(`<node>.<module>.<key>`), 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/<provider>/<provision>/<node>/<module>` (the forge's read token for claude-code, issue 737), filled
wherever `${secret:<name>}` names it and written to its `path` alone, and asked again on its `secret-changed`;
- leaves every `${secret:<name>}` 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`, `<node>.<collection>.<contributor>`
(a character a key's token cannot hold written `=<hex>`: `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.<node>.<module>` and keeps the same report in `mesh-provisioner_applied` (`<node>.<module>`):
`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:<provision>}` (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/<provider>/<provision>/<node>/<module>[/<local>]`, 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
@@ -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"}
@@ -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)
@@ -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"
})
}
@@ -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
// `<node>.<module>.<kind>.<name>`: 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, `<node>.<module>.<kind>.<name>`: the name with every
// character a key cannot hold, and `=`, written `=<hex>`, 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})
}
@@ -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":"<node>.<module>"}`)
`"bucket":"mesh-provisioner_plans","key":"<node>.<module>"}`)
}
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
}
@@ -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"
@@ -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.
@@ -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)
}
}
@@ -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
}
@@ -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)
}
}
@@ -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"
})
@@ -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())
@@ -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) {
@@ -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)
@@ -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" }
} }
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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=