The runtime serves a bundle its module's state (novox/hq ADR 0201)
mesh/state.get, put, delete, keys and watch on the stdio channel, from the buckets the membership issues. A watch hands the current values without deletions, then every change, and is answered once the current values are delivered. Refused with the reason: state not issued, a reader's write, a value with a credential-named field — the bus alone would answer a refused write with a timeout.
This commit is contained in:
@@ -578,3 +578,69 @@ func (b *moduleBus) Subscribe(deliver func(json.RawMessage) error) error {
|
||||
c.logf("[mesh-tools] %s's events arrive on its consumer %s", module, bus.ConsumerOf(c.conn.Node(), module))
|
||||
return nil
|
||||
}
|
||||
|
||||
// stateAsked is what a bundle names when it reaches its state (ADR 0201): the state by the name its
|
||||
// module uses, a key, and for a put the value.
|
||||
type stateAsked struct {
|
||||
State string `json:"state"`
|
||||
Key string `json:"key"`
|
||||
Value json.RawMessage `json:"value"`
|
||||
}
|
||||
|
||||
// State answers a bundle's `get`, `put`, `delete` and `keys` on its module's state (ADR 0201). The
|
||||
// runtime refuses, with the reason, a state the module was not issued and a write to one it only reads.
|
||||
func (b *moduleBus) State(verb string, params json.RawMessage) (json.RawMessage, error) {
|
||||
var asked stateAsked
|
||||
if err := json.Unmarshal(params, &asked); err != nil || asked.State == "" {
|
||||
return nil, fmt.Errorf("mesh/state.%s names no state: {state, key, value}", verb)
|
||||
}
|
||||
conn := b.all.conn
|
||||
var answer any
|
||||
switch verb {
|
||||
case "get":
|
||||
entry, err := conn.StateGet(b.module, asked.State, asked.Key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if entry == nil {
|
||||
return json.RawMessage("null"), nil
|
||||
}
|
||||
answer = entry
|
||||
case "put":
|
||||
revision, err := conn.StatePut(b.module, asked.State, asked.Key, asked.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answer = map[string]any{"revision": revision}
|
||||
case "delete":
|
||||
if err := conn.StateDelete(b.module, asked.State, asked.Key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answer = map[string]any{}
|
||||
case "keys":
|
||||
keys, err := conn.StateKeys(b.module, asked.State)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answer = keys
|
||||
default:
|
||||
return nil, fmt.Errorf("the runtime answers no mesh/state.%s", verb)
|
||||
}
|
||||
return json.Marshal(answer)
|
||||
}
|
||||
|
||||
// Watch hands a bundle its module's state as it is and as it changes (ADR 0201): `{state, key}`, the
|
||||
// key a pattern with `*` and `**`, empty for every key.
|
||||
func (b *moduleBus) Watch(params json.RawMessage, deliver func(json.RawMessage) error) (func(), error) {
|
||||
var asked stateAsked
|
||||
if err := json.Unmarshal(params, &asked); err != nil || asked.State == "" {
|
||||
return nil, fmt.Errorf("mesh/state.watch names no state: {state, key}")
|
||||
}
|
||||
return b.all.conn.StateWatch(b.module, asked.State, asked.Key, func(change bus.StateChange) error {
|
||||
raw, err := json.Marshal(change)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return deliver(raw)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-tools/node-tools/internal/bus"
|
||||
mt "github.com/novox/mesh-tools/node-tools/internal/meshtest"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0201: a module keeps its current state in buckets it declares, and its code reaches
|
||||
// them through the runtime. The owner's instances write and read; a reader only reads; a watch hands
|
||||
// the current values — none that is deleted — and then every change; the runtime refuses, with the
|
||||
// reason, what the module was not issued, a write to state it only reads, and a value naming a
|
||||
// credential.
|
||||
func TestABundleKeepsAndWatchesItsStateThroughTheRuntime(t *testing.T) {
|
||||
mesh := mt.New(t)
|
||||
kv := mesh.Bucket(t, "keeper_servers")
|
||||
// What was there before anything started: one value, and one key since deleted.
|
||||
if _, err := kv.Put("all.one", []byte(`{"url":"http://one"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := kv.Put("all.gone", []byte(`{}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := kv.Delete("all.gone"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
keeper := mt.MembershipOf("keeper", "anchor", false, nil)
|
||||
keeper.State = []bus.StateIssued{{Name: "servers", Bucket: "keeper_servers", Writes: true}}
|
||||
peeker := mt.MembershipOf("peeker", "anchor", false, nil)
|
||||
peeker.State = []bus.StateIssued{{Name: "keeper.servers", Bucket: "keeper_servers"}}
|
||||
mesh.Issue(t, keeper)
|
||||
mesh.Issue(t, peeker)
|
||||
|
||||
nodeTools := connect(t, "node-tools", "anchor")
|
||||
asker := connect(t, "console", "workstation")
|
||||
dir := t.TempDir()
|
||||
keeperLog, peekerLog := filepath.Join(dir, "keeper.log"), filepath.Join(dir, "peeker.log")
|
||||
said := &lines{}
|
||||
stop, err := Run(nodeTools, []Served{
|
||||
{Module: "keeper", Entrypoints: []string{mt.Fixture("state-keeper.mjs")}},
|
||||
{Module: "peeker", Entrypoints: []string{mt.Fixture("state-keeper.mjs")}},
|
||||
}, map[string]map[string]string{
|
||||
"keeper": {"STATE_NAME": "servers", "STATE_LOG": keeperLog},
|
||||
"peeker": {"STATE_NAME": "keeper.servers", "STATE_LOG": peekerLog},
|
||||
}, said.logf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(stop)
|
||||
|
||||
// Both read the whole current state at start: the value, not the deleted key, then the end of it.
|
||||
for _, log := range []string{keeperLog, peekerLog} {
|
||||
waitFor(t, log, "current")
|
||||
lines := read(t, log)
|
||||
if lines[0] != `was put all.one {"url":"http://one"}` || lines[1] != "current" || strings.Contains(strings.Join(lines, "\n"), "all.gone") {
|
||||
t.Fatalf("the current state was not handed over as it is:\n%s\nruntime:\n%s", strings.Join(lines, "\n"), said.all())
|
||||
}
|
||||
}
|
||||
|
||||
// The owner writes; every watch sees the change.
|
||||
got, err := call(t, asker, "keeper.put@anchor", map[string]any{"state": "servers", "key": "anchor.two", "value": map[string]any{"url": "http://two"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(got, `"revision"`) {
|
||||
t.Fatalf("a put answered %s", got)
|
||||
}
|
||||
waitFor(t, keeperLog, `now put anchor.two {"url":"http://two"}`)
|
||||
waitFor(t, peekerLog, `now put anchor.two {"url":"http://two"}`)
|
||||
|
||||
// Get and keys, by the owner and by the reader.
|
||||
got, err = call(t, asker, "peeker.get@anchor", map[string]any{"state": "keeper.servers", "key": "anchor.two"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(got, `"value":{"url":"http://two"}`) {
|
||||
t.Fatalf("the reader's get answered %s", got)
|
||||
}
|
||||
got, err = call(t, asker, "peeker.get@anchor", map[string]any{"state": "keeper.servers", "key": "nothing.here"})
|
||||
if err != nil || got != "null" {
|
||||
t.Fatalf("a key with no value answered %s, %v", got, err)
|
||||
}
|
||||
got, err = call(t, asker, "keeper.keys@anchor", map[string]any{"state": "servers"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
same(t, got, `["all.one","anchor.two"]`)
|
||||
|
||||
// Refused, with the reason, before anything is sent.
|
||||
for _, c := range []struct {
|
||||
key, why string
|
||||
args map[string]any
|
||||
}{
|
||||
{"peeker.put@anchor", "peeker reads keeper.servers and does not keep it",
|
||||
map[string]any{"state": "keeper.servers", "key": "x", "value": 1}},
|
||||
{"keeper.get@anchor", `keeper keeps and reads no state called "other"`,
|
||||
map[string]any{"state": "other", "key": "x"}},
|
||||
{"keeper.put@anchor", `carries a field "accessToken", which names a credential`,
|
||||
map[string]any{"state": "servers", "key": "x", "value": map[string]any{"headers": map[string]any{"accessToken": "s"}}}},
|
||||
{"keeper.put@anchor", "is not a key the bus can hold",
|
||||
map[string]any{"state": "servers", "key": "a..b", "value": 1}},
|
||||
} {
|
||||
if _, err := call(t, asker, c.key, c.args); err == nil || !strings.Contains(err.Error(), c.why) {
|
||||
t.Errorf("%s %v: want refused for %q, got %v", c.key, c.args, c.why, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A delete reaches every watch.
|
||||
if _, err := call(t, asker, "keeper.del@anchor", map[string]any{"state": "servers", "key": "all.one"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitFor(t, peekerLog, "now delete all.one")
|
||||
waitFor(t, keeperLog, "now delete all.one")
|
||||
}
|
||||
|
||||
func read(t *testing.T, path string) []string {
|
||||
t.Helper()
|
||||
b, _ := os.ReadFile(path)
|
||||
return strings.Split(strings.TrimSpace(string(b)), "\n")
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, path, line string) {
|
||||
t.Helper()
|
||||
mt.Until(t, func() error {
|
||||
for _, l := range read(t, path) {
|
||||
if l == line {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errorf("%s has no %q yet: %s", filepath.Base(path), line, strings.Join(read(t, path), " | "))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user