Files
mesh-controller/internal/inventory/config_move_test.go
T
mesh-tools 97a2d841c7 Land feature 57's controller work on its release branch
The controller plans config, templates and moved providers' provisions,
hands bus accounts to the vault and judges the new manifest words, on the
feature's SDK and node-engine validator (feature 57, config and provisioning).
2026-10-15 21:27:33 +00:00

219 lines
8.6 KiB
Go

package inventory
import (
"context"
"errors"
"reflect"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// configured is a module in the config form: one value a mergeable file takes, and a directory to place.
func configured(t *testing.T, name string) catalogue.Manifest {
t.Helper()
m, err := catalogue.ParseManifest([]byte(`{"module": "` + name + `", "version": "1",
"config": {"level": {"type": "string", "description": "how much it says", "default": "info"}},
"resources": [{"id": "data", "type": "directory", "mode": "0750"}, {"id": "conf", "type": "file", "path": "/etc/` + name + `.json", "mode": "0640",
"content": "{\"level\": \"info\"}", "merge": "json", "takes-config": ["level"]}]}`))
if err != nil {
t.Fatal(err)
}
return m
}
// standInConfig is mesh-config's resolved values as a test holds them: by `<node>.<module>`, and each read.
type standInConfig struct {
values map[string]map[string]any
read []string
fails error
}
func (s *standInConfig) read1(_ context.Context, node, module string) (map[string]any, error) {
s.read = append(s.read, node+"."+module)
if s.fails != nil {
return nil, s.fails
}
return s.values[node+"."+module], nil
}
// aMeshForTheConfigMove is a store with a module in the config form and one still in the settings form, each with a
// layer on anchor, the config one with placement beside it, and a whole-mesh layer of the config one.
func aMeshForTheConfigMove(t *testing.T) (*Inventory, *standInConfig) {
t.Helper()
inv := ForTest(t)
ctx := t.Context()
old := catalogue.Manifest{Module: "older", Version: "1", Resources: []map[string]any{{"id": "conf", "type": "file",
"path": "/etc/older.json", "mode": "0640", "content": `{}`, "merge": "json"}}}
for _, m := range []catalogue.Manifest{configured(t, "newer"), old} {
if err := inv.RegisterModule(ctx, m, Source{}); err != nil {
t.Fatal(err)
}
}
if _, err := inv.AddNode(ctx, "anchor"); err != nil {
t.Fatal(err)
}
for _, m := range []string{"newer", "older"} {
if _, err := inv.Assign(ctx, "anchor", m); err != nil {
t.Fatal(err)
}
}
if err := inv.SetSettings(ctx, "anchor", "newer", map[string]any{"level": "debug",
"places": map[string]any{"data": map[string]any{"path": "/srv/newer"}}}); err != nil {
t.Fatal(err)
}
if err := inv.SetSettings(ctx, "", "newer", map[string]any{"level": "warn"}); err != nil {
t.Fatal(err)
}
if err := inv.SetSettings(ctx, "anchor", "older", map[string]any{"colour": "blue"}); err != nil {
t.Fatal(err)
}
config := &standInConfig{values: map[string]map[string]any{"anchor.newer": {"level": "debug"}}}
inv.ReadsConfigFrom(config.read1)
return inv, config
}
// valuesOf is each layer's From and Values, as composition reads them.
func valuesOf(layers []catalogue.Layer) map[string]map[string]any {
out := map[string]map[string]any{}
for _, l := range layers {
out[l.From] = l.Values
}
return out
}
// **The layers are read until the move of config has run** (novox/hq ADR 0348 §9, issue 664): before it, composition
// reads every module's layers and never asks mesh-config. After it, a module in the config form takes its values
// from mesh-config, its layers keep their placement and are kept whole in the history; a module in the settings form
// keeps its layer.
func TestTheLayersAreReadUntilTheMoveOfConfigHasRun(t *testing.T) {
inv, config := aMeshForTheConfigMove(t)
ctx := t.Context()
layers, err := inv.SettingsFor(ctx, "anchor", "newer")
if err != nil {
t.Fatal(err)
}
if got := valuesOf(layers); !reflect.DeepEqual(got, map[string]map[string]any{
catalogue.MeshWideLayer: {"level": "warn"}, "anchor": {"level": "debug"}}) || len(config.read) > 0 {
t.Fatalf("before the move composition reads %v and asked mesh-config %v", got, config.read)
}
if moved, err := inv.ConfigMoved(ctx); err != nil || moved.Moved {
t.Fatalf("the switch is on before the move: %+v (%v)", moved, err)
}
toMove, holding, err := inv.LayersToMove(ctx)
if err != nil || len(holding) > 0 {
t.Fatalf("the layers to move: %v, holding placement %v", err, holding)
}
var newer []LayerToMove
for _, l := range toMove {
if l.Module == "newer" {
newer = append(newer, l)
}
}
if len(newer) != 2 || newer[0].Node != "" || newer[1].Node != "anchor" {
t.Fatalf("the layers to move are %+v", toMove)
}
if err := inv.FinishConfigMove(ctx, newer, "the move of config, run by a test", "release 57 is delivered"); err != nil {
t.Fatal(err)
}
moved, err := inv.ConfigMoved(ctx)
if err != nil || !moved.Moved || moved.Why != "release 57 is delivered" || !strings.Contains(moved.By, "a test") {
t.Fatalf("the switch reads %+v (%v)", moved, err)
}
layers, err = inv.SettingsFor(ctx, "anchor", "newer")
if err != nil {
t.Fatal(err)
}
if got := valuesOf(layers); !reflect.DeepEqual(got, map[string]map[string]any{
catalogue.MeshWideLayer: {}, "anchor": {"level": "debug"}}) {
t.Fatalf("after the move composition reads %v", got)
}
if len(config.read) == 0 {
t.Fatal("after the move mesh-config was not asked")
}
for _, l := range layers {
if l.From == "anchor" && l.Placement["places"] == nil {
t.Fatalf("the placement beside the layer was lost: %+v", l)
}
}
if held := inv.QueryForTest(t, `select values = '{}' from settings where module = 'newer'`); len(held) != 2 ||
held[0][0] != "true" || held[1][0] != "true" {
t.Fatalf("the moved layers hold %v", held)
}
past := inv.QueryForTest(t, `select values->>'level', values ? 'places', why = $1 from settings_history
where module = 'newer' and replaced_by = 'moved' order by node nulls first`, configMovedWhy)
if len(past) != 2 || past[0][0] != `"warn"` || past[1][0] != `"debug"` || past[1][1] != "true" || past[0][2] != "true" {
t.Fatalf("the history holds %v", past)
}
// The module in the settings form keeps its layer, and mesh-config is not asked for it.
config.read = nil
layers, err = inv.SettingsFor(ctx, "anchor", "older")
if err != nil || !reflect.DeepEqual(valuesOf(layers), map[string]map[string]any{"anchor": {"colour": "blue"}}) ||
len(config.read) > 0 {
t.Fatalf("the settings form's layer reads %v (%v), mesh-config asked %v", valuesOf(layers), err, config.read)
}
}
// A module that holds a layer again — one moved to the config form after the move — reads its layer until the move
// runs again, so no value is read from where it is not yet; mesh-config that cannot be read, or no reader at all,
// refuses composition by name rather than composing without the values.
func TestAModuleReadsMeshConfigOnlyWhereItsValuesAre(t *testing.T) {
inv, config := aMeshForTheConfigMove(t)
ctx := t.Context()
toMove, _, err := inv.LayersToMove(ctx)
if err != nil {
t.Fatal(err)
}
var newer []LayerToMove
for _, l := range toMove {
if l.Module == "newer" {
newer = append(newer, l)
}
}
if err := inv.FinishConfigMove(ctx, newer, "a test", "moved"); err != nil {
t.Fatal(err)
}
inv.ExecForTest(t, `update settings set values = '{"level": "trace"}' where module = 'newer' and node is not null`)
layers, err := inv.SettingsFor(ctx, "anchor", "newer")
if err != nil || valuesOf(layers)["anchor"]["level"] != "trace" {
t.Fatalf("a module holding a layer again reads %v (%v)", valuesOf(layers), err)
}
inv.ExecForTest(t, `update settings set values = '{}' where module = 'newer'`)
config.fails = errors.New("the bucket is gone")
if _, err := inv.SettingsFor(ctx, "anchor", "newer"); err == nil || !strings.Contains(err.Error(), "the bucket is gone") ||
!strings.Contains(err.Error(), "newer on anchor") {
t.Fatalf("an unreadable mesh-config composes: %v", err)
}
inv.ReadsConfigFrom(nil)
if _, err := inv.SettingsFor(ctx, "anchor", "newer"); err == nil || !strings.Contains(err.Error(), "no way to read") {
t.Fatalf("no reader composes: %v", err)
}
}
// **The move ends only on the layers it read** (issue 664): a layer changed between the move reading it and its
// end refuses the whole, and nothing is emptied or switched.
func TestTheEndOfTheMoveRefusesALayerChangedSinceItWasRead(t *testing.T) {
inv, _ := aMeshForTheConfigMove(t)
ctx := t.Context()
toMove, _, err := inv.LayersToMove(ctx)
if err != nil {
t.Fatal(err)
}
if err := inv.SetSettings(ctx, "anchor", "newer", map[string]any{"level": "error"}); err != nil {
t.Fatal(err)
}
err = inv.FinishConfigMove(ctx, toMove, "a test", "moved")
if err == nil || !strings.Contains(err.Error(), "newer on anchor changed") {
t.Fatalf("a layer changed since it was read was moved: %v", 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] != "3" {
t.Fatalf("a refused move emptied a layer: %v", held)
}
}