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:
@@ -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.
|
||||
|
||||
+27
-3
@@ -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)
|
||||
|
||||
+1
-1
@@ -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" }
|
||||
} }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
Reference in New Issue
Block a user