Files
mesh-catalog/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/asks_test.go
T
jochen e7ff7c3045
mesh/merge-gate head e7ff7c3045fc merged into trunk afb94d925e94 — pass: builds mesh-provisioner-client → ace, g14, novox, shanks; no bus step; every machi…
mesh/repo-check head e7ff7c3045fc merged into trunk afb94d925e94 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
Write a provisioner client's files only on a change over the bus, never on a timer (issue 1129, ADR 0349 clause 4)
2026-10-18 18:08:50 +02:00

155 lines
5.5 KiB
Go

package main
import (
"encoding/json"
"fmt"
"strings"
"testing"
"time"
"github.com/nats-io/nats.go"
)
// A module's code asks at runtime (novox/hq ADR 0349 clause 14, ADR 0370, design 57 S1, issue 685), against a real
// bus whose accounts are the client's and its machine's tool runner's as mesh-controller composes them: the tool
// runner asks for a carried module's config value and is answered; a change is said, so a watch asks again; a key
// the module does not declare, a module not placed on the machine, a secret and a malformed ask are refused, each
// with its reason.
// toolRunner is g14's tool runner on the bus, asking as its account may.
type toolRunner struct {
t *testing.T
nc *nats.Conn
}
func aToolRunner(t *testing.T, b *aBus) *toolRunner {
t.Helper()
nc, err := nats.Connect(b.url, nats.UserInfo(node+".node-tools", "x"),
nats.CustomInboxPrefix("_INBOX."+node+".node-tools"),
nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) {
t.Errorf("the bus refused the tool runner: %v", err)
}))
if err != nil {
t.Fatal(err)
}
t.Cleanup(nc.Close)
return &toolRunner{t: t, nc: nc}
}
func (r *toolRunner) ask(body string) Answered {
r.t.Helper()
var a Answered
deadline := time.Now().Add(10 * time.Second)
for {
msg, err := r.nc.Request(AskSubject(node), []byte(body), 2*time.Second)
if err == nil {
if err := json.Unmarshal(msg.Data, &a); err != nil {
r.t.Fatalf("the client answered %s", msg.Data)
}
return a
}
if time.Now().After(deadline) {
r.t.Fatalf("the client did not answer %s: %v", body, err)
}
time.Sleep(50 * time.Millisecond) // not listening yet
}
}
func (r *toolRunner) askConfig(module, key string) Answered {
r.t.Helper()
return r.ask(fmt.Sprintf(`{"module":%q,"kind":"config","key":%q}`, module, key))
}
// hear collects what the client says moved on this machine.
func (r *toolRunner) hear() chan string {
r.t.Helper()
heard := make(chan string, 64)
if _, err := r.nc.Subscribe("prov.changed."+node+".>", func(m *nats.Msg) {
heard <- strings.TrimPrefix(m.Subject, "prov.changed."+node+".") + " " + string(m.Data)
}); err != nil {
r.t.Fatal(err)
}
_ = r.nc.Flush()
return heard
}
// placed writes an assignment of a module the controller has not handed to the provisioner: placed, no plan.
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"}`,
node, module))
}
func until(t *testing.T, heard chan string, want string) {
t.Helper()
deadline := time.After(10 * time.Second)
for {
select {
case got := <-heard:
if got == want {
return
}
case <-deadline:
t.Fatalf("the client did not say %s", want)
}
}
}
func TestNatsAModulesCodeReadsItsConfigAndIsToldOfAChange(t *testing.T) {
b := startBus(t)
e := startEngine(t, nil)
b.placed(t, "claude-code")
b.config(t, "claude-code", "managed_settings", `{"spinnerTipsEnabled":false}`)
runner := aToolRunner(t, b)
heard := runner.hear()
aClient(t, b, e, t.TempDir())
// The value as mesh-config resolved it.
if a := runner.askConfig("claude-code", "managed_settings"); a.Error != "" || string(a.Value) != `{"spinnerTipsEnabled":false}` {
t.Fatalf("answered %+v", a)
}
// Coming up, it says every module's config moved, so a watch held across its absence asks again.
until(t, heard, `claude-code {"kind":"config"}`)
// A change is said, which value and never the value; asked again, the new value.
b.config(t, "claude-code", "managed_settings", `{"spinnerTipsEnabled":true}`)
until(t, heard, `claude-code {"kind":"config","key":"managed_settings"}`)
if a := runner.askConfig("claude-code", "managed_settings"); string(a.Value) != `{"spinnerTipsEnabled":true}` {
t.Fatalf("after the change, answered %+v", a)
}
// Taken off the machine: said, and no longer answered.
b.del(t, AssignmentsBucket, 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)
}
}
func TestNatsWhatAModulesCodeMayNotReadIsRefusedWithTheReason(t *testing.T) {
b := startBus(t)
e := startEngine(t, nil)
b.placed(t, "claude-code")
b.config(t, "claude-code", "managed_settings", `{}`)
// Resolved for a module the composition does not place on this machine.
b.config(t, "dunst", "font-size", "13")
runner := aToolRunner(t, b)
aClient(t, b, e, t.TempDir())
for _, c := range []struct{ ask, refused string }{
{`{"module":"claude-code","kind":"config","key":"no_such_key"}`, "declares no config value no_such_key"},
{`{"module":"dunst","kind":"config","key":"font-size"}`, "dunst is not placed on g14"},
{`{"module":"claude-code","kind":"secret","key":"gitea-read"}`, "not answered on the tool runner's word"},
{`{"module":"claude-code","kind":"bound","key":"mcp-endpoint:url"}`, "not answered yet"},
{`{"module":"claude-code","kind":"setting","key":"role"}`, "not a kind the client answers"},
{`{"module":"claude-code","kind":"config"}`, "names its module, its kind and its key"},
{`{"module":"claude-code","kind":"config","key":"managed_settings","as":"dunst"}`, "names its module, its kind and its key"},
{`not json`, "names its module, its kind and its key"},
} {
a := runner.ask(c.ask)
if len(a.Value) != 0 || !strings.Contains(a.Error, c.refused) {
t.Errorf("%s: answered %+v, want refused with %q", c.ask, a, c.refused)
}
}
}