diff --git a/cmd/mesh-controller/config.go b/cmd/mesh-controller/config.go new file mode 100644 index 00000000..cd09a289 --- /dev/null +++ b/cmd/mesh-controller/config.go @@ -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 ] [--module ] +// config set [--node [--module ]] --why +// config clear [--node [--module ]] --why +// config history [--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 ] [--module ]") + } + return configList(ctx, inv, *node, *module) + case "history": + if len(positionals) != 1 || *module != "" { + return errors.New("config history [--node ] — a module's own values are in `settings show --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 [--node [--module ]] --why , or " + + "config clear [--node [--module ]] --why ") + } + 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, ", ") +} diff --git a/cmd/mesh-controller/handacts.go b/cmd/mesh-controller/handacts.go index 147a6dca..49decc69 100644 --- a/cmd/mesh-controller/handacts.go +++ b/cmd/mesh-controller/handacts.go @@ -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)"}, diff --git a/cmd/mesh-controller/main.go b/cmd/mesh-controller/main.go index 7269d761..4ddc6143 100644 --- a/cmd/mesh-controller/main.go +++ b/cmd/mesh-controller/main.go @@ -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":