mesh/merge-gate head 33d136e70227 merged into trunk 6b3e15042fee — pass, with a note: builds mesh-tools, node-tools (+40 dependent(s)) → ace, g14, novox, s…
mesh/repo-check head 33d136e70227 merged into trunk 6b3e15042fee — pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the ch…
mesh/delivery delivered
204 lines
7.0 KiB
Go
204 lines
7.0 KiB
Go
package runtime
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/nats-io/nats.go"
|
|
|
|
mt "github.com/novox/mesh-tools/node-tools/internal/meshtest"
|
|
)
|
|
|
|
// A module's code reads its values at runtime through moduleBus.Provision and ProvisionWatch (novox/hq ADR 0349
|
|
// §14, ADR 0370, issue 685), against a real bus with a stand-in for the machine's provisioner client: the runtime
|
|
// asks as the module whose bundle asks, whatever the bundle names; a watch hands the current value, then each change
|
|
// the client says moved, and a refusal once; a secret is refused without asking.
|
|
|
|
// standInClient answers prov.ask.<node> from its values, keyed `<module>.<kind>.<key>`, and keeps every ask.
|
|
type standInClient struct {
|
|
mu sync.Mutex
|
|
values map[string]string
|
|
asks []string
|
|
nc *nats.Conn
|
|
}
|
|
|
|
func aStandInClient(t *testing.T, node string, values map[string]string) *standInClient {
|
|
t.Helper()
|
|
nc, err := nats.Connect(mt.URL(t))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(nc.Close)
|
|
c := &standInClient{values: values, nc: nc}
|
|
if _, err := nc.Subscribe("prov.ask."+node, func(m *nats.Msg) {
|
|
var a struct{ Module, Kind, Key string }
|
|
_ = json.Unmarshal(m.Data, &a)
|
|
c.mu.Lock()
|
|
c.asks = append(c.asks, string(m.Data))
|
|
v, ok := c.values[a.Module+"."+a.Kind+"."+a.Key]
|
|
c.mu.Unlock()
|
|
if !ok {
|
|
_ = m.Respond([]byte(`{"error":"` + a.Module + ` declares no config value ` + a.Key + `"}`))
|
|
return
|
|
}
|
|
_ = m.Respond([]byte(`{"value":` + v + `}`))
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = nc.Flush()
|
|
return c
|
|
}
|
|
|
|
func (c *standInClient) set(key, value string) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if value == "" {
|
|
delete(c.values, key)
|
|
return
|
|
}
|
|
c.values[key] = value
|
|
}
|
|
|
|
func (c *standInClient) asked() []string {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return append([]string(nil), c.asks...)
|
|
}
|
|
|
|
func (c *standInClient) say(t *testing.T, node, module, body string) {
|
|
t.Helper()
|
|
if err := c.nc.Publish("prov.changed."+node+"."+module, []byte(body)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestABundleAsksForItsValuesAsItsOwnModule(t *testing.T) {
|
|
client := aStandInClient(t, "anchor", map[string]string{
|
|
"claude-code.config.managed_settings": `{"spinnerTipsEnabled":false}`,
|
|
"dunst.config.managed_settings": `"dunst's"`,
|
|
})
|
|
conn := connect(t, "node-tools", "anchor")
|
|
all := &consumers{conn: conn, logf: t.Logf, of: map[string]*moduleEvents{}}
|
|
b := all.forModule("claude-code").(*moduleBus)
|
|
|
|
// Its own value, though the bundle names another module: the runtime names the module from the bundle.
|
|
got, err := b.Provision("get", json.RawMessage(`{"kind":"config","key":"managed_settings","module":"dunst"}`))
|
|
if err != nil || string(got) != `{"spinnerTipsEnabled":false}` {
|
|
t.Fatalf("answered %s, %v", got, err)
|
|
}
|
|
if asks := client.asked(); len(asks) != 1 || asks[0] != `{"key":"managed_settings","kind":"config","module":"claude-code"}` {
|
|
t.Fatalf("the client was asked %v", asks)
|
|
}
|
|
// The client's refusal, in its words.
|
|
if _, err := b.Provision("get", json.RawMessage(`{"kind":"config","key":"nothing"}`)); err == nil ||
|
|
!strings.Contains(err.Error(), "declares no config value nothing") {
|
|
t.Errorf("an undeclared key: %v", err)
|
|
}
|
|
// A secret, refused here and never asked; and what is not an ask.
|
|
before := len(client.asked())
|
|
for params, want := range map[string]string{
|
|
`{"kind":"secret","key":"gitea-read"}`: "the vault hands a secret only to a caller the bus proves",
|
|
`{"kind":"setting","key":"role"}`: "not a kind of value",
|
|
`{"key":"role"}`: "names no value",
|
|
} {
|
|
if _, err := b.Provision("get", json.RawMessage(params)); err == nil || !strings.Contains(err.Error(), want) {
|
|
t.Errorf("%s: %v, want %q", params, err, want)
|
|
}
|
|
}
|
|
if _, err := b.ProvisionWatch(json.RawMessage(`{"kind":"secret","key":"gitea-read"}`), func(json.RawMessage) error {
|
|
return nil
|
|
}); err == nil {
|
|
t.Error("a secret was watched")
|
|
}
|
|
if n := len(client.asked()); n != before {
|
|
t.Errorf("the client was asked for what the runtime refuses: %v", client.asked()[before:])
|
|
}
|
|
}
|
|
|
|
func TestABundlesWatchIsHandedTheValueAndEachChange(t *testing.T) {
|
|
client := aStandInClient(t, "anchor", map[string]string{"claude-code.config.managed_settings": `{"a":1}`})
|
|
conn := connect(t, "node-tools", "anchor")
|
|
all := &consumers{conn: conn, logf: t.Logf, of: map[string]*moduleEvents{}}
|
|
t.Cleanup(all.stopAll)
|
|
b := all.forModule("claude-code").(*moduleBus)
|
|
|
|
handed := make(chan ProvisionChange, 16)
|
|
stop, err := b.ProvisionWatch(json.RawMessage(`{"kind":"config","key":"managed_settings","module":"dunst"}`),
|
|
func(raw json.RawMessage) error {
|
|
var c ProvisionChange
|
|
if err := json.Unmarshal(raw, &c); err != nil {
|
|
t.Errorf("handed %s", raw)
|
|
}
|
|
handed <- c
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
next := func(what string) ProvisionChange {
|
|
t.Helper()
|
|
select {
|
|
case c := <-handed:
|
|
return c
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatalf("nothing handed: %s", what)
|
|
}
|
|
return ProvisionChange{}
|
|
}
|
|
nothing := func(what string) {
|
|
t.Helper()
|
|
select {
|
|
case c := <-handed:
|
|
t.Errorf("%s: handed %+v", what, c)
|
|
case <-time.After(300 * time.Millisecond):
|
|
}
|
|
}
|
|
if c := next("the current value"); string(c.Value) != `{"a":1}` || c.Kind != "config" || c.Key != "managed_settings" {
|
|
t.Fatalf("first handed %+v", c)
|
|
}
|
|
|
|
// A change, said by the client: asked again, handed.
|
|
client.set("claude-code.config.managed_settings", `{"a":2}`)
|
|
client.say(t, "anchor", "claude-code", `{"kind":"config","key":"managed_settings"}`)
|
|
if c := next("the change"); string(c.Value) != `{"a":2}` {
|
|
t.Fatalf("after the change, handed %+v", c)
|
|
}
|
|
// Every key of the kind said to have moved, the value the same: nothing handed. Another module's or another
|
|
// key's word: not even asked.
|
|
client.say(t, "anchor", "claude-code", `{"kind":"config"}`)
|
|
nothing("the same value again")
|
|
before := len(client.asked())
|
|
client.say(t, "anchor", "dunst", `{"kind":"config","key":"managed_settings"}`)
|
|
client.say(t, "anchor", "claude-code", `{"kind":"config","key":"role"}`)
|
|
nothing("another's word")
|
|
if n := len(client.asked()); n != before {
|
|
t.Errorf("asked again for another's word: %v", client.asked()[before:])
|
|
}
|
|
// No longer answered: handed the refusal, once.
|
|
client.set("claude-code.config.managed_settings", "")
|
|
client.say(t, "anchor", "claude-code", `{"kind":"config"}`)
|
|
if c := next("the refusal"); c.Refused == "" || len(c.Value) != 0 {
|
|
t.Fatalf("handed %+v, want the refusal", c)
|
|
}
|
|
client.say(t, "anchor", "claude-code", `{"kind":"config"}`)
|
|
nothing("the same refusal again")
|
|
// Answered again, and then stopped: nothing after.
|
|
client.set("claude-code.config.managed_settings", `{"a":3}`)
|
|
client.say(t, "anchor", "claude-code", `{"kind":"config"}`)
|
|
if c := next("answered again"); string(c.Value) != `{"a":3}` {
|
|
t.Fatalf("handed %+v", c)
|
|
}
|
|
stop()
|
|
client.set("claude-code.config.managed_settings", `{"a":4}`)
|
|
client.say(t, "anchor", "claude-code", `{"kind":"config"}`)
|
|
nothing("a stopped watch")
|
|
for _, a := range client.asked() {
|
|
if !strings.Contains(a, `"module":"claude-code"`) {
|
|
t.Errorf("asked as another module: %s", a)
|
|
}
|
|
}
|
|
}
|