337 lines
13 KiB
Go
337 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/novox/mesh-controller/internal/catalogue"
|
|
snapshot "github.com/novox/mesh-controller/internal/facts"
|
|
"github.com/novox/mesh-controller/internal/link"
|
|
)
|
|
|
|
// 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.
|
|
|
|
// 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.
|
|
|
|
// **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.
|
|
|
|
// 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.
|
|
|
|
// **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.
|
|
|
|
// **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).
|