786 lines
32 KiB
Go
786 lines
32 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-controller/internal/catalogue"
|
|
snapshot "github.com/novox/mesh-controller/internal/facts"
|
|
"github.com/novox/mesh-controller/internal/link"
|
|
"github.com/novox/mesh-controller/internal/overlay"
|
|
)
|
|
|
|
// configuredMesh is every live settings layer (issue 664) — read with the settings verb on 2026-10-15, on the four
|
|
// machines as anchor, laptop, workstation and desktop, the whole mesh's with no node; personal values stood in for,
|
|
// and the placement words left out, which move-placement moves first — with each module in the config form shaped
|
|
// as its definition on the feature branch: its config, its files (templates inlined) and directories, placeholders
|
|
// of kinds this test's mesh does not hold stood in for. A module still in the settings form (the media catalogue's,
|
|
// and kernel) stands in as a mergeable file that takes every key. `on` is where each is assigned, as live.
|
|
type configuredMesh struct {
|
|
Modules []json.RawMessage `json:"modules"`
|
|
Layers []configuredLayer `json:"layers"`
|
|
On map[string][]string `json:"on"`
|
|
// Predefined is the catalogue's list of predefined keys, which a module falls back to.
|
|
Predefined json.RawMessage `json:"predefined"`
|
|
}
|
|
|
|
type configuredLayer struct {
|
|
Node string `json:"node"`
|
|
Module string `json:"module"`
|
|
Values map[string]any `json:"values"`
|
|
}
|
|
|
|
var fourMachines = []string{"anchor", "laptop", "workstation", "desktop"}
|
|
|
|
// aConfiguredMesh is aMesh with two machines more, every module of the configured mesh registered and assigned, and
|
|
// its layers held as the store holds them once move-placement has run.
|
|
func aConfiguredMesh(t *testing.T) (*stores, configuredMesh, map[string]map[string]any) {
|
|
t.Helper()
|
|
open := aMesh(t)
|
|
ctx := t.Context()
|
|
inv := open.inventory
|
|
for i, name := range []string{"workstation", "desktop"} {
|
|
record, err := inv.AddNode(ctx, name)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := inv.SetPlace(ctx, name, name+".example:51820", "here", false, fmt.Sprintf("10.77.0.%d", i+3)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := inv.RecordProfile(ctx, record.ID, map[string]any{"capabilities": []any{
|
|
map[string]any{"name": "container-runtime", "present": true},
|
|
map[string]any{"name": "systemd", "present": true}}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := inv.RecordSealingKey(ctx, record.ID, aPublicKey(t)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := inv.RecordOverlayKey(ctx, record.ID, aPublicKey(t)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := inv.Assign(ctx, name, overlay.Name); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
raw, err := os.ReadFile(filepath.Join("testdata", "config-moved", "mesh.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var w configuredMesh
|
|
if err := json.Unmarshal(raw, &w); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
list, err := catalogue.ParsePredefined(w.Predefined)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
catalogue.UsePredefined(list)
|
|
served := map[string]map[string]any{}
|
|
for _, text := range w.Modules {
|
|
var shape map[string]json.RawMessage
|
|
if err := json.Unmarshal(text, &shape); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var name string
|
|
_ = json.Unmarshal(shape["module"], &name)
|
|
if s, ok := shape["serves-checked"]; ok {
|
|
var serves map[string]any
|
|
if err := json.Unmarshal(s, &serves); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
served[name] = serves
|
|
delete(shape, "serves-checked")
|
|
}
|
|
text, err := json.Marshal(shape)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m, err := catalogue.ParseManifest(text)
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", name, err)
|
|
}
|
|
register(t, open, m)
|
|
}
|
|
for _, l := range w.Layers {
|
|
values, err := json.Marshal(l.Values)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if l.Node == "" {
|
|
inv.ExecForTest(t, `insert into settings (node, module, values) values (null, $1, $2)`, l.Module, string(values))
|
|
continue
|
|
}
|
|
inv.ExecForTest(t, `insert into settings (node, module, values) select id, $2, $3 from node where name = $1`,
|
|
l.Node, l.Module, string(values))
|
|
}
|
|
for _, module := range movedKeys(w.On) {
|
|
for _, node := range w.On[module] {
|
|
if _, err := assign(ctx, open, node, module); err != nil {
|
|
t.Fatalf("assign %s %s: %v", node, module, err)
|
|
}
|
|
}
|
|
}
|
|
return open, w, served
|
|
}
|
|
|
|
// standInMeshConfig is mesh-config as this test holds it: each value at its level, and resolved from the catalogue
|
|
// the store holds as mesh-config resolves (novox/hq ADR 0348 §2): the module's own value on the node, else node
|
|
// config then mesh config for the predefined key it falls back to, else its default; a required value nothing sets is
|
|
// unset.
|
|
type standInMeshConfig struct {
|
|
open *stores
|
|
values map[string]any
|
|
sets []string
|
|
// asked is each call of its verb: the action and the level's key.
|
|
asked []string
|
|
}
|
|
|
|
func (c *standInMeshConfig) Values(context.Context) (map[string]json.RawMessage, error) {
|
|
out := map[string]json.RawMessage{}
|
|
for k, v := range c.values {
|
|
raw, err := json.Marshal(v)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out[k] = raw
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (c *standInMeshConfig) Set(_ context.Context, level configLevel, key string, value any, by, why string) error {
|
|
if by == "" || why == "" {
|
|
return fmt.Errorf("%s at %s is set without who or why", key, level)
|
|
}
|
|
c.values[level.valueKey(key)] = normalJSON(value)
|
|
c.sets = append(c.sets, level.valueKey(key))
|
|
return nil
|
|
}
|
|
|
|
func (c *standInMeshConfig) Resolved(ctx context.Context) ([]resolvedEntry, error) {
|
|
entries, err := c.open.inventory.Catalogued(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out []resolvedEntry
|
|
for _, e := range entries {
|
|
m := e.Manifest
|
|
if !m.DeclaresConfig() {
|
|
continue
|
|
}
|
|
for _, node := range e.On {
|
|
for _, key := range movedKeys(m.Settings) {
|
|
d := m.Settings[key]
|
|
r := resolvedEntry{Node: node, Module: m.Module, Key: key, Source: "default"}
|
|
value, has := c.values["module."+node+"."+m.Module+"."+key]
|
|
switch {
|
|
case has:
|
|
r.Source = "module"
|
|
case d.Predefined != "" && c.values["node."+node+"."+d.Predefined] != nil:
|
|
value, r.Source = c.values["node."+node+"."+d.Predefined], "node"
|
|
case d.Predefined != "" && c.values["mesh."+d.Predefined] != nil:
|
|
value, r.Source = c.values["mesh."+d.Predefined], "mesh"
|
|
case d.Required:
|
|
r.Source = "unset"
|
|
default:
|
|
value = d.Default
|
|
}
|
|
if r.Source != "unset" {
|
|
if r.Value, err = json.Marshal(value); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// laid is the stand-in as composition reads it once the switch is on (inventory.ConfigValues).
|
|
func (c *standInMeshConfig) laid(ctx context.Context, node, module string) (map[string]any, error) {
|
|
resolved, err := c.Resolved(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return configLaid(resolved, node, module)
|
|
}
|
|
|
|
// snapshotFacts is the facts snapshot as gatherFacts gives it.
|
|
type snapshotFacts = snapshot.Facts
|
|
|
|
// toldEveryMachine is what each of the four machines is told — every resource and the filter's rules — and what
|
|
// each module that serves something serves, settled from its layers, as JSON a person can diff.
|
|
func toldEveryMachine(t *testing.T, open *stores, served map[string]map[string]any, on map[string][]string) map[string]string {
|
|
t.Helper()
|
|
ctx := t.Context()
|
|
gens, err := generators(ctx, open)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
shelf, err := open.inventory.Catalogue(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
told := map[string]string{}
|
|
for _, node := range fourMachines {
|
|
plan, settings, err := planFor(ctx, open, node)
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", node, err)
|
|
}
|
|
with, _, err := renderingFor(ctx, open, node, plan, settings, gens, Allocating)
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", node, err)
|
|
}
|
|
composed, err := plan.Compose(with)
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", node, err)
|
|
}
|
|
rules, err := plan.Rules(with)
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", node, err)
|
|
}
|
|
settles := map[string]any{}
|
|
for module, serves := range served {
|
|
if !containsString(on[module], node) {
|
|
continue
|
|
}
|
|
layers, err := open.inventory.SettingsFor(ctx, node, module)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for name, s := range serves {
|
|
fact, _ := s.(map[string]any)
|
|
settled, err := catalogue.Settle(fact, catalogue.WithDefaults(shelf[module], layers))
|
|
if err != nil {
|
|
settles[module+" serves "+name] = "error: " + err.Error()
|
|
continue
|
|
}
|
|
settles[module+" serves "+name] = settled
|
|
}
|
|
}
|
|
got, err := json.MarshalIndent(map[string]any{"resources": composed.Resources, "left-out": composed.LeftOut,
|
|
"rules": rules, "served": settles}, "", " ")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
told[node] = string(got)
|
|
}
|
|
return told
|
|
}
|
|
|
|
// effectiveEverywhere is each module's every value on each machine it is assigned to, as `settings show` and
|
|
// `settings preferences` say them (catalogue.Effective): the value, never where it came from, which the move
|
|
// changes by design; a key a layer says by its file's name is read as its config key.
|
|
func effectiveEverywhere(t *testing.T, open *stores, w configuredMesh) map[string]any {
|
|
t.Helper()
|
|
ctx := t.Context()
|
|
shelf, err := open.inventory.Catalogue(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out := map[string]any{}
|
|
for _, module := range movedKeys(w.On) {
|
|
if !shelf[module].DeclaresConfig() {
|
|
continue
|
|
}
|
|
for _, node := range w.On[module] {
|
|
layers, err := open.inventory.SettingsFor(ctx, node, module)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// A layer's old spelling of a key (nodered's timeZone) is listed beside the key it is the file's name
|
|
// for, and reaches the file as that key does (issue 751): read as that key, which the move sets.
|
|
as := catalogue.TakenAs(shelf[module])
|
|
for _, v := range catalogue.Effective(shelf[module], layers) {
|
|
if key, old := as[v.Key]; old {
|
|
out[node+"."+module+"."+key] = normalJSON(v.Value)
|
|
continue
|
|
}
|
|
if _, said := out[node+"."+module+"."+v.Key]; said && v.FromDefault {
|
|
continue
|
|
}
|
|
out[node+"."+module+"."+v.Key] = normalJSON(v.Value)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func setAt(a configMoveAnswer, key string) []configSet {
|
|
var out []configSet
|
|
for _, s := range a.Sets {
|
|
if s.valueKey(s.Key) == key {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// **The settings layers move into mesh-config and no machine is told anything new** (novox/hq ADR 0348 §9, design
|
|
// 57 K5, issue 664). Every live layer is seeded as the store holds it once move-placement has run, on modules shaped
|
|
// as their definitions, and every machine composed. The move, asked without confirming, says each value it would set
|
|
// and where from, and sets nothing; confirmed, it sets them in mesh-config, reads them back, empties the layers it
|
|
// moved and switches. Each machine is then told byte for byte what it was told before, every module serves the same,
|
|
// and `settings` lists the same values `config` resolves. Asked again, it sets nothing.
|
|
func TestEveryMachineIsToldTheSameAfterTheMoveOfConfig(t *testing.T) {
|
|
open, w, served := aConfiguredMesh(t)
|
|
ctx := t.Context()
|
|
readBackTries = 0
|
|
t.Cleanup(func() { readBackTries = 15 })
|
|
inv := open.inventory
|
|
config := &standInMeshConfig{open: open, values: map[string]any{}}
|
|
inv.ReadsConfigFrom(config.laid)
|
|
before := toldEveryMachine(t, open, served, w.On)
|
|
listedBefore := effectiveEverywhere(t, open, w)
|
|
factsBefore, err := gatherFacts(ctx, open, "2.11.17")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
asked, err := runConfigMove(ctx, inv, config, false, "the move of config, run by a test", "")
|
|
if err != nil || !asked.DryRun || asked.Switched || len(asked.Problems) > 0 || len(config.sets) > 0 {
|
|
t.Fatalf("the move, not confirmed, says %+v (%v) and set %v", asked, err, config.sets)
|
|
}
|
|
// nodered's live layer says the file's name for its time zone: set under the config key (issue 751).
|
|
if s := setAt(asked, "module.anchor.nodered.time-zone"); len(s) != 1 || s[0].Value != "Europe/Brussels" ||
|
|
!strings.HasSuffix(s[0].From[0], ": timeZone") {
|
|
t.Errorf("nodered's time zone moves as %+v", s)
|
|
}
|
|
for _, s := range asked.Sets {
|
|
if s.Key == "timeZone" {
|
|
t.Errorf("a key is set under the file's name: %+v", s)
|
|
}
|
|
}
|
|
// claude-code's whole-mesh managed settings become mesh config for agent-managed-settings (ADR 0348's
|
|
// consequences), and its whole-mesh layer sets no module value.
|
|
if s := setAt(asked, "mesh.agent-managed-settings"); len(s) != 1 || s[0].From[0] != "claude-code on the whole mesh: managed_settings" {
|
|
t.Errorf("claude-code's managed settings move as %+v", s)
|
|
}
|
|
for _, node := range fourMachines {
|
|
if s := setAt(asked, "module."+node+".claude-code.managed_settings"); len(s) > 0 {
|
|
t.Errorf("claude-code's managed settings are also set on %s: %+v", node, s)
|
|
}
|
|
}
|
|
// A whole-mesh layer of a value no predefined key holds goes to each machine it is assigned to, under that
|
|
// machine's layer: power's on the workstation is the workstation's, its own elsewhere.
|
|
if s := setAt(asked, "module.workstation.power.handle-power-key"); len(s) != 1 || s[0].Value != "suspend" {
|
|
t.Errorf("power's key on the workstation moves as %+v", s)
|
|
}
|
|
if s := setAt(asked, "module.anchor.power.handle-power-key"); len(s) != 1 || s[0].Value != "poweroff" {
|
|
t.Errorf("power's key on the anchor moves as %+v", s)
|
|
}
|
|
for _, s := range asked.Sets {
|
|
if s.Level == "node" {
|
|
t.Errorf("a machine's layer became node config: %+v", s)
|
|
}
|
|
}
|
|
var stays []string
|
|
for _, s := range asked.Stays {
|
|
stays = append(stays, strings.Fields(s)[0])
|
|
}
|
|
sort.Strings(stays)
|
|
if want := []string{"bazarr", "bazarr", "bookshelf", "jackett", "kernel", "kometa", "lidarr", "nzbget", "ombi",
|
|
"plex", "qbittorrent", "radarr", "sonarr", "tautulli"}; len(stays) < 10 || !containsString(stays, "kernel") {
|
|
t.Errorf("the layers that stay are %v, want those of %v", stays, want)
|
|
}
|
|
if after := toldEveryMachine(t, open, served, w.On); !reflect.DeepEqual(after, before) {
|
|
t.Fatal("a dry run changed what a machine is told")
|
|
}
|
|
|
|
moved, err := runConfigMove(ctx, inv, config, true, "the move of config, run by a test", "release 57 is delivered")
|
|
if err != nil || moved.DryRun || !moved.Switched {
|
|
t.Fatalf("the move says %+v (%v)", moved, err)
|
|
}
|
|
if len(config.sets) != len(asked.Sets) {
|
|
t.Fatalf("the move set %d values, and said it would set %d", len(config.sets), len(asked.Sets))
|
|
}
|
|
if held := inv.QueryForTest(t, `select module from settings s where values <> '{}'
|
|
and module in (select name from module where manifest ? 'config')`); len(held) != 0 {
|
|
t.Fatalf("a layer of a module in the config form still holds values: %v", held)
|
|
}
|
|
after := toldEveryMachine(t, open, served, w.On)
|
|
for _, node := range fourMachines {
|
|
if after[node] != before[node] {
|
|
t.Errorf("%s is told something else after the move:\n%s\nwas:\n%s", node, after[node], before[node])
|
|
}
|
|
}
|
|
if listedAfter := effectiveEverywhere(t, open, w); !reflect.DeepEqual(listedAfter, listedBefore) {
|
|
t.Errorf("settings lists other values after the move:\n%v\nwas:\n%v", listedAfter, listedBefore)
|
|
}
|
|
// And config lists what settings listed: each module's resolved value on each machine is the value settings
|
|
// said before the move.
|
|
resolved, err := config.Resolved(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, r := range resolved {
|
|
said, listed := listedBefore[r.Node+"."+r.Module+"."+r.Key]
|
|
if r.Source == "unset" {
|
|
if listed && said != nil {
|
|
t.Errorf("%s of %s on %s is unset in config, and settings said %v", r.Key, r.Module, r.Node, said)
|
|
}
|
|
continue
|
|
}
|
|
var v any
|
|
_ = json.Unmarshal(r.Value, &v)
|
|
if !listed || !reflect.DeepEqual(v, said) {
|
|
t.Errorf("config resolves %s of %s on %s as %v, and settings said %v", r.Key, r.Module, r.Node, v, said)
|
|
}
|
|
}
|
|
|
|
// The facts snapshot carries mesh-config's values for the merge gate, as each machine's layer of each module, and
|
|
// each machine's declaration composes to the digest it did before the move.
|
|
factsAfter, err := gatherFacts(ctx, open, "2.11.17")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Machines are named in a snapshot by stand-ins of its own; each is found by what it composes.
|
|
digests := func(f snapshotFacts) ([]string, string) {
|
|
var out []string
|
|
zone := ""
|
|
for _, m := range f.Machines {
|
|
out = append(out, m.Declaration.Digest)
|
|
for _, l := range m.Settings {
|
|
if l.Module == "nodered" {
|
|
zone = compactJSON(l.Values["time-zone"])
|
|
}
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out, zone
|
|
}
|
|
was, _ := digests(factsBefore)
|
|
now, zone := digests(factsAfter)
|
|
if zone != `"Europe/Brussels"` {
|
|
t.Errorf("the facts snapshot does not carry nodered's time zone from mesh-config: %s", zone)
|
|
}
|
|
if len(now) != len(fourMachines) || now[0] == "" || !reflect.DeepEqual(now, was) {
|
|
t.Errorf("the declarations in the facts snapshot are %v after the move, %v before", now, was)
|
|
}
|
|
for _, s := range factsAfter.Settings {
|
|
if _, isConfig := map[string]bool{"betterbird": true, "claude-code": true, "power": true, "xdg": true}[s.Module]; isConfig &&
|
|
len(s.Values) > 0 {
|
|
t.Errorf("the facts snapshot still carries the whole mesh's layer of %s: %v", s.Module, s.Values)
|
|
}
|
|
}
|
|
|
|
config.sets = nil
|
|
again, err := runConfigMove(ctx, inv, config, true, "the move of config, run by a test", "again")
|
|
if err != nil || len(config.sets) != 0 || len(again.Moved) != 0 {
|
|
t.Fatalf("asked again, the move set %v and says %+v (%v)", config.sets, again, err)
|
|
}
|
|
if after := toldEveryMachine(t, open, served, w.On); !reflect.DeepEqual(after, before) {
|
|
t.Fatal("the move asked again changed what a machine is told")
|
|
}
|
|
}
|
|
|
|
// **The move refuses what it cannot move as it composes, and sets nothing** (issue 664): a layer that still holds
|
|
// placement (move-placement has not run), a key its module does not declare, and a value its declaration refuses —
|
|
// each said by name; and a value mesh-config resolves from a level no layer says (it would be laid once the switch
|
|
// is on) leaves every layer and the switch as they were.
|
|
func TestTheMoveOfConfigRefusesWhatWouldChangeAMachine(t *testing.T) {
|
|
open, _, _ := aConfiguredMesh(t)
|
|
ctx := t.Context()
|
|
readBackTries = 0
|
|
t.Cleanup(func() { readBackTries = 15 })
|
|
inv := open.inventory
|
|
config := &standInMeshConfig{open: open, values: map[string]any{}}
|
|
inv.ReadsConfigFrom(config.laid)
|
|
|
|
inv.ExecForTest(t, `update settings set values = values || '{"endpoints": {"web": {"reach": "public"}}}'
|
|
where module = 'nodered'`)
|
|
inv.ExecForTest(t, `update settings set values = values || '{"colour": "blue"}' where module = 'unifi'`)
|
|
inv.ExecForTest(t, `update settings set values = '{"font-size": 900}' where module = 'dunst'`)
|
|
answer, err := runConfigMove(ctx, inv, config, true, "a test", "release 57 is delivered")
|
|
if err == nil || answer.Switched || len(config.sets) > 0 {
|
|
t.Fatalf("the move went ahead: %+v (%v), set %v", answer, err, config.sets)
|
|
}
|
|
for _, want := range []string{"nodered on anchor still holds placement (endpoints): run move-placement first",
|
|
`unifi on anchor says "colour", which unifi does not declare`, "font-size at dunst's own value on workstation"} {
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Errorf("the refusal does not say %q: %v", want, err)
|
|
}
|
|
}
|
|
inv.ExecForTest(t, `update settings set values = values - 'endpoints' where module = 'nodered'`)
|
|
inv.ExecForTest(t, `update settings set values = values - 'colour' where module = 'unifi'`)
|
|
inv.ExecForTest(t, `update settings set values = '{"font-size": 13, "width": 500}' where module = 'dunst'`)
|
|
|
|
// Set in mesh-config before the move, on a module whose layers say nothing of it: the switch would lay it.
|
|
config.values["module.anchor.grafana."+movedKeys(func() map[string]catalogue.SettingDeclaration {
|
|
shelf, _ := inv.Catalogue(ctx)
|
|
return shelf["grafana"].Settings
|
|
}())[0]] = "set before the move"
|
|
answer, err = runConfigMove(ctx, inv, config, true, "a test", "release 57 is delivered")
|
|
if err == nil || !strings.Contains(err.Error(), "grafana on anchor resolves") || answer.Switched {
|
|
t.Fatalf("a value no layer says was laid by the switch: %+v (%v)", answer, err)
|
|
}
|
|
if moved, _ := inv.ConfigMoved(ctx); moved.Moved {
|
|
t.Fatal("the switch is on after a refused move")
|
|
}
|
|
if held := inv.QueryForTest(t, `select count(*) from settings where values <> '{}'`); held[0][0] == "0" {
|
|
t.Fatal("a refused move emptied the layers")
|
|
}
|
|
}
|
|
|
|
// The verb's command line: a dry run unless confirmed, and a confirmed move says why, recorded as a person's step of
|
|
// the release, never a repair (issue 664).
|
|
func TestTheMoveOfConfigIsADryRunUnlessConfirmed(t *testing.T) {
|
|
for _, c := range []struct {
|
|
args map[string]any
|
|
want string
|
|
}{
|
|
{map[string]any{}, "move-config --json"},
|
|
{map[string]any{"confirm": "true", "why": "release 57 is delivered"}, "move-config --json --confirm --why release 57 is delivered"},
|
|
} {
|
|
argv, err := argvFor("move-config", c.args)
|
|
if err != nil || strings.Join(argv, " ") != c.want {
|
|
t.Errorf("%v: %q %v, want %q", c.args, argv, err, c.want)
|
|
}
|
|
}
|
|
for _, args := range []map[string]any{{"confirm": "true"}, {"why": "tidy"}, {"node": "anchor"}} {
|
|
if argv, err := argvFor("move-config", args); err == nil {
|
|
t.Errorf("%v was composed as %q", args, argv)
|
|
}
|
|
}
|
|
if repairingCommand([]string{"move-config", "--json", "--confirm", "--why", "x"}) != "move-config" {
|
|
t.Error("a confirmed move through the generic verb would go unrecorded")
|
|
}
|
|
if !personsDecision(link.HandAct{Verb: "move-config"}) {
|
|
t.Error("a move of the release would count toward a healer the mesh lacks")
|
|
}
|
|
}
|
|
|
|
// ask is mesh-config's verb as the stand-in answers it: list, set, clear and propose.
|
|
func (c *standInMeshConfig) ask(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
|
str := func(k string) string { s, _ := args[k].(string); return s }
|
|
level := configLevel{Level: "mesh"}
|
|
switch {
|
|
case str("module") != "" && str("node") != "":
|
|
level = configLevel{Level: "module", Node: str("node"), Module: str("module")}
|
|
case str("node") != "":
|
|
level = configLevel{Level: "node", Node: str("node")}
|
|
}
|
|
c.asked = append(c.asked, str("action")+" "+level.valueKey(str("key")))
|
|
switch str("action") {
|
|
case "list":
|
|
resolved, err := c.Resolved(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := map[string]any{"resolved": []resolvedEntry{}, "set": []any{}}
|
|
var listed []resolvedEntry
|
|
for _, r := range resolved {
|
|
if r.Module == str("module") && (str("node") == "" || r.Node == str("node")) {
|
|
listed = append(listed, r)
|
|
}
|
|
}
|
|
out["resolved"] = listed
|
|
var set []any
|
|
for _, k := range movedKeys(c.values) {
|
|
parts := strings.SplitN(k, ".", 4)
|
|
if len(parts) == 4 && parts[0] == "module" && parts[2] == str("module") {
|
|
set = append(set, map[string]any{"level": configLevel{Level: "module", Node: parts[1], Module: parts[2]},
|
|
"key": parts[3], "value": c.values[k], "by": "a test"})
|
|
}
|
|
}
|
|
out["set"] = set
|
|
return json.Marshal(out)
|
|
case "set":
|
|
if str("why") == "" || str("by") == "" {
|
|
return nil, fmt.Errorf("a set without why or by: %v", args)
|
|
}
|
|
return nil, c.Set(ctx, level, str("key"), args["value"], str("by"), str("why"))
|
|
case "clear":
|
|
if _, had := c.values[level.valueKey(str("key"))]; !had {
|
|
return nil, fmt.Errorf("nothing is set for %s", str("key"))
|
|
}
|
|
delete(c.values, level.valueKey(str("key")))
|
|
return json.RawMessage(`{}`), nil
|
|
case "propose":
|
|
return json.RawMessage(`{"proposal":"p-1"}`), nil
|
|
}
|
|
return nil, fmt.Errorf("the stand-in does not %s", str("action"))
|
|
}
|
|
|
|
// **`settings` answers by forwarding to `config`** (novox/hq ADR 0348 §1, design 57 K5, issue 664): once the move
|
|
// has run, a module in the config form is shown from mesh-config, its values set and cleared there as its own on the
|
|
// machine (placement still beside the assignment), and proposed through it; a trusted value is refused through a
|
|
// verb, a module value for the whole mesh is refused, and a module still in the settings form keeps today's verb.
|
|
func TestTheSettingsVerbForwardsToConfigOnceTheValuesMoved(t *testing.T) {
|
|
open, w, served := aConfiguredMesh(t)
|
|
ctx := t.Context()
|
|
inv := open.inventory
|
|
readBackTries = 0
|
|
t.Cleanup(func() { readBackTries = 15 })
|
|
config := &standInMeshConfig{open: open, values: map[string]any{}}
|
|
inv.ReadsConfigFrom(config.laid)
|
|
savedRead, savedAsk := readConfigValues, configAsk
|
|
readConfigValues, configAsk = config.laid, config.ask
|
|
t.Cleanup(func() { readConfigValues, configAsk = savedRead, savedAsk })
|
|
if _, err := runConfigMove(ctx, inv, config, true, "a test", "release 57 is delivered"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
shown := stdoutOf(t, func() error { return settingsCommand(ctx, []string{"show", "nodered", "--node", "anchor"}) })
|
|
for _, want := range []string{`"time-zone": "Europe/Brussels"`, `time-zone = "Europe/Brussels" (module)`,
|
|
"mesh-config.config", `"mqtt": {`} {
|
|
if !strings.Contains(shown, want) {
|
|
t.Errorf("settings show does not say %q:\n%s", want, shown)
|
|
}
|
|
}
|
|
if strings.Contains(shown, "timeZone") {
|
|
t.Errorf("settings show still says the file's name:\n%s", shown)
|
|
}
|
|
|
|
config.asked = nil
|
|
said := stdoutOf(t, func() error {
|
|
return settingsCommand(ctx, []string{"set", "dunst", `{"font-size": 12, "width": 500}`, "--node", "workstation"})
|
|
})
|
|
if config.values["module.workstation.dunst.font-size"] != float64(12) || !strings.Contains(said, "~ font-size") ||
|
|
containsString(config.asked, "set module.workstation.dunst.width") {
|
|
t.Fatalf("settings set reached mesh-config as %v (%v):\n%s", config.values, config.asked, said)
|
|
}
|
|
told := toldEveryMachine(t, open, served, w.On)["workstation"]
|
|
if !strings.Contains(told, "12") {
|
|
t.Error("the workstation is not told the font size set through settings")
|
|
}
|
|
if err := settingsCommand(ctx, []string{"set", "dunst", `{"font-size": 12}`, "--node", "workstation"}); err == nil ||
|
|
!strings.Contains(err.Error(), "would no longer set width") {
|
|
t.Fatalf("a set dropping a value without --replace: %v", err)
|
|
}
|
|
if err := settingsCommand(ctx, []string{"set", "dunst", `{"font-size": 12}`}); err == nil ||
|
|
!strings.Contains(err.Error(), "no own value for the whole mesh") {
|
|
t.Fatalf("a module value for the whole mesh: %v", err)
|
|
}
|
|
|
|
t.Setenv(verbVar, "settings")
|
|
for _, c := range []struct{ module, node, values, key, held, why string }{
|
|
// A file root reads: refused as before the move (novox/hq issue 339).
|
|
{"hostname", "anchor", `{"hostname": "elsewhere"}`, "module.anchor.hostname.hostname", "Ace", "issue 339"},
|
|
{"messenger", "laptop", `{"desktop-machines": ["g14", "shanks"], "time-zone": "UTC"}`,
|
|
"module.laptop.messenger.time-zone", "Europe/Brussels", "issue 339"},
|
|
} {
|
|
err := settingsCommand(ctx, []string{"set", c.module, c.values, "--node", c.node})
|
|
if err == nil || !strings.Contains(err.Error(), c.why) || config.values[c.key] != c.held {
|
|
t.Fatalf("a trusted value of %s through a verb: %v, holds %v", c.module, err, config.values[c.key])
|
|
}
|
|
}
|
|
// And a trusted value no file makes the terminal's is refused as trusted, since mesh-config takes the controller's
|
|
// word for it (ADR 0348 §8).
|
|
if err := refuseTrustedThroughAVerb(ctx, inv, "messenger", "laptop", []string{"time-zone"}); err == nil ||
|
|
!strings.Contains(err.Error(), "ADR 0348 §8") {
|
|
t.Fatalf("a trusted value through a verb: %v", err)
|
|
}
|
|
if err := refuseTrustedThroughAVerb(ctx, inv, "dunst", "workstation", []string{"width"}); err != nil {
|
|
t.Fatalf("a value nobody trusts through a verb: %v", err)
|
|
}
|
|
stdoutOf(t, func() error {
|
|
return settingsCommand(ctx, []string{"set", "dunst", `{"font-size": 12, "width": 600}`, "--node", "workstation"})
|
|
})
|
|
if config.values["module.workstation.dunst.width"] != float64(600) {
|
|
t.Fatalf("a value nobody trusts was not set through a verb: %v", config.values["module.workstation.dunst.width"])
|
|
}
|
|
t.Setenv(verbVar, "")
|
|
|
|
stdoutOf(t, func() error { return settingsCommand(ctx, []string{"clear", "dunst", "--node", "workstation"}) })
|
|
for _, k := range []string{"font-size", "width"} {
|
|
if _, held := config.values["module.workstation.dunst."+k]; held {
|
|
t.Errorf("settings clear left dunst's %s on the workstation", k)
|
|
}
|
|
}
|
|
|
|
config.asked = nil
|
|
proposed := stdoutOf(t, func() error {
|
|
return settingsCommand(ctx, []string{"propose", "hostname", `{"hostname": "elsewhere"}`, "--node", "anchor"})
|
|
})
|
|
if !containsString(config.asked, "propose module.anchor.hostname.hostname") || !strings.Contains(proposed, "p-1") {
|
|
t.Fatalf("settings propose reached mesh-config as %v:\n%s", config.asked, proposed)
|
|
}
|
|
|
|
config.asked = nil
|
|
kept := stdoutOf(t, func() error { return settingsCommand(ctx, []string{"show", "kernel", "--node", "laptop"}) })
|
|
if !strings.Contains(kept, "transparent-huge-pages") || len(config.asked) > 0 {
|
|
t.Fatalf("a module in the settings form is shown as %q, asking mesh-config %v", kept, config.asked)
|
|
}
|
|
}
|
|
|
|
// **An approval for a module whose config has moved is refused, and writes nothing** (issue 830, novox/hq ADR 0348):
|
|
// a proposal raised while grafana read its layer (it had none, so the layer is as the operator was shown it) and
|
|
// approved after the move would put its values back into the layer, ReadsConfig would turn false, and the module
|
|
// would compose from emptied layers everywhere.
|
|
func TestAnApprovalRaisedBeforeTheMoveOfConfigIsRefusedAfterIt(t *testing.T) {
|
|
open, _, _ := aConfiguredMesh(t)
|
|
ctx := t.Context()
|
|
readBackTries = 0
|
|
t.Cleanup(func() { readBackTries = 15 })
|
|
inv := open.inventory
|
|
config := &standInMeshConfig{open: open, values: map[string]any{}}
|
|
inv.ReadsConfigFrom(config.laid)
|
|
shelf, err := inv.Catalogue(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
key := movedKeys(func() map[string]catalogue.SettingDeclaration { return shelf["grafana"].Settings }())[0]
|
|
values := map[string]any{key: "raised before the move"}
|
|
p := settingsProposal{Module: "grafana", Node: "anchor", Values: values, From: "g14/claude-code", At: time.Now(),
|
|
Digest: layerDigest(values), BeforeDigest: layerDigest(nil)}
|
|
if _, has, err := inv.Layer(ctx, "anchor", "grafana"); err != nil || has {
|
|
t.Fatalf("grafana has a layer on anchor before the test: %v %v", has, err)
|
|
}
|
|
if moved, err := runConfigMove(ctx, inv, config, true, "a test", "release 57 is delivered"); err != nil || !moved.Switched {
|
|
t.Fatalf("the move says %+v (%v)", moved, err)
|
|
}
|
|
if reads, err := inv.ReadsConfig(ctx, "grafana"); err != nil || !reads {
|
|
t.Fatalf("grafana does not read config after the move: %v %v", reads, err)
|
|
}
|
|
_, err = setLayerIn(open)(ctx, p, "s30", "approved by the operator")
|
|
if err == nil || !strings.Contains(err.Error(), "moved to mesh-config since the operator was shown the change") ||
|
|
!strings.Contains(err.Error(), "propose it again") {
|
|
t.Fatalf("the stale approval: %v", err)
|
|
}
|
|
if layer, has, err := inv.Layer(ctx, "anchor", "grafana"); err != nil || has && len(layer) > 0 {
|
|
t.Errorf("the refused approval wrote the layer: %v %v %v", layer, has, err)
|
|
}
|
|
if reads, err := inv.ReadsConfig(ctx, "grafana"); err != nil || !reads {
|
|
t.Errorf("the refused approval turned ReadsConfig off: %v %v", reads, err)
|
|
}
|
|
}
|
|
|
|
// **A proposal of placement only is set after the move, as before** (issue 830): forwardPropose sends it down the
|
|
// old path on purpose, so refusing it would loop the operator (refused, proposed again, refused).
|
|
func TestAPlacementOnlyApprovalRaisedBeforeTheMoveIsSetAfterIt(t *testing.T) {
|
|
open, _, _ := aConfiguredMesh(t)
|
|
ctx := t.Context()
|
|
readBackTries = 0
|
|
t.Cleanup(func() { readBackTries = 15 })
|
|
inv := open.inventory
|
|
config := &standInMeshConfig{open: open, values: map[string]any{}}
|
|
inv.ReadsConfigFrom(config.laid)
|
|
values := map[string]any{catalogue.PlacesSetting: map[string]any{"data": "/srv/grafana-data"}}
|
|
p := settingsProposal{Module: "grafana", Node: "anchor", Values: values, From: "g14/claude-code", At: time.Now(),
|
|
Digest: layerDigest(values), BeforeDigest: layerDigest(nil)}
|
|
if _, has, err := inv.Layer(ctx, "anchor", "grafana"); err != nil || has {
|
|
t.Fatalf("grafana has a layer on anchor before the test: %v %v", has, err)
|
|
}
|
|
if moved, err := runConfigMove(ctx, inv, config, true, "a test", "release 57 is delivered"); err != nil || !moved.Switched {
|
|
t.Fatalf("the move says %+v (%v)", moved, err)
|
|
}
|
|
if _, err := setLayerIn(open)(ctx, p, "s31", "approved by the operator"); err != nil {
|
|
t.Fatalf("the placement-only approval after the move: %v", err)
|
|
}
|
|
if _, has, err := inv.Layer(ctx, "anchor", "grafana"); err != nil || !has {
|
|
t.Errorf("the approval did not set the layer: %v %v", has, err)
|
|
}
|
|
if reads, err := inv.ReadsConfig(ctx, "grafana"); err != nil || !reads {
|
|
t.Errorf("the approval turned ReadsConfig off: %v %v", reads, err)
|
|
}
|
|
}
|