mesh/delivery closed: the pull request closed unmerged
mesh/check pass 96a37f58ca76 on 030f9ee4e780 | unit 1173 run, 3272 kept, 13 skipped | integration 1212 run, 3274 kept, 22 skipped | over bound: unit, …
The controller's declared-config mirror never held de-spiegel's keys, and Resolve skipped an assigned module it held no declarations of, silently: de-spiegel stayed held on novox with its values set. Declarations now come from the indexer's trunk manifest with the wanted build's laid over it per node; which module runs where from the seat mesh-placement alone. A placed module with no readable manifest is listed as unresolved with why, logged, and keeps its last resolved values.
736 lines
29 KiB
Go
736 lines
29 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
|
|
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 {
|
|
if event != ConfigChanged {
|
|
return fmt.Errorf("mesh-config emits no %s", event)
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.events = append(m.events, body.(Change))
|
|
return nil
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
func (m *memStore) place(t *testing.T, node, module string, on bool) {
|
|
t.Helper()
|
|
type placed struct {
|
|
Module string `json:"module"`
|
|
}
|
|
p := struct {
|
|
V int `json:"v"`
|
|
Node string `json:"node"`
|
|
Modules []placed `json:"modules"`
|
|
}{V: 1, Node: node}
|
|
if e, _ := m.Get(PlacementRead, "placement."+node); e != nil {
|
|
if err := json.Unmarshal(e.Value, &p); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
var kept []placed
|
|
for _, x := range p.Modules {
|
|
if x.Module != module {
|
|
kept = append(kept, x)
|
|
}
|
|
}
|
|
if on {
|
|
kept = append(kept, placed{Module: module})
|
|
}
|
|
p.Modules = kept
|
|
if err := m.Put(PlacementRead, "placement."+node, p); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// 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 controller's predefined mirror, as it writes it.
|
|
const theList = `{"keys":{"agent-managed-settings":{"type":"object","description":"managed settings","trusted":true},
|
|
"font-size":{"type":"number","description":"text size, in points","minimum":6,"maximum":72}},
|
|
"repository":"mesh-catalog","commit":"aaaaaaaaaaaa","generation":5,"build":"b5"}`
|
|
|
|
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"}}}`)
|
|
m.mirror(t, PredefinedRead, PredefinedMirrorKey, 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 from the terminal", Call{Key: "agent-managed-settings",
|
|
Value: map[string]any{"a": 1.0}}, "set only at the controller's terminal"},
|
|
{"a trusted module value, not from the terminal", Call{Key: "managed", Value: map[string]any{},
|
|
Node: "g14", Module: "claude-code"}, "set only at the controller's terminal"},
|
|
{"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))
|
|
}
|
|
}
|
|
|
|
// **A trusted value from the controller's terminal is set**, with who gave it there (ADR 0348 §8).
|
|
func TestATrustedValueFromTheTerminalIsSetWithWhoGaveIt(t *testing.T) {
|
|
c, m := aMesh(t)
|
|
a := set(t, c, Call{Key: "agent-managed-settings", Value: map[string]any{"b": 2.0, "a": 1.0},
|
|
Caller: ControllerCaller, By: "jochen"})
|
|
if string(a.Value) != `{"a":1,"b":2}` {
|
|
t.Errorf("the value is written as %s", a.Value)
|
|
}
|
|
r := m.resolved(t, "g14", "claude-code", "managed")
|
|
if r.Source != SourceMesh || !r.Trusted || string(r.Value) != `{"a":1,"b":2}` {
|
|
t.Errorf("claude-code's managed settings resolve as %+v", r)
|
|
}
|
|
e, _ := m.Get(ValuesState, "mesh.agent-managed-settings")
|
|
var v Value
|
|
_ = json.Unmarshal(e.Value, &v)
|
|
if v.By != "jochen" || v.Via != ControllerCaller {
|
|
t.Errorf("the value says it was set by %s via %s", v.By, v.Via)
|
|
}
|
|
}
|
|
|
|
// **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, and an older one heard later is not** (ADR 0348 §7).
|
|
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"].Generation != 5 || !p["agent-managed-settings"].Trusted {
|
|
t.Fatalf("published %+v", p)
|
|
}
|
|
m.mirror(t, PredefinedRead, PredefinedMirrorKey, `{"keys":{"font-size":{"type":"number","description":"x"},
|
|
"theme":{"type":"string","description":"the theme"}},"commit":"bbbb","generation":7}`)
|
|
if _, err := c.Reconcile(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if p := published(); len(p) != 2 || p["theme"].Generation != 7 || p["agent-managed-settings"].Type != "" {
|
|
t.Fatalf("a newer list published %+v", p)
|
|
}
|
|
m.mirror(t, PredefinedRead, PredefinedMirrorKey, theList) // generation 5, heard later
|
|
if _, err := c.Reconcile(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if p := published(); p["theme"].Generation != 7 {
|
|
t.Fatalf("an older list replaced the newer one: %+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"})
|
|
m.mirror(t, PredefinedRead, PredefinedMirrorKey, `{"keys":{"agent-managed-settings":{"type":"object",
|
|
"description":"managed settings","trusted":true}},"commit":"cccccccccc","generation":9}`)
|
|
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)
|
|
}
|
|
}
|