diff --git a/cmd/mesh-controller/modules.go b/cmd/mesh-controller/modules.go index c35b1f46..0c9070ca 100644 --- a/cmd/mesh-controller/modules.go +++ b/cmd/mesh-controller/modules.go @@ -8,6 +8,7 @@ import ( "fmt" "net" "os" + "sort" "strings" "github.com/novox/mesh-controller/internal/broker" @@ -396,7 +397,8 @@ func assignCommand(ctx context.Context, verb string, args []string) error { func settingsCommand(ctx context.Context, args []string) error { if len(args) == 0 { return errors.New("settings show [--node ] [--history], settings set " + - "[--node ] [--replace], or settings clear [--node ]") + "[--node ] [--replace], settings clear [--node ], or settings preferences " + + "[] [--node ]") } open, err := openStores(ctx) if err != nil { @@ -536,6 +538,46 @@ func settingsCommand(ctx context.Context, args []string) error { fmt.Print(describeEffective(positionals[0], where, catalogue.Effective(m, layers))) return nil + case "preferences": + // Every module's preferences, and each machine's value with its source (novox/hq ADR 0262). + if len(positionals) > 1 { + return errors.New("settings preferences [] [--node ]") + } + only := "" + if len(positionals) == 1 { + only = positionals[0] + } + entries, err := inv.Catalogued(ctx) + if err != nil { + return err + } + var listed []preferencesOf + for _, e := range entries { + m := e.Manifest + if len(m.Settings) == 0 || (only != "" && m.Module != only) { + 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) + } + if only != "" && len(listed) == 0 { + fmt.Printf("%s declares no preferences\n", only) + return nil + } + fmt.Print(describePreferences(listed)) + return nil + case "clear": if len(positionals) != 1 { return errors.New("settings clear [--node ]") @@ -547,7 +589,7 @@ func settingsCommand(ctx context.Context, args []string) error { return nil default: - return fmt.Errorf("settings has no %q; it has show, set and clear", args[0]) + return fmt.Errorf("settings has no %q; it has show, set, clear and preferences", args[0]) } } @@ -568,6 +610,58 @@ func describeEffective(module, where string, values []catalogue.SettingSource) s return b.String() } +// preferencesOf is one module's preferences and its value on each machine it is assigned to. +type preferencesOf struct { + Manifest catalogue.Manifest + Nodes []string + On map[string][]catalogue.SettingSource +} + +// describePreferences lists each module's preferences — key, default and why — and, per machine it is +// assigned to, the value and where it comes from (novox/hq ADR 0262). +func describePreferences(modules []preferencesOf) string { + if len(modules) == 0 { + return "no module declares a preference\n" + } + var b strings.Builder + for i, p := range modules { + if i > 0 { + b.WriteString("\n") + } + on := "assigned nowhere" + if len(p.Nodes) > 0 { + on = "on " + strings.Join(p.Nodes, ", ") + } + fmt.Fprintf(&b, "%s (%s)\n", p.Manifest.Module, on) + keys := make([]string, 0, len(p.Manifest.Settings)) + for k := range p.Manifest.Settings { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + d := p.Manifest.Settings[k] + def, _ := json.Marshal(d.Default) + fmt.Fprintf(&b, " %s, default %s: %s\n", k, def, d.Why) + for _, n := range p.Nodes { + for _, s := range p.On[n] { + if s.Key != k { + continue + } + v, _ := json.Marshal(s.Value) + from := s.From + if s.FromDefault { + from = catalogue.DefaultLayer + } else if s.From != catalogue.MeshWideLayer { + from = "the node" + } + fmt.Fprintf(&b, " %s: %s (%s)\n", n, v, from) + } + } + } + } + return b.String() +} + // nodeFlag is ` --node ` for a machine's layer, nothing for the whole mesh's. func nodeFlag(node string) string { if node == "" { diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index 9b4a9beb..0a233da3 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -761,6 +761,22 @@ func (a *verbArguments) commandLine() ([]string, error) { } return argv, nil case "settings": + // Every module's preferences, their defaults and each machine's value (novox/hq ADR 0262): + // the one interface for them, so no module builds a settings tool of its own. Asked for by + // name, or by naming no module, since a layer is always some module's. + if list := str("list"); list != "" || str("module") == "" { + if list != "" && list != "preferences" { + return nil, fmt.Errorf("settings lists %q only; %q is not a listing", "preferences", list) + } + argv := []string{"settings", "preferences"} + if m := str("module"); m != "" { + argv = append(argv, m) + } + if n := str("node"); n != "" { + argv = append(argv, "--node", n) + } + return argv, nil + } // `settings set|clear` at a shell (novox/hq issue 198). The values travel as an argument // because a tool has no file to hand the command; the command reads either. if err := need("module"); err != nil { diff --git a/cmd/mesh-controller/settings_effective_test.go b/cmd/mesh-controller/settings_effective_test.go index 472bd485..2f2ddf1a 100644 --- a/cmd/mesh-controller/settings_effective_test.go +++ b/cmd/mesh-controller/settings_effective_test.go @@ -1,6 +1,7 @@ package main import ( + "strings" "testing" "github.com/novox/mesh-controller/internal/catalogue" @@ -19,3 +20,52 @@ func TestSettingsSayWhereEachValueComesFrom(t *testing.T) { t.Fatalf("said\n%s\nwant\n%s", got, want) } } + +// `settings` with list "preferences" is the one listing of every module's preferences; module and node +// narrow it, and no other listing is taken. +func TestSettingsListPreferences(t *testing.T) { + for _, c := range []struct { + args map[string]any + want string + }{ + {map[string]any{"list": "preferences"}, "settings preferences"}, + {map[string]any{"list": "preferences", "module": "dunst"}, "settings preferences dunst"}, + {map[string]any{"list": "preferences", "node": "laptop"}, "settings preferences --node laptop"}, + } { + argv, err := argvFor("settings", c.args) + if err != nil || strings.Join(argv, " ") != c.want { + t.Errorf("%v: %v %v, want %s", c.args, argv, err, c.want) + } + } + if _, err := argvFor("settings", map[string]any{"list": "everything"}); err == nil { + t.Error("a listing other than preferences was taken") + } + if argv, err := argvFor("settings", map[string]any{}); err != nil || strings.Join(argv, " ") != "settings preferences" { + t.Errorf("settings naming no module is the listing: %v %v", argv, err) + } +} + +func TestPreferencesSayEachMachinesValueAndItsSource(t *testing.T) { + m := catalogue.Manifest{Module: "dunst", Settings: map[string]catalogue.SettingDeclaration{ + "font-size": {Kind: catalogue.KindPreference, Default: float64(10), Why: "readable at 100 DPI"}, + "width": {Kind: catalogue.KindPreference, Default: float64(250), Why: "forty characters"}, + }} + on := map[string][]catalogue.SettingSource{ + "laptop": catalogue.Effective(m, []catalogue.Layer{{From: "laptop", Values: map[string]any{"font-size": float64(16)}}}), + "desk": catalogue.Effective(m, []catalogue.Layer{{From: catalogue.MeshWideLayer, Values: map[string]any{"width": float64(300)}}}), + } + got := describePreferences([]preferencesOf{{Manifest: m, Nodes: []string{"desk", "laptop"}, On: on}}) + want := "dunst (on desk, laptop)\n" + + " font-size, default 10: readable at 100 DPI\n" + + " desk: 10 (default)\n" + + " laptop: 16 (the node)\n" + + " width, default 250: forty characters\n" + + " desk: 300 (the mesh)\n" + + " laptop: 250 (default)\n" + if got != want { + t.Fatalf("said\n%s\nwant\n%s", got, want) + } + if describePreferences(nil) != "no module declares a preference\n" { + t.Fatal("an empty listing") + } +} diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index f2ddd8e6..e326b231 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -251,19 +251,22 @@ var ControllerVerbs = []Verb{ {Name: "settings", Description: "Read or set what an assignment is configured with: a module's settings for the whole " + "mesh, or for one machine. Without values or clear, answers the layer as it stands — read it before setting it — " + "then every value the module gives a default or a layer sets, with where it comes from: the module's default, " + - "the mesh, or the machine (novox/hq ADR 0262); " + + "the mesh, or the machine (novox/hq ADR 0262); with list \"preferences\", or with no module, every " + + "module's preferences and each machine's value; " + "with history, the layers it replaced. Setting replaces that layer whole and answers each key it adds (+), " + "changes (~) and removes (-); a set that would remove a key is refused unless replace says it is meant " + "(novox/hq ADR 0217). Takes effect at the next push. With clear, removes the layer and the module is back to " + "what its definition says; a cleared or replaced layer is kept in the history.", Input: schema(map[string]string{ - "module": "the module's name", + "module": "the module's name; with list, only that module's", "values": "the settings as a JSON object, for set", "node": "one machine; the whole mesh when absent", "clear": "\"true\" to remove the layer instead of setting it; not with values", "replace": "\"true\": with values, the set is meant to remove the keys the layer had and it does not name", "history": "\"true\": without values or clear, the layers this one replaced, the latest first", - }, []string{"module"}, "clear", "replace", "history")}, + "list": "\"preferences\": every module's preferences — key, default and why — and the value on each " + + "machine it is assigned to with where it comes from; module and node narrow it (novox/hq ADR 0262)", + }, nil, "clear", "replace", "history")}, {Name: "command", Description: "Run one command line of the controller's own, as you would type it at its " + "shell — `node account g14 jochen`, `node show ace`, `module list` — and answer what it printed. The " + "generic verb beside the named ones (novox/hq ADR 0154): everything the binary can do, without a verb " +