package main import ( "context" "encoding/json" "fmt" "strings" "testing" "git.novox.be/novox/mesh-sdk/go/catalogue" snapshot "git.novox.be/novox/mesh-sdk/go/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() shelf, err := open.inventory.Catalogue(ctx) if err != nil { t.Fatal(err) } told := map[string]string{} for _, node := range fourMachines { plan, _, err := resolveNode(ctx, open, node) if err != nil { t.Fatalf("%s: %v", node, err) } with, err := madeAndRendered(ctx, open, node) 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).