A declaration's renamed-from moves the value with its trust and opens a window per node, closed on delivered and, for a returning node-engine, on manifest-reads-changed (ADR 0618). A value no manifest reads is marked retiring only once no node runs a build that reads it, asked once, removed only on the Approve (a secret through the seat mesh-vault's retire). New state in the old sections (ADR 0619), the stream config-events, the tools config_renames, config_rename_again, config_rename_close, config_retiring and the seat verb renames. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
779 lines
30 KiB
Go
779 lines
30 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
)
|
|
|
|
// memStore is the bus as mesh-config reaches it, in memory: its states and the mirrors it reads, its history log,
|
|
// the events it raised and the hand-acts it recorded.
|
|
type memStore struct {
|
|
mu sync.Mutex
|
|
states map[string]map[string][]byte
|
|
logs map[string][]LogEntry
|
|
events []Change
|
|
said []saidEvent
|
|
handActs []string
|
|
// failHandAct makes the hand-act log refuse.
|
|
failHandAct bool
|
|
}
|
|
|
|
func newStore() *memStore {
|
|
return &memStore{states: map[string]map[string][]byte{}, logs: map[string][]LogEntry{}}
|
|
}
|
|
|
|
func (m *memStore) Get(state, key string) (*Entry, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
v, ok := m.states[state][key]
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
return &Entry{Key: key, Value: append([]byte(nil), v...)}, nil
|
|
}
|
|
|
|
func (m *memStore) Put(state, key string, value any) error {
|
|
raw, err := json.Marshal(value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.states[state] == nil {
|
|
m.states[state] = map[string][]byte{}
|
|
}
|
|
m.states[state][key] = raw
|
|
return nil
|
|
}
|
|
|
|
func (m *memStore) Delete(state, key string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
delete(m.states[state], key)
|
|
return nil
|
|
}
|
|
|
|
func (m *memStore) Keys(state string) ([]string, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
var keys []string
|
|
for k := range m.states[state] {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys, nil
|
|
}
|
|
|
|
func (m *memStore) Append(log, key string, value any) error {
|
|
raw, err := json.Marshal(value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.logs[log] = append(m.logs[log], LogEntry{Key: key, Value: raw, At: time.Now()})
|
|
return nil
|
|
}
|
|
|
|
func (m *memStore) Read(log string) ([]LogEntry, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return append([]LogEntry(nil), m.logs[log]...), nil
|
|
}
|
|
|
|
func (m *memStore) Emit(event string, body any) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
switch event {
|
|
case ConfigChanged:
|
|
m.events = append(m.events, body.(Change))
|
|
case EventRenamed, EventRetiring, EventRetired:
|
|
m.said = append(m.said, saidEvent{event, body})
|
|
default:
|
|
return fmt.Errorf("mesh-config emits no %s", event)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// saidEvent is an event of the family config.events as it was emitted.
|
|
type saidEvent struct {
|
|
Event string
|
|
Body any
|
|
}
|
|
|
|
// saidOf is the events of one name emitted so far.
|
|
func (m *memStore) saidOf(event string) []any {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
var out []any
|
|
for _, e := range m.said {
|
|
if e.Event == event {
|
|
out = append(out, e.Body)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (m *memStore) HandAct(what, why, cause string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.failHandAct {
|
|
return errors.New("the controller did not answer")
|
|
}
|
|
m.handActs = append(m.handActs, what+" | "+why+" | "+cause)
|
|
return nil
|
|
}
|
|
|
|
// mirror writes what the controller mirrors, in the shapes of its mirrors.
|
|
func (m *memStore) mirror(t *testing.T, read, key, raw string) {
|
|
t.Helper()
|
|
if !json.Valid([]byte(raw)) {
|
|
t.Fatalf("not JSON: %s", raw)
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.states[read] == nil {
|
|
m.states[read] = map[string][]byte{}
|
|
}
|
|
m.states[read][key] = []byte(raw)
|
|
}
|
|
|
|
// assign places a module on a node, in the seat mesh-placement's key for the node.
|
|
func (m *memStore) assign(t *testing.T, node, module string) {
|
|
t.Helper()
|
|
m.place(t, node, module, true)
|
|
}
|
|
|
|
// unassign takes a module off a node's placement.
|
|
func (m *memStore) unassign(t *testing.T, node, module string) {
|
|
t.Helper()
|
|
m.place(t, node, module, false)
|
|
}
|
|
|
|
// place writes a node's placement as the seat writes it: its node key and module keys (research 072 row 37), and for
|
|
// ace in the form before, `placement.ace`, which a reader still takes while the change is delivered.
|
|
func (m *memStore) place(t *testing.T, node, module string, on bool) {
|
|
t.Helper()
|
|
keys := map[string][]byte{}
|
|
for _, k := range m.keysOf(PlacementRead) {
|
|
e, _ := m.Get(PlacementRead, k)
|
|
keys[k] = e.Value
|
|
}
|
|
all, err := delivery.PlacementsOf(keys)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p := all[node]
|
|
p.Node = node
|
|
var kept []delivery.PlacedModule
|
|
for _, x := range p.Modules {
|
|
if x.Module != module {
|
|
kept = append(kept, x)
|
|
}
|
|
}
|
|
if on {
|
|
kept = append(kept, delivery.PlacedModule{Module: module})
|
|
}
|
|
p.Modules = kept
|
|
for _, k := range m.keysOf(PlacementRead) {
|
|
if n, _, _, err := delivery.ParsePlacementKey(k); err == nil && n == node {
|
|
_ = m.Delete(PlacementRead, k)
|
|
}
|
|
}
|
|
if node == "ace" {
|
|
raw, err := delivery.EncodePlacement(p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = m.Put(PlacementRead, "placement.ace", json.RawMessage(raw))
|
|
return
|
|
}
|
|
values, err := delivery.PlacementValues(p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for k, raw := range values {
|
|
if err := m.Put(PlacementRead, k, json.RawMessage(raw)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *memStore) keysOf(state string) []string {
|
|
keys, _ := m.Keys(state)
|
|
return keys
|
|
}
|
|
|
|
// manifest keeps a manifest in the indexer's manifests by its digest, as the indexer writes it, and answers the digest.
|
|
func (m *memStore) manifest(t *testing.T, module, raw string) string {
|
|
t.Helper()
|
|
if !json.Valid([]byte(raw)) {
|
|
t.Fatalf("not JSON: %s", raw)
|
|
}
|
|
digest := delivery.ManifestDigest([]byte(raw))
|
|
value, err := delivery.EncodeManifest(delivery.Manifest{Module: module, JSON: raw})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m.mirror(t, ManifestsRead, digest, string(value))
|
|
return digest
|
|
}
|
|
|
|
// declare makes raw the module's manifest at the trunk: kept by digest, and named by the indexer's modules.<module>.
|
|
func (m *memStore) declare(t *testing.T, module, raw string) {
|
|
t.Helper()
|
|
digest := m.manifest(t, module, raw)
|
|
m.mirror(t, ModulesRead, module, fmt.Sprintf(`{"v":1,"manifest":%q,"repository":"novox/%s","commit":"c1"}`,
|
|
digest, module))
|
|
}
|
|
|
|
// want makes a node's want name a build of the module whose manifest is raw, as the assigner and the builder keep them.
|
|
func (m *memStore) want(t *testing.T, node, module, fp, raw string) {
|
|
t.Helper()
|
|
digest := m.manifest(t, module, raw)
|
|
key, err := delivery.BuilderBuildKey(module, fp)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m.mirror(t, BuildsRead, key, fmt.Sprintf(`{"v":1,"state":"done","manifest":%q}`, digest))
|
|
m.mirror(t, WantsRead, "want."+node+"."+module, fmt.Sprintf(`{"v":1,"fp":%q,"self":true}`, fp))
|
|
}
|
|
|
|
func (m *memStore) resolved(t *testing.T, node, module, key string) Resolved {
|
|
t.Helper()
|
|
e, _ := m.Get(ResolvedState, ResolvedKey(node, module, key))
|
|
if e == nil {
|
|
t.Fatalf("%s holds no resolved value for %s on %s", key, module, node)
|
|
}
|
|
var r Resolved
|
|
if err := json.Unmarshal(e.Value, &r); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return r
|
|
}
|
|
|
|
// dunstDeclared is dunst's declarations as the declared-config mirror on the feature branch writes them: kind, why
|
|
// and default always written, so a config-form key without a default comes as kind "", why "" and default null.
|
|
const dunstDeclared = `{"module":"dunst","settings":{
|
|
"font-size":{"kind":"","default":10,"why":"","type":"number","description":"text size, in points","minimum":6,"maximum":72,"predefined":"font-size"},
|
|
"width":{"kind":"","default":300,"why":"","type":"number","description":"the width of a notification, in pixels","minimum":100},
|
|
"position":{"kind":"","default":"top-right","why":"","type":"string","description":"where notifications show","enum":["top-right","top-left"]},
|
|
"origin":{"kind":"","default":null,"why":"","type":"string","description":"the operator's own origin","required":true,"pattern":"[a-z]+"}}}`
|
|
|
|
// theList is the indexer's list of predefined keys, as predefined.json holds it.
|
|
const theList = `{"agent-managed-settings":{"type":"object","description":"managed settings","trusted":true},
|
|
"font-size":{"type":"number","description":"text size, in points","minimum":6,"maximum":72}}`
|
|
|
|
func aMesh(t *testing.T) (*Config, *memStore) {
|
|
t.Helper()
|
|
m := newStore()
|
|
m.declare(t, "dunst", dunstDeclared)
|
|
m.declare(t, "claude-code", `{"module":"claude-code","settings":{
|
|
"managed":{"kind":"","default":{},"why":"","type":"object","description":"managed settings","trusted":true,"predefined":"agent-managed-settings"}}}`)
|
|
m.declare(t, "old", `{"module":"old","settings":{"workers":{"kind":"preference","default":4,"why":"two per core"}}}`)
|
|
indexerList(t, m, "aaaaaaaaaaaa", theList)
|
|
for _, node := range []string{"g14", "novox"} {
|
|
m.assign(t, node, "dunst")
|
|
}
|
|
m.assign(t, "g14", "claude-code")
|
|
m.assign(t, "g14", "old")
|
|
clock := time.Date(2026, 10, 14, 19, 0, 0, 0, time.UTC)
|
|
c := &Config{Store: m, Now: func() time.Time { clock = clock.Add(time.Second); return clock }, Logf: t.Logf}
|
|
if _, err := c.Reconcile(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m.events = nil // the first resolution's
|
|
return c, m
|
|
}
|
|
|
|
func set(t *testing.T, c *Config, call Call) *SetAnswer {
|
|
t.Helper()
|
|
if call.Action == "" {
|
|
call.Action = "set"
|
|
}
|
|
if call.Caller == "" {
|
|
call.Caller = "g14.node-tools"
|
|
}
|
|
if call.Why == "" {
|
|
call.Why = "the operator asked for it"
|
|
}
|
|
a, err := c.Change(call)
|
|
if err != nil {
|
|
t.Fatalf("%s %s: %v", call.Action, call.Key, err)
|
|
}
|
|
return a
|
|
}
|
|
|
|
func expect(t *testing.T, m *memStore, node string, value, source string) {
|
|
t.Helper()
|
|
r := m.resolved(t, node, "dunst", "font-size")
|
|
if string(r.Value) != value || r.Source != source {
|
|
t.Errorf("dunst's font-size on %s is %s from %s, want %s from %s", node, r.Value, r.Source, value, source)
|
|
}
|
|
}
|
|
|
|
// **The four sources, each in turn** (ADR 0348 §2, How it is checked): the manifest's default; mesh config for the
|
|
// predefined key it falls back to; node config for that key on the node; the module's own value on that node. Each
|
|
// cleared falls back to the one below.
|
|
func TestEachOfTheFourSourcesResolvesInTurnAndEachClearedFallsBack(t *testing.T) {
|
|
c, m := aMesh(t)
|
|
expect(t, m, "g14", "10", SourceDefault)
|
|
expect(t, m, "novox", "10", SourceDefault)
|
|
|
|
a := set(t, c, Call{Key: "font-size", Value: 12.0})
|
|
expect(t, m, "g14", "12", SourceMesh)
|
|
expect(t, m, "novox", "12", SourceMesh)
|
|
if len(a.Moved) != 2 || a.Level.Kind != LevelMesh {
|
|
t.Errorf("a mesh value moved %+v", a.Moved)
|
|
}
|
|
|
|
a = set(t, c, Call{Key: "font-size", Value: 14.0, Node: "g14"})
|
|
expect(t, m, "g14", "14", SourceNode)
|
|
expect(t, m, "novox", "12", SourceMesh)
|
|
if len(a.Moved) != 1 || a.Moved[0].Node != "g14" || string(a.Moved[0].From) != "12" || string(a.Moved[0].To) != "14" {
|
|
t.Errorf("g14's node value moved %+v", a.Moved)
|
|
}
|
|
|
|
a = set(t, c, Call{Key: "font-size", Value: 16.0, Node: "g14", Module: "dunst"})
|
|
expect(t, m, "g14", "16", SourceModule)
|
|
expect(t, m, "novox", "12", SourceMesh)
|
|
if len(a.Moved) != 1 || a.Moved[0].Source != SourceModule {
|
|
t.Errorf("dunst's own value moved %+v", a.Moved)
|
|
}
|
|
|
|
// A value set below the module's own moves nothing it overrides.
|
|
if a = set(t, c, Call{Key: "font-size", Value: 18.0, Node: "g14"}); len(a.Moved) != 0 {
|
|
t.Errorf("a node value under a module's own moved %+v", a.Moved)
|
|
}
|
|
|
|
set(t, c, Call{Action: "clear", Key: "font-size", Node: "g14", Module: "dunst"})
|
|
expect(t, m, "g14", "18", SourceNode)
|
|
set(t, c, Call{Action: "clear", Key: "font-size", Node: "g14"})
|
|
expect(t, m, "g14", "12", SourceMesh)
|
|
set(t, c, Call{Action: "clear", Key: "font-size"})
|
|
expect(t, m, "g14", "10", SourceDefault)
|
|
expect(t, m, "novox", "10", SourceDefault)
|
|
}
|
|
|
|
// **A value naming no predefined key ignores mesh and node config** (ADR 0348 §2): it resolves from its default and
|
|
// the module's own value only.
|
|
func TestAValueNamingNoPredefinedKeyResolvesFromItsDefaultAndItsOwnValueOnly(t *testing.T) {
|
|
c, m := aMesh(t)
|
|
set(t, c, Call{Key: "font-size", Value: 12.0})
|
|
set(t, c, Call{Key: "font-size", Value: 14.0, Node: "g14"})
|
|
if r := m.resolved(t, "g14", "dunst", "width"); string(r.Value) != "300" || r.Source != SourceDefault {
|
|
t.Errorf("width is %s from %s, want its default", r.Value, r.Source)
|
|
}
|
|
set(t, c, Call{Key: "width", Value: 400.0, Node: "g14", Module: "dunst"})
|
|
if r := m.resolved(t, "g14", "dunst", "width"); string(r.Value) != "400" || r.Source != SourceModule {
|
|
t.Errorf("width is %s from %s, want dunst's own value", r.Value, r.Source)
|
|
}
|
|
// A module not yet moved, in the settings form: its default and its own value.
|
|
if r := m.resolved(t, "g14", "old", "workers"); string(r.Value) != "4" || r.Source != SourceDefault {
|
|
t.Errorf("old's workers is %s from %s", r.Value, r.Source)
|
|
}
|
|
set(t, c, Call{Key: "workers", Value: 8.0, Node: "g14", Module: "old"})
|
|
if r := m.resolved(t, "g14", "old", "workers"); string(r.Value) != "8" || r.Source != SourceModule {
|
|
t.Errorf("old's workers is %s from %s", r.Value, r.Source)
|
|
}
|
|
}
|
|
|
|
// **A required value nothing sets is answered unset** (ADR 0348 §4), and once set, it is the value.
|
|
func TestARequiredValueNothingSetsIsAnsweredUnset(t *testing.T) {
|
|
c, m := aMesh(t)
|
|
r := m.resolved(t, "g14", "dunst", "origin")
|
|
if r.Source != SourceUnset || !r.Required || len(r.Value) != 0 {
|
|
t.Errorf("an unset required value resolves as %+v", r)
|
|
}
|
|
set(t, c, Call{Key: "origin", Value: "home", Node: "g14", Module: "dunst"})
|
|
if r = m.resolved(t, "g14", "dunst", "origin"); r.Source != SourceModule || string(r.Value) != `"home"` {
|
|
t.Errorf("a set required value resolves as %+v", r)
|
|
}
|
|
if r = m.resolved(t, "novox", "dunst", "origin"); r.Source != SourceUnset {
|
|
t.Errorf("another node's required value resolves as %+v", r)
|
|
}
|
|
}
|
|
|
|
// **The verb refuses** (ADR 0348 §3, §8, §10, How it is checked): a key not declared, naming the declared keys; a
|
|
// module value without a node; a value failing validation; a trusted value without the terminal; a change without
|
|
// why or a proven caller.
|
|
func TestTheVerbRefusesWhatADeclarationDoesNotAllow(t *testing.T) {
|
|
c, m := aMesh(t)
|
|
for _, tc := range []struct {
|
|
name string
|
|
call Call
|
|
want string
|
|
}{
|
|
{"a key the module does not declare", Call{Key: "colour", Value: "red", Node: "g14", Module: "dunst"},
|
|
"it declares: font-size, origin, position, width"},
|
|
{"a key not on the predefined list, at the mesh", Call{Key: "width", Value: 400.0},
|
|
"the predefined keys are: agent-managed-settings, font-size"},
|
|
{"a key not on the predefined list, at a node", Call{Key: "width", Value: 400.0, Node: "g14"}, "is not one"},
|
|
{"a module value without a node", Call{Key: "width", Value: 400.0, Module: "dunst"},
|
|
"no module value for the whole mesh"},
|
|
{"a module on a node it is not placed on", Call{Key: "width", Value: 400.0, Node: "ace", Module: "dunst"},
|
|
"it is placed on: g14, novox"},
|
|
{"a module not registered", Call{Key: "width", Value: 400.0, Node: "g14", Module: "nobody"}, "not a registered module"},
|
|
{"a value of another type", Call{Key: "font-size", Value: "12"}, `"12" is not a number`},
|
|
{"a value below its minimum", Call{Key: "font-size", Value: 2.0, Node: "g14"}, "below the minimum 6"},
|
|
{"a value above its maximum", Call{Key: "font-size", Value: 99.0, Node: "g14", Module: "dunst"}, "above the maximum 72"},
|
|
{"a value not in its enum", Call{Key: "position", Value: "middle", Node: "g14", Module: "dunst"}, "is not one of"},
|
|
{"a value not matching its pattern", Call{Key: "origin", Value: "Home 1", Node: "g14", Module: "dunst"},
|
|
"does not match the pattern"},
|
|
{"a trusted value at the mesh, not on an Approve", Call{Key: "agent-managed-settings",
|
|
Value: map[string]any{"a": 1.0}}, "set only on the operator's Approve"},
|
|
{"a trusted module value, not on an Approve", Call{Key: "managed", Value: map[string]any{},
|
|
Node: "g14", Module: "claude-code"}, "set only on the operator's Approve"},
|
|
{"a change without why", Call{Key: "font-size", Value: 12.0, Why: " "}, "say why"},
|
|
{"an unproven caller", Call{Key: "font-size", Value: 12.0, Caller: Unproven}, "no proven caller"},
|
|
{"by from anyone but the controller", Call{Key: "font-size", Value: 12.0, By: "jochen"}, "only the controller says"},
|
|
{"a clear of nothing set", Call{Action: "clear", Key: "font-size", Node: "g14"}, "nothing is set"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
call := tc.call
|
|
if call.Action == "" {
|
|
call.Action = "set"
|
|
}
|
|
if call.Caller == "" {
|
|
call.Caller = "g14.node-tools"
|
|
}
|
|
if call.Why == "" {
|
|
call.Why = "a test"
|
|
}
|
|
_, err := c.Change(call)
|
|
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
|
t.Fatalf("refused with %v, want %q", err, tc.want)
|
|
}
|
|
})
|
|
}
|
|
if keys, _ := m.Keys(ValuesState); len(keys) != 0 {
|
|
t.Errorf("a refused call wrote %v", keys)
|
|
}
|
|
if len(m.events) != 0 || len(m.handActs) != 0 {
|
|
t.Errorf("a refused call raised %d events and recorded %d hand-acts", len(m.events), len(m.handActs))
|
|
}
|
|
}
|
|
|
|
// 1126/A5/AC1: **no controller relay is trusted.** With no lease key (the controller ended by its unassignment), a trusted
|
|
// value set or cleared by the controller's account is refused, naming propose; nothing is written (ADR 0565 §13).
|
|
func TestNoControllerRelayIsTrusted(t *testing.T) {
|
|
for _, call := range []Call{
|
|
{Action: "set", Key: "agent-managed-settings", Value: map[string]any{"a": 1.0}},
|
|
{Action: "set", Key: "managed", Node: "g14", Module: "claude-code", Value: map[string]any{"a": 1.0}},
|
|
{Action: "clear", Key: "agent-managed-settings"},
|
|
} {
|
|
c, m := aMesh(t)
|
|
m.mirror(t, ValuesState, "mesh.agent-managed-settings", `{"value":{"b":2},"by":"person.jochen","via":"person.jochen"}`)
|
|
call.Caller, call.By, call.Why = ControllerCaller, "jochen", "at the terminal"
|
|
_, err := c.Change(call)
|
|
if err == nil || !strings.Contains(err.Error(), "propose") {
|
|
t.Errorf("%s %s from the controller: %v", call.Action, call.Key, err)
|
|
}
|
|
if v, _ := storedValue(t, m, "mesh.agent-managed-settings"); string(v.Value) != `{"b":2}` {
|
|
t.Errorf("%s %s from the controller left %s", call.Action, call.Key, v.Value)
|
|
}
|
|
if _, held := storedValue(t, m, "module.g14.claude-code.managed"); held {
|
|
t.Errorf("%s %s from the controller wrote claude-code's own value", call.Action, call.Key)
|
|
}
|
|
}
|
|
}
|
|
|
|
// **Every set and clear is in the history and the hand-act log with the proven caller and why, and raises
|
|
// config-changed naming what moved** (ADR 0348 §9, §10).
|
|
func TestEveryChangeIsKeptWithWhoAndWhyAndRaisesConfigChanged(t *testing.T) {
|
|
c, m := aMesh(t)
|
|
m.events = nil
|
|
set(t, c, Call{Key: "font-size", Value: 12.0, Caller: "novox.node-tools", Why: "bigger on every desk"})
|
|
set(t, c, Call{Action: "clear", Key: "font-size", Caller: "g14.node-tools", Why: "back to the default"})
|
|
|
|
history, err := c.History(Call{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var changes []HistoryEntry
|
|
for _, h := range history {
|
|
if h.Action == "set" || h.Action == "clear" {
|
|
changes = append(changes, h)
|
|
}
|
|
}
|
|
if len(changes) != 2 || changes[0].By != "novox.node-tools" || changes[0].Why != "bigger on every desk" ||
|
|
changes[1].Action != "clear" || changes[1].By != "g14.node-tools" || string(changes[1].Was) != "12" {
|
|
t.Fatalf("the history holds %+v", changes)
|
|
}
|
|
if len(m.handActs) != 2 || !strings.Contains(m.handActs[0], "config set font-size at mesh config to 12, by novox.node-tools | bigger on every desk") {
|
|
t.Errorf("the hand-act log holds %q", m.handActs)
|
|
}
|
|
if len(m.events) != 2 || len(m.events[0].Moved) != 2 || m.events[0].Key != "font-size" || m.events[1].Action != "clear" {
|
|
t.Errorf("config-changed was raised as %+v", m.events)
|
|
}
|
|
// Narrowed: g14's dunst sees both, through what they moved; a node that holds no dunst sees neither.
|
|
if h, _ := c.History(Call{Module: "dunst", Node: "g14"}); len(h) < 2 {
|
|
t.Errorf("dunst on g14's history holds %d changes", len(h))
|
|
}
|
|
if h, _ := c.History(Call{Node: "ace"}); len(h) != 0 {
|
|
t.Errorf("ace's history holds %+v", h)
|
|
}
|
|
}
|
|
|
|
// **A hand-act the log refuses is said in the answer, never dropped**, and the value is set.
|
|
func TestAChangeTheHandActLogRefusesIsSaid(t *testing.T) {
|
|
c, m := aMesh(t)
|
|
m.failHandAct = true
|
|
a := set(t, c, Call{Key: "font-size", Value: 12.0})
|
|
if len(a.Unsaid) != 1 || !strings.Contains(a.Unsaid[0], "hand-act log does not hold it") {
|
|
t.Errorf("the answer says %q", a.Unsaid)
|
|
}
|
|
expect(t, m, "g14", "12", SourceMesh)
|
|
}
|
|
|
|
// **The newest list of predefined keys is published** (ADR 0348 §7; the indexer never keeps an older merge's).
|
|
func TestTheNewestListOfPredefinedKeysIsPublished(t *testing.T) {
|
|
c, m := aMesh(t)
|
|
published := func() map[string]Published {
|
|
out := map[string]Published{}
|
|
keys, _ := m.Keys(PredefinedState)
|
|
for _, k := range keys {
|
|
e, _ := m.Get(PredefinedState, k)
|
|
var p Published
|
|
_ = json.Unmarshal(e.Value, &p)
|
|
out[k] = p
|
|
}
|
|
return out
|
|
}
|
|
if p := published(); len(p) != 2 || p["font-size"].Commit != "aaaaaaaaaaaa" || !p["agent-managed-settings"].Trusted {
|
|
t.Fatalf("published %+v", p)
|
|
}
|
|
indexerList(t, m, "bbbb", `{"font-size":{"type":"number","description":"x"},
|
|
"theme":{"type":"string","description":"the theme"}}`)
|
|
if _, err := c.Reconcile(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if p := published(); len(p) != 2 || p["theme"].Commit != "bbbb" || p["agent-managed-settings"].Type != "" {
|
|
t.Fatalf("a newer list published %+v", p)
|
|
}
|
|
}
|
|
|
|
// **A list dropping a key that still holds a value is withheld** (ADR 0348 §7): the list published stands, and the
|
|
// listing says why until the value is cleared.
|
|
func TestAListDroppingAKeyThatHoldsAValueIsWithheldUntilItIsCleared(t *testing.T) {
|
|
c, m := aMesh(t)
|
|
set(t, c, Call{Key: "font-size", Value: 12.0, Node: "novox"})
|
|
indexerList(t, m, "cccccccccc", `{"agent-managed-settings":{"type":"object","description":"managed settings","trusted":true}}`)
|
|
if _, err := c.Reconcile(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
listed, err := c.List(Call{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, kept := listed.Predefined["font-size"]; !kept ||
|
|
!strings.Contains(listed.Withheld, "drops font-size (still set as node config on novox)") {
|
|
t.Fatalf("the list was published over a value it drops: withheld %q, published %v", listed.Withheld,
|
|
joinKeys(listed.Predefined))
|
|
}
|
|
set(t, c, Call{Action: "clear", Key: "font-size", Node: "novox"})
|
|
if _, err := c.Reconcile(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
listed, _ = c.List(Call{})
|
|
if _, kept := listed.Predefined["font-size"]; kept || listed.Withheld != "" {
|
|
t.Errorf("once cleared, the list is still withheld: %q, %v", listed.Withheld, joinKeys(listed.Predefined))
|
|
}
|
|
}
|
|
|
|
// **A placement, a declaration or its removal moves the resolved values**, written only where they moved, and
|
|
// said as config-changed; a pass over unchanged sources writes nothing.
|
|
func TestAnAssignmentMovesTheResolvedValuesAndAQuietPassWritesNothing(t *testing.T) {
|
|
c, m := aMesh(t)
|
|
r, err := c.Reconcile()
|
|
if err != nil || len(r.Moved) != 0 {
|
|
t.Fatalf("a pass over unchanged sources moved %+v (%v)", r.Moved, err)
|
|
}
|
|
m.events = nil
|
|
m.assign(t, "ace", "dunst")
|
|
if r, _ = c.Reconcile(); len(r.Moved) != 4 {
|
|
t.Errorf("an assignment moved %+v", r.Moved)
|
|
}
|
|
expect(t, m, "ace", "10", SourceDefault)
|
|
if len(m.events) != 1 || m.events[0].Action != "resolve" {
|
|
t.Errorf("an assignment raised %+v", m.events)
|
|
}
|
|
m.unassign(t, "ace", "dunst")
|
|
if r, _ = c.Reconcile(); len(r.Moved) != 4 || !r.Moved[0].Gone {
|
|
t.Errorf("an unassignment moved %+v", r.Moved)
|
|
}
|
|
if e, _ := m.Get(ResolvedState, ResolvedKey("ace", "dunst", "font-size")); e != nil {
|
|
t.Error("an unassigned module keeps its resolved values")
|
|
}
|
|
}
|
|
|
|
// **The declared-config mirror's two shapes read the same** (the release lead's note on issue 652): kind, why and a
|
|
// null default written for a config-form key are not given, and a mirror that leaves them out reads alike.
|
|
func TestTheDeclaredConfigMirrorsTwoShapesReadTheSame(t *testing.T) {
|
|
with, err := ParseModuleDeclarations([]byte(`{"module":"x","settings":{"k":{"kind":"","default":null,"why":"","type":"string","description":"d","required":true}}}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
without, err := ParseModuleDeclarations([]byte(`{"module":"x","settings":{"k":{"type":"string","description":"d","required":true}}}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a, _ := json.Marshal(with)
|
|
b, _ := json.Marshal(without)
|
|
if string(a) != string(b) || with.Config["k"].HasDefault() {
|
|
t.Errorf("the two shapes read as %s and %s", a, b)
|
|
}
|
|
if r := resolveOne(Sources{}, "n", "x", "k", with.Config["k"]); r.Source != SourceUnset {
|
|
t.Errorf("a null default resolved as %+v", r)
|
|
}
|
|
}
|
|
|
|
// **A listing narrows by module and node**, and says the mesh and node values its modules fall back to.
|
|
func TestAListingNarrowsByModuleAndNode(t *testing.T) {
|
|
c, _ := aMesh(t)
|
|
set(t, c, Call{Key: "font-size", Value: 12.0})
|
|
set(t, c, Call{Key: "font-size", Value: 14.0, Node: "novox"})
|
|
l, err := c.List(Call{Module: "dunst", Node: "g14"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(l.Resolved) != 4 || len(l.Set) != 1 || l.Set[0].Level.Kind != LevelMesh || l.Predefined != nil {
|
|
t.Errorf("dunst on g14 lists %d resolved and %+v", len(l.Resolved), l.Set)
|
|
}
|
|
l, _ = c.List(Call{Node: "novox"})
|
|
if len(l.Resolved) != 4 || len(l.Set) != 2 {
|
|
t.Errorf("novox lists %d resolved and %+v", len(l.Resolved), l.Set)
|
|
}
|
|
}
|
|
|
|
// **The verb's arguments are read strictly**: one it does not know is refused, naming what it takes.
|
|
func TestTheVerbsArgumentsAreReadStrictly(t *testing.T) {
|
|
if _, err := callOf(map[string]any{"action": "set", "level": "mesh"}); err == nil ||
|
|
!strings.Contains(err.Error(), "config takes no level") {
|
|
t.Errorf("an unknown argument: %v", err)
|
|
}
|
|
if _, err := callOf(map[string]any{"key": 3.0}); err == nil {
|
|
t.Error("a key that is not a string was read")
|
|
}
|
|
call, err := callOf(map[string]any{"action": "set", "key": "font-size", "value": 12.0, "why": " w "})
|
|
if err != nil || call.Value != 12.0 || call.Why != "w" {
|
|
t.Errorf("read %+v (%v)", call, err)
|
|
}
|
|
}
|
|
|
|
// despiegel is de-spiegel's manifest as its own repository declares it (novox/de-spiegel at e25e1beb, its config
|
|
// alone): required trusted values, no default.
|
|
const despiegel = `{"module":"de-spiegel","version":"1","requires":["route","smtp"],"config":{
|
|
"praktijk-name":{"type":"string","description":"the practice's public name","trusted":true,"required":true},
|
|
"praktijk-www-name":{"type":"string","description":"its www form","trusted":true,"required":true}}}`
|
|
|
|
// Issue 1953: **each placed module's declarations come from its own manifests as built**, the trunk's at the indexer
|
|
// and the wanted build's at the builder, never from the controller's mirror; a module placed with no manifest to read
|
|
// is said unresolved with why, in the listing and the log, and keeps what was resolved for it.
|
|
func TestDeclarationsComeFromTheManifestsAsBuilt(t *testing.T) {
|
|
fp := "src2:" + strings.Repeat("ab", 32)
|
|
cases := []struct {
|
|
name string
|
|
given func(t *testing.T, m *memStore)
|
|
// resolved is each key's resolved value on novox, `<module>.<key>` to source and value ("" for none).
|
|
resolved map[string]string
|
|
absent []string
|
|
unresolved string
|
|
}{
|
|
{name: "a module of its own repository resolves from the trunk's manifest, its value set",
|
|
given: func(t *testing.T, m *memStore) {
|
|
m.declare(t, "de-spiegel", despiegel)
|
|
m.assign(t, "novox", "de-spiegel")
|
|
must(t, m.Put(ValuesState, Level{Kind: LevelModule, Node: "novox", Module: "de-spiegel"}.ValueKey(
|
|
"praktijk-name"), Value{Value: json.RawMessage(`"praktijkdespiegel.be"`), By: "the operator"}))
|
|
// The controller's mirror says otherwise, and is not read.
|
|
m.mirror(t, "mesh-controller.declared-config", "de-spiegel", `{"module":"de-spiegel","settings":{}}`)
|
|
},
|
|
resolved: map[string]string{"de-spiegel.praktijk-name": `module "praktijkdespiegel.be"`,
|
|
"de-spiegel.praktijk-www-name": "unset "}},
|
|
{name: "the wanted build's manifest lays its keys over the trunk's on its node",
|
|
given: func(t *testing.T, m *memStore) {
|
|
m.declare(t, "de-spiegel", despiegel)
|
|
m.assign(t, "novox", "de-spiegel")
|
|
m.want(t, "novox", "de-spiegel", fp, `{"module":"de-spiegel","config":{
|
|
"praktijk-name":{"type":"string","description":"d","default":"old.example"},
|
|
"old-key":{"type":"number","description":"d","default":3}}}`)
|
|
},
|
|
resolved: map[string]string{"de-spiegel.praktijk-name": `default "old.example"`,
|
|
"de-spiegel.old-key": "default 3", "de-spiegel.praktijk-www-name": "unset "}},
|
|
{name: "a removed want resolves from the trunk alone",
|
|
given: func(t *testing.T, m *memStore) {
|
|
m.declare(t, "de-spiegel", despiegel)
|
|
m.assign(t, "novox", "de-spiegel")
|
|
m.want(t, "novox", "de-spiegel", fp, `{"module":"de-spiegel","config":{
|
|
"old-key":{"type":"number","description":"d","default":3}}}`)
|
|
m.mirror(t, WantsRead, "want.novox.de-spiegel", `{"v":1,"removed":true}`)
|
|
},
|
|
resolved: map[string]string{"de-spiegel.praktijk-name": "unset "}, absent: []string{"de-spiegel.old-key"}},
|
|
{name: "a placed module the indexer holds no manifest of is unresolved, said, its values kept",
|
|
given: func(t *testing.T, m *memStore) {
|
|
must(t, m.Put(ResolvedState, ResolvedKey("novox", "de-spiegel", "praktijk-name"),
|
|
Resolved{Node: "novox", Module: "de-spiegel", Key: "praktijk-name", Source: SourceUnset}))
|
|
m.assign(t, "novox", "de-spiegel")
|
|
},
|
|
resolved: map[string]string{"de-spiegel.praktijk-name": "unset "},
|
|
unresolved: "the indexer holds no modules.de-spiegel"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
m := newStore()
|
|
tc.given(t, m)
|
|
var said []string
|
|
c := &Config{Store: m, Now: time.Now, Logf: func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) }}
|
|
if _, err := c.Reconcile(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for k, want := range tc.resolved {
|
|
module, key, _ := strings.Cut(k, ".")
|
|
r := m.resolved(t, "novox", module, key)
|
|
if got := r.Source + " " + string(r.Value); got != want {
|
|
t.Errorf("%s resolved as %s, want %s", k, got, want)
|
|
}
|
|
}
|
|
for _, k := range tc.absent {
|
|
module, key, _ := strings.Cut(k, ".")
|
|
if e, _ := m.Get(ResolvedState, ResolvedKey("novox", module, key)); e != nil {
|
|
t.Errorf("%s is resolved: %s", k, e.Value)
|
|
}
|
|
}
|
|
l, err := c.List(Call{Module: "de-spiegel"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if tc.unresolved == "" {
|
|
if len(l.Unresolved) != 0 {
|
|
t.Errorf("unresolved: %+v", l.Unresolved)
|
|
}
|
|
return
|
|
}
|
|
if len(l.Unresolved) != 1 || l.Unresolved[0].Node != "novox" ||
|
|
!strings.Contains(l.Unresolved[0].Why, tc.unresolved) {
|
|
t.Errorf("the listing says %+v, want %q", l.Unresolved, tc.unresolved)
|
|
}
|
|
if !strings.Contains(strings.Join(said, "\n"), tc.unresolved) {
|
|
t.Errorf("the log does not say it: %q", said)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func must(t *testing.T, err error) {
|
|
t.Helper()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|