Add the config command: mesh, node and module values in one call, with why and history (hq issue 630)

One place to set each of the three sources over a default, refusing what is not a predefined key and a module value without a node, recorded in the hand-act log like any act by hand.
This commit is contained in:
2026-10-13 13:16:54 +00:00
parent ea4c318404
commit 0c31ca0eaf
3 changed files with 387 additions and 0 deletions
+383
View File
@@ -0,0 +1,383 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"sort"
"strings"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
)
// configCommand reads and sets config values (novox/hq issue 630). A value resolves from four sources, the
// last set winning: the module's default, mesh config, node config, and the module's own value on a node
// (its module-assignment config). Mesh and node config hold values only for the predefined keys, the
// catalogue's list; a module's own value is set on one node, never for the whole mesh.
//
// config [list] [--node <node>] [--module <module>]
// config set <key> <value> [--node <node> [--module <module>]] --why <text>
// config clear <key> [--node <node> [--module <module>]] --why <text>
// config history <key> [--node <node>]
//
// A set or clear is recorded in the hand-act log with the caller and why, kept in the history, and takes
// effect at the next push of the machines it reaches, which it names — as `settings set` does.
func configCommand(ctx context.Context, args []string) error {
sub := "list"
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
sub, args = args[0], args[1:]
}
set := flag.NewFlagSet("config", flag.ContinueOnError)
node := set.String("node", "", "one node: its node config, or with --module the module's own value there")
module := set.String("module", "", "a module: its own value on --node (module-assignment config)")
acts := addHandActFlags(set)
positionals, err := parseAround(set, args)
if err != nil {
return err
}
if *module != "" && *node == "" && sub != "list" {
return fmt.Errorf("config: %s names no node, and there is no module layer for the whole mesh — an "+
"assignment is one module on one node (novox/hq issue 630). Give node, or set mesh config for a "+
"predefined key without module. Nothing was changed", *module)
}
open, err := openStores(ctx)
if err != nil {
return err
}
defer open.Close()
inv := open.inventory
switch sub {
case "list":
if len(positionals) > 0 {
return errors.New("config list [--node <node>] [--module <module>]")
}
return configList(ctx, inv, *node, *module)
case "history":
if len(positionals) != 1 || *module != "" {
return errors.New("config history <key> [--node <node>] — a module's own values are in `settings show <module> --node <node> --history`")
}
past, err := inv.ConfigHistory(ctx, positionals[0], *node)
if err != nil {
return err
}
where := configWhere(*node, "")
if len(past) == 0 {
fmt.Printf("%s, %s: no value has been replaced\n", positionals[0], where)
return nil
}
for _, p := range past {
v, _ := json.Marshal(p.Value)
fmt.Printf("%s, %s: %s until %s (%s", positionals[0], where, v,
p.ReplacedAt.Local().Format("2006-01-02 15:04:05"), p.ReplacedBy)
if p.ReplacedWhy != "" {
fmt.Printf(": %s", p.ReplacedWhy)
}
fmt.Printf("); %s, because %s\n", orUnknown(p.SetBy), orUnknown(p.Why))
}
return nil
case "set", "clear":
default:
return fmt.Errorf("config has no %q; it has list, set, clear and history", sub)
}
// set and clear.
if err := acts.require("config " + sub); err != nil {
return err
}
why := strings.TrimSpace(*acts.why)
var key string
var value any
switch {
case sub == "set" && len(positionals) == 2:
key = positionals[0]
if err := json.Unmarshal([]byte(positionals[1]), &value); err != nil {
// A bare word is a string: `config set theme dark` means "dark".
value = positionals[1]
}
if value == nil {
return errors.New("config set takes a value; to remove one, config clear")
}
case sub == "clear" && len(positionals) == 1:
key = positionals[0]
default:
return errors.New("config set <key> <value> [--node <node> [--module <module>]] --why <text>, or " +
"config clear <key> [--node <node> [--module <module>]] --why <text>")
}
if *node != "" {
if _, err := inv.NodeByName(ctx, *node); err != nil {
return err
}
}
where := configWhere(*node, *module)
// The module's own value on one node: a preference it declares, of its type, judged as `settings set`
// judges a layer, its trusted keys refused through a verb exactly as there (novox/hq issue 339, 340).
if *module != "" {
shelf, err := inv.Catalogue(ctx)
if err != nil {
return err
}
m, known := shelf[*module]
if !known {
return fmt.Errorf("%w: %s", inventory.ErrNoSuchModule, *module)
}
p, declared := catalogue.PreferencesOf(m)[key]
if !declared {
return fmt.Errorf("config: %s declares no preference %q; config sets a module's preferences (%s) — "+
"any other setting is the settings verb's. Nothing was changed", *module, key,
preferencesOrNone(sortedPreferenceKeys(m)))
}
if value != nil {
if err := catalogue.ConfigValueProblem(fmt.Sprintf("%s's preference %q", *module, key), p.Type, value); err != nil {
return err
}
}
before, _, err := inv.Layer(ctx, *node, *module)
if err != nil {
return err
}
after := map[string]any{}
for k, v := range before {
after[k] = v
}
if value == nil {
delete(after, key)
} else {
after[key] = value
}
if err := refuseTerminalSettingsThroughAVerb(ctx, inv, before, after, *module, *node); err != nil {
return err
}
acts.record(ctx, "config", configActArgs(sub, positionals, *node, *module))
was, had, err := inv.SetModuleValue(ctx, *node, *module, key, value, setByWords()+": "+why)
if err != nil {
return err
}
sayConfigChange(key, where, value, was, had)
fmt.Printf(" takes effect at the next push: run `plan %s --diff` to see it, `push %s` to send it\n", *node, *node)
return nil
}
// Mesh config or node config: a predefined key only, judged before the act is recorded.
if value != nil {
if err := inv.JudgeConfig(ctx, key, *node, value); err != nil {
return err
}
}
acts.record(ctx, "config", configActArgs(sub, positionals, *node, ""))
before, had := configValueOf(ctx, inv, key, *node)
if value == nil {
removed, err := inv.ClearConfig(ctx, key, *node, setByWords(), why)
if err != nil {
return err
}
if !removed {
fmt.Printf("%s, %s: no value was set; nothing changed\n", key, where)
return nil
}
} else if err := inv.SetConfig(ctx, key, *node, value, setByWords(), why); err != nil {
return err
}
sayConfigChange(key, where, value, before, had)
reached, err := reachedBy(ctx, inv, key, *node)
if err != nil {
return err
}
if len(reached) == 0 {
fmt.Println(" no module assigned there falls back to it yet; nothing to push")
return nil
}
fmt.Printf(" takes effect at the next push of %s: run `push %s` to send it\n", strings.Join(reached, ", "),
strings.Join(reached, " "))
return nil
}
// configList lists the predefined keys with their mesh and node config, then each module's preferences
// with the value on each node it is assigned to and where it comes from.
func configList(ctx context.Context, inv *inventory.Inventory, node, module string) error {
if node != "" {
if _, err := inv.NodeByName(ctx, node); err != nil {
return err
}
}
list, err := inv.Predefined(ctx)
if err != nil {
return err
}
values, err := inv.Config(ctx)
if err != nil {
return err
}
var b strings.Builder
if len(list) == 0 {
fmt.Fprintf(&b, "no predefined keys: the list arrives with a build of the catalogue carrying %s\n",
catalogue.PredefinedFile)
} else {
fmt.Fprintf(&b, "predefined keys (the catalogue's %s), with mesh config and node config:\n", catalogue.PredefinedFile)
}
for _, key := range list.Keys() {
k := list[key]
fmt.Fprintf(&b, " %s (%s): %s\n", key, k.Type, k.Why)
held := false
for _, v := range values {
if v.Key != key || (node != "" && v.Node != "" && v.Node != node) {
continue
}
held = true
shown, _ := json.Marshal(v.Value)
fmt.Fprintf(&b, " %s: %s — %s, because %s\n", configWhere(v.Node, ""), shown, orUnknown(v.SetBy), v.Why)
}
if !held {
b.WriteString(" no mesh or node config: each module's default\n")
}
}
for _, key := range inventory.ConfigKeysSorted(values) {
if _, on := list[key]; on {
continue
}
for _, v := range values {
if v.Key == key {
shown, _ := json.Marshal(v.Value)
fmt.Fprintf(&b, " %s is no longer a predefined key, and %s still holds %s for it: it reaches nothing; "+
"config clear %s%s\n", key, configWhere(v.Node, ""), shown, key, nodeFlag(v.Node))
}
}
}
entries, err := inv.Catalogued(ctx)
if err != nil {
return err
}
var listed []preferencesOf
for _, e := range entries {
m := e.Manifest
if len(catalogue.PreferencesOf(m)) == 0 || (module != "" && m.Module != module) {
continue
}
if node != "" && !containsString(e.On, node) {
continue
}
p := preferencesOf{Manifest: m, On: map[string][]catalogue.SettingSource{}}
for _, n := range e.On {
if node != "" && n != node {
continue
}
p.Nodes = append(p.Nodes, n)
layers, err := inv.SettingsFor(ctx, n, m.Module)
if err != nil {
return err
}
p.On[n] = catalogue.Effective(m, layers)
}
listed = append(listed, p)
}
b.WriteString("\npreferences, and on each node the value and where it comes from (default, mesh config, " +
"node config, or the module on the node):\n")
b.WriteString(describePreferences(listed))
fmt.Print(b.String())
return nil
}
// configWhere is how a config value's place is said.
func configWhere(node, module string) string {
switch {
case module != "":
return module + " on " + node
case node != "":
return catalogue.NodeConfigLayer(node)
}
return catalogue.MeshConfigLayer
}
// configValueOf is the value mesh config (node "") or a node's config holds for a key.
func configValueOf(ctx context.Context, inv *inventory.Inventory, key, node string) (any, bool) {
values, err := inv.Config(ctx)
if err != nil {
return nil, false
}
for _, v := range values {
if v.Key == key && v.Node == node {
return v.Value, true
}
}
return nil, false
}
// reachedBy is the nodes a value of mesh config (node "") or a node's config for a key reaches: those
// running a module whose preference falls back to it.
func reachedBy(ctx context.Context, inv *inventory.Inventory, key, node string) ([]string, error) {
entries, err := inv.Catalogued(ctx)
if err != nil {
return nil, err
}
nodes := map[string]bool{}
for _, e := range entries {
if !containsString(catalogue.PredefinedNamed(e.Manifest), key) {
continue
}
for _, n := range e.On {
if node == "" || n == node {
nodes[n] = true
}
}
}
out := make([]string, 0, len(nodes))
for n := range nodes {
out = append(out, n)
}
sort.Strings(out)
return out, nil
}
func sayConfigChange(key, where string, value, before any, had bool) {
was := "nothing"
if had {
w, _ := json.Marshal(before)
was = string(w)
}
if value == nil {
fmt.Printf("%s, %s: cleared (was %s); the next source down applies\n", key, where, was)
return
}
now, _ := json.Marshal(value)
fmt.Printf("%s, %s: %s (was %s); the value it replaced is kept\n", key, where, now, was)
}
func configActArgs(sub string, positionals []string, node, module string) []string {
args := append([]string{sub}, positionals...)
if node != "" {
args = append(args, "--node", node)
}
if module != "" {
args = append(args, "--module", module)
}
return args
}
func sortedPreferenceKeys(m catalogue.Manifest) []string {
declared := catalogue.PreferencesOf(m)
keys := make([]string, 0, len(declared))
for k := range declared {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
func orUnknown(s string) string {
if strings.TrimSpace(s) == "" {
return "not kept"
}
return s
}
func preferencesOrNone(keys []string) string {
if len(keys) == 0 {
return "it declares none"
}
return strings.Join(keys, ", ")
}
+2
View File
@@ -89,6 +89,8 @@ var handActVerbs = []handActVerb{
{Verb: handActIssued, Decision: "a credential the provider's software issued, kept for its consumers (ADR 0305)"},
{Verb: "hand-act drill", Decision: "a drill is a person's deliberate test of the mesh, never a repair"},
// A person's decisions by design.
// A config value is what a person chooses the mesh's modules use (novox/hq issue 630), never a repair.
{Verb: "config", Decision: "a config value is a person's choice of what the modules use, not a repair (issue 630)"},
{Verb: "retire approve", Decision: "nothing is retired past its bound without a person (ADR 0230)"},
{Verb: "retire reject", Decision: "keeping a consumer active is a person's word (ADR 0230)"},
{Verb: "cleanup delete", Decision: "nothing retired is deleted without a person (ADR 0230)"},
+2
View File
@@ -171,6 +171,8 @@ func run() error {
return adoptCommand(ctx, args[1:])
case "settings":
return settingsCommand(ctx, args[1:])
case "config":
return configCommand(ctx, args[1:])
case "secret":
return secretCommand(ctx, args[1:])
case "operator":