Feature 57 (config and provisioning): release mesh-tools (the provider's StateBus, the SDK pin, templates), reviewed
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
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
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/novox/mesh-tools/node-tools/internal/bus"
|
||||
)
|
||||
|
||||
// A module's code reads its values at runtime (novox/hq ADR 0349 clause 14 as ADR 0370 changes it, issue 685).
|
||||
//
|
||||
// A bundle asks `mesh/provision.get` and `mesh/provision.watch` with `{kind, key}`; the runtime asks its machine's
|
||||
// provisioner client **as the module whose bundle asks** — the module this bus was made for, from the bundle it
|
||||
// launched, never a name the bundle sent (ADR 0370 §4) — and hands the answer back. A watch hands the current value
|
||||
// first and then each change: the client says which value moved, and the runtime asks again. A secret is refused
|
||||
// here, with the reason, and never asked: the client serves no secret on the tool runner's word (§2), and a module
|
||||
// under an account of its own asks the vault itself, which waits on issue 569.
|
||||
|
||||
// The kinds a bundle may ask.
|
||||
const (
|
||||
provisionConfig = "config"
|
||||
provisionBound = "bound"
|
||||
provisionSecret = "secret"
|
||||
)
|
||||
|
||||
// provisionAsked is what a bundle names. Anything else it sends, a module's name among it, is not read.
|
||||
type provisionAsked struct {
|
||||
Kind string `json:"kind"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
// ProvisionChange is what a watch hands a bundle: the value, or, when it is no longer answered, why.
|
||||
type ProvisionChange struct {
|
||||
Kind string `json:"kind"`
|
||||
Key string `json:"key"`
|
||||
Value json.RawMessage `json:"value,omitempty"`
|
||||
// Refused is why the value is no longer answered: the module taken off the machine, the key no longer declared.
|
||||
Refused string `json:"refused,omitempty"`
|
||||
}
|
||||
|
||||
func readProvisionAsked(verb string, params json.RawMessage) (provisionAsked, error) {
|
||||
var asked provisionAsked
|
||||
if err := json.Unmarshal(params, &asked); err != nil || asked.Kind == "" || asked.Key == "" {
|
||||
return asked, fmt.Errorf("mesh/provision.%s names no value: {kind, key}", verb)
|
||||
}
|
||||
switch asked.Kind {
|
||||
case provisionConfig, provisionBound:
|
||||
return asked, nil
|
||||
case provisionSecret:
|
||||
return asked, fmt.Errorf("the secret %s is not asked for at runtime while the module's code runs in the "+
|
||||
"tool runner: the vault hands a secret only to a caller the bus proves, and the bus proves only the tool "+
|
||||
"runner here (novox/hq ADR 0370 §2, ADR 0349 §11); the module's secrets reach it through its files, "+
|
||||
"until it runs under a bus account of its own (issue 569)", asked.Key)
|
||||
}
|
||||
return asked, fmt.Errorf("mesh/provision.%s: %q is not a kind of value a module reads at runtime: config or bound",
|
||||
verb, asked.Kind)
|
||||
}
|
||||
|
||||
// Provision answers a bundle's `get` of one of its module's values.
|
||||
func (b *moduleBus) Provision(verb string, params json.RawMessage) (json.RawMessage, error) {
|
||||
if verb != "get" {
|
||||
return nil, fmt.Errorf("the runtime answers no mesh/provision.%s", verb)
|
||||
}
|
||||
asked, err := readProvisionAsked(verb, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b.all.conn.AskProvisioner(b.module, asked.Kind, asked.Key)
|
||||
}
|
||||
|
||||
// ProvisionWatch hands deliver the value now and each change after, until stopped. A value not answered now is
|
||||
// refused now, with the client's reason.
|
||||
func (b *moduleBus) ProvisionWatch(params json.RawMessage, deliver func(json.RawMessage) error) (func(), error) {
|
||||
asked, err := readProvisionAsked("watch", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, err := b.all.conn.AskProvisioner(b.module, asked.Kind, asked.Key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w := &provisionWatch{bus: b, asked: asked, deliver: deliver, last: value}
|
||||
if err := w.hand(ProvisionChange{Kind: asked.Kind, Key: asked.Key, Value: value}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stop, err := b.all.provisioned.add(b.all, b.module, w)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return stop, nil
|
||||
}
|
||||
|
||||
// provisionWatch is one watch a bundle holds: what it asked, and what it was handed last.
|
||||
type provisionWatch struct {
|
||||
bus *moduleBus
|
||||
asked provisionAsked
|
||||
deliver func(json.RawMessage) error
|
||||
mu sync.Mutex // one change at a time, in order
|
||||
last json.RawMessage
|
||||
refused string
|
||||
stopped bool
|
||||
}
|
||||
|
||||
func (w *provisionWatch) hand(change ProvisionChange) error {
|
||||
raw, err := json.Marshal(change)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return w.deliver(raw)
|
||||
}
|
||||
|
||||
// again asks once more after the client said something moved, and hands the bundle what changed.
|
||||
func (w *provisionWatch) again() {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.stopped {
|
||||
return
|
||||
}
|
||||
value, err := w.bus.all.conn.AskProvisioner(w.bus.module, w.asked.Kind, w.asked.Key)
|
||||
change := ProvisionChange{Kind: w.asked.Kind, Key: w.asked.Key}
|
||||
var refused *bus.ProvisionRefused
|
||||
switch {
|
||||
case errors.As(err, &refused):
|
||||
if w.refused == refused.Reason {
|
||||
return
|
||||
}
|
||||
change.Refused = refused.Reason
|
||||
case err != nil:
|
||||
// The client not reached: asked again on its next word, which it says whenever it comes up.
|
||||
w.bus.all.logf("[mesh-tools] %s's watch of its %s %s could not ask again: %v", w.bus.module, w.asked.Kind,
|
||||
w.asked.Key, err)
|
||||
return
|
||||
default:
|
||||
if w.refused == "" && bytes.Equal(value, w.last) {
|
||||
return
|
||||
}
|
||||
change.Value = value
|
||||
}
|
||||
if err := w.hand(change); err != nil {
|
||||
w.bus.all.logf("[mesh-tools] %s did not take a change of its %s %s: %v", w.bus.module, w.asked.Kind,
|
||||
w.asked.Key, err)
|
||||
return
|
||||
}
|
||||
w.last, w.refused = value, change.Refused
|
||||
}
|
||||
|
||||
// provisionWatches is every watch on this runtime, by module, and the one subscription that hears the client.
|
||||
type provisionWatches struct {
|
||||
mu sync.Mutex
|
||||
of map[string]map[*provisionWatch]bool
|
||||
stop func()
|
||||
}
|
||||
|
||||
func (p *provisionWatches) add(all *consumers, module string, w *provisionWatch) (func(), error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.stop == nil {
|
||||
stop, err := all.conn.OnProvisionChanged(func(module string, changed bus.ProvisionChanged) {
|
||||
for _, w := range p.told(module, changed) {
|
||||
go w.again()
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the runtime cannot hear its machine's provisioner client: %w", err)
|
||||
}
|
||||
p.stop = stop
|
||||
}
|
||||
if p.of == nil {
|
||||
p.of = map[string]map[*provisionWatch]bool{}
|
||||
}
|
||||
if p.of[module] == nil {
|
||||
p.of[module] = map[*provisionWatch]bool{}
|
||||
}
|
||||
p.of[module][w] = true
|
||||
return func() {
|
||||
w.mu.Lock()
|
||||
w.stopped = true
|
||||
w.mu.Unlock()
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
delete(p.of[module], w)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// told is the watches of module that what moved concerns.
|
||||
func (p *provisionWatches) told(module string, changed bus.ProvisionChanged) []*provisionWatch {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
var out []*provisionWatch
|
||||
for w := range p.of[module] {
|
||||
if w.asked.Kind == changed.Kind && (changed.Key == "" || changed.Key == w.asked.Key) {
|
||||
out = append(out, w)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (p *provisionWatches) stopAll() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for _, ws := range p.of {
|
||||
for w := range ws {
|
||||
w.mu.Lock()
|
||||
w.stopped = true
|
||||
w.mu.Unlock()
|
||||
}
|
||||
}
|
||||
p.of = nil
|
||||
if p.stop != nil {
|
||||
p.stop()
|
||||
p.stop = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -458,6 +458,8 @@ type consumers struct {
|
||||
of map[string]*moduleEvents
|
||||
// seatBound is each module's seat traffic bound once: a worker taken, a proof subject answered.
|
||||
seatBound map[string]func()
|
||||
// provisioned is every watch a module's code holds of its values at runtime (provision.go).
|
||||
provisioned provisionWatches
|
||||
}
|
||||
|
||||
type moduleEvents struct {
|
||||
@@ -479,6 +481,7 @@ func (c *consumers) stopAll() {
|
||||
stop()
|
||||
}
|
||||
c.seatBound = map[string]func(){}
|
||||
c.provisioned.stopAll()
|
||||
}
|
||||
|
||||
// forModule is the bus one launched bundle of a module reaches the mesh through.
|
||||
|
||||
Reference in New Issue
Block a user