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:
@@ -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, ", ")
|
||||
}
|
||||
@@ -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)"},
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user