Design 47's last milestones: the keeper, self-check, healers, hand-acts, provisioner, vault, artifact sweep, disk readings, credential forms (issues 774, 1952, 1953, 1954, 1955)
mesh/delivery-group group feat/774-l3-reland rejected: composed together: the checker's verdict at the members' heads (b48c90fea31f): integration: mesh-self-ch…
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: integration: mesh-self-check/cmd/mesh-self-check.TestTheSelfCheckWholeRunsOnTheBus: the run answered {Run:doctor-1793351737-1 At:20…
mesh/delivery-group group feat/774-l3-reland rejected: composed together: the checker's verdict at the members' heads (b48c90fea31f): integration: mesh-self-ch…
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: integration: mesh-self-check/cmd/mesh-self-check.TestTheSelfCheckWholeRunsOnTheBus: the run answered {Run:doctor-1793351737-1 At:20…
One commit with exactly the tree of the reviewed head35b4509c0a, on main12c2574c9d, so two compiled programs added and removed on the branch leave no trace in history (issue 1577).
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
||||
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"os"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
@@ -39,11 +42,16 @@ 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
|
||||
// ControllerAssignmentsBucket is the controller's assignments mirror, read for a module the seat holds no plan of
|
||||
// until the controller hands its lease away: the holder writes plans only for modules with a want, so a module
|
||||
// that runs without one (nats on novox) would otherwise lose its files' fill (review of 2026-10-30).
|
||||
ControllerAssignmentsBucket = "mesh-controller_assignments"
|
||||
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 +90,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"`
|
||||
@@ -134,12 +132,14 @@ type Client struct {
|
||||
type state struct {
|
||||
registry map[string]Answer
|
||||
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
|
||||
// seatPlans and controllerAssigned are each source's value by key, as an assignment's value reads: the seat's
|
||||
// plan wins, and the controller's assignment stands for a module the seat has no plan of.
|
||||
seatPlans, controllerAssigned map[string][]byte
|
||||
refused map[string]string // a module whose plan cannot be acted on, and why
|
||||
config map[string]map[string]json.RawMessage
|
||||
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
|
||||
// items is every item recorded for this node, by collection and contributor: what a collector is filled from.
|
||||
items map[string]map[string]itemValue
|
||||
// recorded is each item's key and its value as kept: what this client compares before it records again.
|
||||
@@ -181,7 +181,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,
|
||||
for _, b := range []string{PlansBucket, ControllerAssignmentsBucket, ConfigBucket, RegistryBucket, AppliedBucket,
|
||||
CollectBucket} {
|
||||
kv, err := c.JS.KeyValue(ctx, b)
|
||||
if err != nil {
|
||||
@@ -220,12 +220,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, ControllerAssignmentsBucket} {
|
||||
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 +307,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 +315,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) < 6 { // the registry, the plans, the controller's assignments, config, applied, collect
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
@@ -491,7 +491,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, ControllerAssignmentsBucket:
|
||||
c.tell(rest, Changed{Kind: AskConfig})
|
||||
}
|
||||
}
|
||||
@@ -539,16 +539,40 @@ 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, ControllerAssignmentsBucket:
|
||||
if s.seatPlans == nil {
|
||||
s.seatPlans, s.controllerAssigned = map[string][]byte{}, map[string][]byte{}
|
||||
}
|
||||
source := s.seatPlans
|
||||
if u.bucket == ControllerAssignmentsBucket {
|
||||
source = s.controllerAssigned
|
||||
}
|
||||
if gone {
|
||||
delete(source, key)
|
||||
} else if u.bucket == ControllerAssignmentsBucket {
|
||||
source[key] = e.Value()
|
||||
} else if v, err := asAssignment(e.Value()); err != nil {
|
||||
logf("[provisioner-client] the plan %s cannot be read, so the controller's assignment is read: %v", key, err)
|
||||
delete(source, key)
|
||||
} else {
|
||||
source[key] = v
|
||||
}
|
||||
// The seat's plan where it holds one, the controller's assignment otherwise: a module leaves only when
|
||||
// neither places it, never because the seat has no plan of it yet.
|
||||
value, placed := s.seatPlans[key]
|
||||
if !placed {
|
||||
value, placed = s.controllerAssigned[key]
|
||||
}
|
||||
gone = !placed
|
||||
// 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 +595,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 +621,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 +670,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 +686,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 +697,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 +755,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
|
||||
@@ -930,52 +931,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 +1106,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, ControllerAssignmentsBucket, 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")
|
||||
@@ -621,6 +627,44 @@ func TestNatsAModuleTakenOffTheNodeLosesItsNeedsAndItsReport(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// Review of 2026-10-30: until the controller hands its lease away, a module the seat holds no plan of is read from
|
||||
// the controller's assignment (nats on novox runs without a want, so the holder writes it no plan), and a module never
|
||||
// leaves because its plan is missing: only when neither source places it.
|
||||
func TestNatsAModuleWithoutASeatPlanIsFilledFromTheControllersAssignmentAndLeavesOnlyWhenNeitherPlacesIt(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, err := json.Marshal(dunst(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assignment := fmt.Sprintf(`{"node":%q,"module":"dunst","assigned":"2026-10-14T20:00:00Z","provisioning":%s}`, node, p)
|
||||
b.put(t, ControllerAssignmentsBucket, node+".dunst", assignment)
|
||||
aClient(t, b, e, t.TempDir())
|
||||
eventually(t, "dunstrc is filled from the controller's assignment", func() bool {
|
||||
return strings.Contains(e.read(t, dunstrc), "Monospace 13")
|
||||
})
|
||||
|
||||
// The seat's plan comes, and goes again: the module stays, filled, its report kept.
|
||||
b.assign(t, "dunst", dunst(t))
|
||||
b.del(t, PlansBucket, node+".dunst")
|
||||
b.config(t, "dunst", "font-size", "15")
|
||||
eventually(t, "dunstrc follows a change with only the controller's assignment", func() bool {
|
||||
return strings.Contains(e.read(t, dunstrc), "Monospace 15")
|
||||
})
|
||||
if r, _ := heard.last("dunst"); r.Status == StatusRemoved || len(b.keys(t, AppliedBucket, node+".dunst")) != 1 {
|
||||
t.Fatalf("the module left when its plan went, though the controller still places it: %+v", r)
|
||||
}
|
||||
|
||||
// Neither places it: it leaves.
|
||||
b.del(t, ControllerAssignmentsBucket, node+".dunst")
|
||||
eventually(t, "prov.applied says it is removed", func() bool {
|
||||
r, _ := heard.last("dunst")
|
||||
return r.Status == StatusRemoved
|
||||
})
|
||||
}
|
||||
|
||||
func TestNatsANodeComingBackFillsFromTheCurrentValues(t *testing.T) {
|
||||
b := startBus(t)
|
||||
const zshrc = "/home/nox/.zshrc"
|
||||
@@ -640,14 +684,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 +705,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 +726,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 +808,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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -13,16 +13,15 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The provisioner's plan of a module on this node (novox/hq ADR 0349 clause 4, design 57 P5): placement's owner,
|
||||
// the controller, carries it in the module's assignment, `<node>.<module>` of mesh-controller_assignments
|
||||
// (mesh-controller internal/inventory Provisioning), which this client reads for its own machine alone. An
|
||||
// assignment with no plan is a module the controller has not handed to the provisioner: its files are the
|
||||
// node-engine's, from the declaration, and this client leaves them alone (ADR 0349 clause 17). A module only pulled
|
||||
// in by another on this node, and handed, has a value of its own there, which names what requires it and no time it
|
||||
// was assigned (issue 765): read and filled the same way.
|
||||
// The provisioner's plan of a module on this node (novox/hq ADR 0349 clause 4, ADR 0570 clause 2): the seat
|
||||
// mesh-provisioner composes it from bus state and keeps it in `plans`, `<node>.<module>`, which this client reads for
|
||||
// its own machine (and every machine's, for a provider's consumers). asAssignment carries a plan into the Assignment
|
||||
// it acts on. A module the seat holds no plan of is read from the controller's assignment until the controller hands its
|
||||
// lease away; a module neither places is not placed here, and its files are the node-engine's, from the declaration. A module only pulled in by another on this
|
||||
// node has a plan of its own there: read and filled the same way.
|
||||
|
||||
// Assignment is one value of the assignments mirror. **Lenient at the top**, so a field the controller adds to an
|
||||
// assignment (design 54 §1) does not stop the client; **strict in the plan**, which is the client's to act on.
|
||||
// Assignment is one plan as the client acts on it. **Lenient at the top**; **strict in the plan**, which is the
|
||||
// client's to act on.
|
||||
type Assignment struct {
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -161,7 +161,9 @@ type changedSecret struct {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// why it cannot be filled. The client fills it in this release: the node-engine of today writes a file's text as it
|
||||
// is handed, and the new one fills only a provision key's reference, never a module's own secret (review of
|
||||
// 2026-10-30); its fill is an addition, for provision keys, until it fills every secret.
|
||||
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 }) {
|
||||
|
||||
@@ -170,10 +170,6 @@ func TestNatsAMovedModulesFileIsFilledWithItsSecretFromTheVault(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
@@ -199,7 +195,7 @@ func TestNatsASecretRotatedInTheVaultReachesItsFileWithoutANewDeclaration(t *tes
|
||||
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")
|
||||
assigned, err := b.kv[PlansBucket].Get(context.Background(), node+".app")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -212,7 +208,7 @@ func TestNatsASecretRotatedInTheVaultReachesItsFileWithoutANewDeclaration(t *tes
|
||||
r, _ := heard.last("app")
|
||||
return r.Status == StatusApplied && r.Files[0].Action == "updated"
|
||||
})
|
||||
now, err := b.kv[AssignmentsBucket].Get(context.Background(), node+".app")
|
||||
now, err := b.kv[PlansBucket].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())
|
||||
}
|
||||
|
||||
+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-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_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-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_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-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_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-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_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-controller_assignments.>", "$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" }
|
||||
} }
|
||||
|
||||
Reference in New Issue
Block a user