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
155 lines
5.5 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|