Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
660649eeed | ||
|
|
c9b63f61bd | ||
|
|
3f9b935c25 | ||
|
|
0c31ca0eaf | ||
|
|
ea4c318404 | ||
|
|
df5254f308 | ||
|
|
fba6a7c454 | ||
|
|
3d16515771 |
@@ -327,6 +327,7 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
|
|||||||
}
|
}
|
||||||
result.Against = built.Against
|
result.Against = built.Against
|
||||||
result.SourceFingerprint = built.Source
|
result.SourceFingerprint = built.Source
|
||||||
|
result.Predefined = built.Predefined
|
||||||
result.Trunk, result.OnTrunk, result.Branches = built.Trunk, built.OnTrunk, built.Branches
|
result.Trunk, result.OnTrunk, result.Branches = built.Trunk, built.OnTrunk, built.Branches
|
||||||
for _, r := range built.Read {
|
for _, r := range built.Read {
|
||||||
result.Read = append(result.Read, link.ReadRepository{Repository: r.Repository, Ref: r.Ref})
|
result.Read = append(result.Read, link.ReadRepository{Repository: r.Repository, Ref: r.Ref})
|
||||||
|
|||||||
@@ -696,6 +696,20 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// **The list of predefined keys arrives with a build of the catalogue** (novox/hq issue 630): the
|
||||||
|
// builder reads predefined.json at the catalogue repository's root at the commit built, and it is kept
|
||||||
|
// before the module is registered, so a preference falling back to a key added in the same commit is
|
||||||
|
// judged against the list that adds it. Only from the catalogue: another repository names no list.
|
||||||
|
if len(result.Predefined) > 0 && isCatalogueRepository(result.Repository) {
|
||||||
|
list, err := catalogue.ParsePredefined(result.Predefined)
|
||||||
|
if err != nil {
|
||||||
|
return manifest, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: %w", result.On,
|
||||||
|
manifest.Module, short(result.Commit), err)
|
||||||
|
}
|
||||||
|
if err := inv.SetPredefined(ctx, list, result.Repository, result.Commit); err != nil {
|
||||||
|
return manifest, kept, err
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := inv.RegisterModule(ctx, manifest, recorded); err != nil {
|
if err := inv.RegisterModule(ctx, manifest, recorded); err != nil {
|
||||||
if errors.Is(err, inventory.ErrSuperseded) {
|
if errors.Is(err, inventory.ErrSuperseded) {
|
||||||
return manifest, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: %w",
|
return manifest, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: %w",
|
||||||
@@ -1025,3 +1039,16 @@ func buildLog(ctx context.Context, id string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// catalogueRepository is the repository the catalogue is: the one whose predefined.json is the list of
|
||||||
|
// predefined keys (novox/hq issue 630).
|
||||||
|
const catalogueRepository = "mesh-catalog"
|
||||||
|
|
||||||
|
// isCatalogueRepository is whether a repository, as a path on the forge or a URL, is the catalogue.
|
||||||
|
func isCatalogueRepository(repository string) bool {
|
||||||
|
trimmed := strings.TrimSuffix(strings.TrimSuffix(strings.TrimSpace(repository), "/"), ".git")
|
||||||
|
if i := strings.LastIndex(trimmed, "/"); i >= 0 {
|
||||||
|
trimmed = trimmed[i+1:]
|
||||||
|
}
|
||||||
|
return trimmed == catalogueRepository
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error {
|
|||||||
shelf := catalogue.Shelf{}
|
shelf := catalogue.Shelf{}
|
||||||
faulted := map[string]bool{}
|
faulted := map[string]bool{}
|
||||||
failed := 0
|
failed := 0
|
||||||
|
// The list of predefined keys a preference may fall back to (novox/hq issue 630): the predefined.json
|
||||||
|
// at the root of the repository the manifests are in, found above the first of them.
|
||||||
|
list, listAt, listErr := predefinedAbove(paths[0])
|
||||||
|
if listErr != nil {
|
||||||
|
fmt.Fprintf(out, "%s: %v\n", listAt, listErr)
|
||||||
|
failed++
|
||||||
|
}
|
||||||
for _, path := range paths {
|
for _, path := range paths {
|
||||||
raw, err := os.ReadFile(path)
|
raw, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -92,6 +99,20 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error {
|
|||||||
failed += len(named)
|
failed += len(named)
|
||||||
faulted[m.Module] = true
|
faulted[m.Module] = true
|
||||||
}
|
}
|
||||||
|
// A preference falls back only to a key on the list, with its type (novox/hq issue 630).
|
||||||
|
if named := catalogue.PredefinedNamed(m); len(named) > 0 {
|
||||||
|
if listAt == "" {
|
||||||
|
fmt.Fprintf(out, "note: %s: its preferences fall back to %s, and no %s was found above it; "+
|
||||||
|
"registration judges them against the list the mesh holds\n", path, strings.Join(named, ", "),
|
||||||
|
catalogue.PredefinedFile)
|
||||||
|
} else if wrong := catalogue.PredefinedProblems(m, list); len(wrong) > 0 {
|
||||||
|
for _, p := range wrong {
|
||||||
|
fmt.Fprintf(out, "%s: %s\n", path, p)
|
||||||
|
}
|
||||||
|
failed += len(wrong)
|
||||||
|
faulted[m.Module] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
shelf[m.Module] = m
|
shelf[m.Module] = m
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,3 +306,27 @@ func manifestsUnder(dir string) ([]string, error) {
|
|||||||
sort.Strings(found)
|
sort.Strings(found)
|
||||||
return found, err
|
return found, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// predefinedAbove reads the predefined.json found in the directory of a manifest or one above it, up to
|
||||||
|
// the repository's root (the directory holding .git). The path it was found at is "" when there is none.
|
||||||
|
func predefinedAbove(manifest string) (catalogue.Predefined, string, error) {
|
||||||
|
dir, err := filepath.Abs(filepath.Dir(manifest))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", nil
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
at := filepath.Join(dir, catalogue.PredefinedFile)
|
||||||
|
if raw, err := os.ReadFile(at); err == nil {
|
||||||
|
list, err := catalogue.ParsePredefined(raw)
|
||||||
|
return list, at, err
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
|
||||||
|
return nil, "", nil
|
||||||
|
}
|
||||||
|
up := filepath.Dir(dir)
|
||||||
|
if up == dir {
|
||||||
|
return nil, "", nil
|
||||||
|
}
|
||||||
|
dir = up
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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, ", ")
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The config verb's line: a listing without key, a set or clear with why, and a module value refused
|
||||||
|
// without a node (novox/hq issue 630).
|
||||||
|
func TestTheConfigVerbsLine(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
args map[string]any
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{map[string]any{}, "config list"},
|
||||||
|
{map[string]any{"node": "g14", "module": "dunst"}, "config list --node g14 --module dunst"},
|
||||||
|
{map[string]any{"key": "font-size", "value": "12", "why": "bigger"}, "config set font-size 12 --why bigger"},
|
||||||
|
{map[string]any{"key": "font-size", "value": "13", "node": "g14", "why": "dense"},
|
||||||
|
"config set font-size 13 --node g14 --why dense"},
|
||||||
|
{map[string]any{"key": "font-size", "value": "14", "node": "g14", "module": "dunst", "why": "x"},
|
||||||
|
"config set font-size 14 --node g14 --module dunst --why x"},
|
||||||
|
{map[string]any{"key": "font-size", "clear": "true", "node": "g14", "why": "back"},
|
||||||
|
"config clear font-size --node g14 --why back"},
|
||||||
|
{map[string]any{"key": "font-size", "history": "true"}, "config history font-size"},
|
||||||
|
} {
|
||||||
|
argv, err := argvFor("config", c.args)
|
||||||
|
if err != nil || strings.Join(argv, " ") != c.want {
|
||||||
|
t.Errorf("%v: %v %v, want %s", c.args, argv, err, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for want, args := range map[string]map[string]any{
|
||||||
|
"no module layer for the whole mesh": {"key": "font-size", "value": "12", "module": "dunst", "why": "x"},
|
||||||
|
"why": {"key": "font-size", "value": "12"},
|
||||||
|
"names its key": {"value": "12"},
|
||||||
|
"not both": {"key": "font-size", "value": "12", "clear": "true", "why": "x"},
|
||||||
|
} {
|
||||||
|
if _, err := argvFor("config", args); err == nil || !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("%v: %v, want %q", args, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// End to end through the verb, as the operator's acceptance reads it: cleared → the default; mesh font-size,
|
||||||
|
// then the node's, then dunst's own on the node — each in the node's dunstrc as composed (novox/hq issue 630).
|
||||||
|
func TestConfigReachesTheComposedFileFromEachSource(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
if err := open.inventory.SetPredefined(ctx, catalogue.Predefined{
|
||||||
|
"font-size": {Type: catalogue.TypeNumber, Why: "the size text is drawn at"}}, "", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
register(t, open, catalogue.Manifest{Module: "dunst", Version: "1",
|
||||||
|
Preferences: map[string]catalogue.Preference{
|
||||||
|
"font-size": {Type: catalogue.TypeNumber, Default: float64(10), Predefined: "font-size", Why: "100 DPI"},
|
||||||
|
},
|
||||||
|
Resources: []map[string]any{{"id": "dunstrc", "type": "file", "path": "/etc/dunst/dunstrc", "mode": "0644", "trusted": false,
|
||||||
|
"content": "font = Inter ${setting:font-size}\n"}}})
|
||||||
|
for _, n := range []string{"laptop", "anchor"} {
|
||||||
|
if _, err := assign(ctx, open, n, "dunst"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dunstrc := func(node string) string {
|
||||||
|
t.Helper()
|
||||||
|
gens, err := generators(ctx, open)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
declared, problems, err := composedAndValidated(ctx, open, node, gens, Allocating)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s does not compose: %v %v", node, err, problems)
|
||||||
|
}
|
||||||
|
for _, r := range declared.Resources {
|
||||||
|
if r["path"] == "/etc/dunst/dunstrc" {
|
||||||
|
return fmt.Sprint(r["content"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("%s has no dunstrc: %v", node, problems)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
want := func(node, size string) {
|
||||||
|
t.Helper()
|
||||||
|
if got := dunstrc(node); got != "font = Inter "+size+"\n" {
|
||||||
|
t.Fatalf("%s's dunstrc: %q, want size %s", node, got, size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
verb := func(args map[string]any) error {
|
||||||
|
t.Helper()
|
||||||
|
return throughVerb(t, "config", args)
|
||||||
|
}
|
||||||
|
want("laptop", "10")
|
||||||
|
|
||||||
|
if err := verb(map[string]any{"key": "font-size", "value": "11", "why": "a larger default"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want("laptop", "11")
|
||||||
|
want("anchor", "11")
|
||||||
|
if err := verb(map[string]any{"key": "font-size", "value": "13", "node": "laptop", "why": "a dense screen"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want("laptop", "13")
|
||||||
|
want("anchor", "11")
|
||||||
|
if err := verb(map[string]any{"key": "font-size", "value": "15", "node": "laptop", "module": "dunst",
|
||||||
|
"why": "dunst alone larger"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want("laptop", "15")
|
||||||
|
|
||||||
|
listing := stdoutOf(t, func() error { return verb(map[string]any{}) })
|
||||||
|
for _, said := range []string{"font-size (number): the size text is drawn at", "mesh config: 11",
|
||||||
|
"node config (laptop): 13", "laptop: 15 (dunst on laptop)", "anchor: 11 (mesh config)",
|
||||||
|
"falls back to the predefined key font-size"} {
|
||||||
|
if !strings.Contains(listing, said) {
|
||||||
|
t.Errorf("the listing does not say %q:\n%s", said, listing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleared, each falls back to the next source down.
|
||||||
|
for _, step := range []struct {
|
||||||
|
args map[string]any
|
||||||
|
size string
|
||||||
|
}{
|
||||||
|
{map[string]any{"key": "font-size", "clear": "true", "node": "laptop", "module": "dunst", "why": "x"}, "13"},
|
||||||
|
{map[string]any{"key": "font-size", "clear": "true", "node": "laptop", "why": "x"}, "11"},
|
||||||
|
{map[string]any{"key": "font-size", "clear": "true", "why": "x"}, "10"},
|
||||||
|
} {
|
||||||
|
if err := verb(step.args); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want("laptop", step.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refused: a key not on the list for mesh config, a module value without a node, a type not the key's.
|
||||||
|
if err := verb(map[string]any{"key": "colour", "value": "blue", "why": "x"}); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "predefined") {
|
||||||
|
t.Fatalf("mesh config for a key not on the list: %v", err)
|
||||||
|
}
|
||||||
|
if err := runLine(t, []string{"config", "set", "font-size", "12", "--module", "dunst", "--why", "x"}); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "no module layer for the whole mesh") {
|
||||||
|
t.Fatalf("a module value without a node: %v", err)
|
||||||
|
}
|
||||||
|
if err := verb(map[string]any{"key": "font-size", "value": `"large"`, "node": "laptop", "module": "dunst",
|
||||||
|
"why": "x"}); err == nil || !strings.Contains(err.Error(), "is a number") {
|
||||||
|
t.Fatalf("a string for a number: %v", err)
|
||||||
|
}
|
||||||
|
if err := verb(map[string]any{"key": "nothing", "value": "1", "node": "laptop", "module": "dunst",
|
||||||
|
"why": "x"}); err == nil || !strings.Contains(err.Error(), "declares no preference") {
|
||||||
|
t.Fatalf("a module value for a key it does not declare: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The module check reads the catalogue's predefined.json at the repository's root and refuses a preference
|
||||||
|
// falling back to a key not on it (novox/hq issue 630).
|
||||||
|
func TestTheModuleCheckJudgesPreferencesAgainstTheCataloguesList(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
for _, dir := range []string{".git", "modules/dunst"} {
|
||||||
|
if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
manifest := filepath.Join(root, "modules/dunst/module.json")
|
||||||
|
write := func(path, body string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write(manifest, `{"module": "dunst", "version": "1",
|
||||||
|
"preferences": {"font-size": {"type": "number", "default": 10, "predefined": "font-size", "why": "100 DPI"}},
|
||||||
|
"resources": [{"id": "rc", "type": "file", "path": "/etc/rc", "mode": "0644", "trusted": false,
|
||||||
|
"content": "font ${setting:font-size}"}]}`)
|
||||||
|
write(filepath.Join(root, "predefined.json"), `{"theme": {"type": "string", "why": "light or dark"}}`)
|
||||||
|
var out strings.Builder
|
||||||
|
if err := moduleCheck([]string{manifest}, &out); err == nil || !strings.Contains(out.String(), "not on the catalogue's list") {
|
||||||
|
t.Fatalf("a key not on the list passed: %v\n%s", err, out.String())
|
||||||
|
}
|
||||||
|
write(filepath.Join(root, "predefined.json"), `{"font-size": {"type": "number", "why": "the size text is drawn at"}}`)
|
||||||
|
out.Reset()
|
||||||
|
if err := moduleCheck([]string{manifest}, &out); err != nil {
|
||||||
|
t.Fatalf("a key on the list was refused: %v\n%s", err, out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -263,6 +263,37 @@ func gatherFacts(ctx context.Context, open *stores, busVersion string) (snapshot
|
|||||||
f.Controller.Commit = c.Commit
|
f.Controller.Commit = c.Commit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The list of predefined keys, and mesh and node config (novox/hq issue 630).
|
||||||
|
predefined, err := inv.Predefined(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return snapshot.Facts{}, err
|
||||||
|
}
|
||||||
|
for key, k := range predefined {
|
||||||
|
if f.Predefined == nil {
|
||||||
|
f.Predefined = map[string]snapshot.PredefinedKey{}
|
||||||
|
}
|
||||||
|
f.Predefined[key] = snapshot.PredefinedKey{Type: k.Type, Why: k.Why}
|
||||||
|
}
|
||||||
|
config, err := inv.Config(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return snapshot.Facts{}, err
|
||||||
|
}
|
||||||
|
nodeConfig := map[string]map[string]any{}
|
||||||
|
meshConfig := map[string]any{}
|
||||||
|
for _, c := range config {
|
||||||
|
if c.Node == "" {
|
||||||
|
meshConfig[c.Key] = c.Value
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if nodeConfig[c.Node] == nil {
|
||||||
|
nodeConfig[c.Node] = map[string]any{}
|
||||||
|
}
|
||||||
|
nodeConfig[c.Node][c.Key] = c.Value
|
||||||
|
}
|
||||||
|
if len(meshConfig) > 0 {
|
||||||
|
f.Config = scrub.Values(meshConfig)
|
||||||
|
}
|
||||||
|
|
||||||
gens, gensErr := generators(ctx, open)
|
gens, gensErr := generators(ctx, open)
|
||||||
hostShelf := shelf[hostModule]
|
hostShelf := shelf[hostModule]
|
||||||
meshWide := map[string]bool{}
|
meshWide := map[string]bool{}
|
||||||
@@ -280,6 +311,9 @@ func gatherFacts(ctx context.Context, open *stores, busVersion string) (snapshot
|
|||||||
if n.HostVersion != "" {
|
if n.HostVersion != "" {
|
||||||
engines[n.HostVersion] = true
|
engines[n.HostVersion] = true
|
||||||
}
|
}
|
||||||
|
if values := nodeConfig[n.Name]; len(values) > 0 {
|
||||||
|
m.Config = scrub.Values(values)
|
||||||
|
}
|
||||||
m.System = systemOf(hostShelf, n.HostVersion)
|
m.System = systemOf(hostShelf, n.HostVersion)
|
||||||
m.Libc = libcOf(m.System)
|
m.Libc = libcOf(m.System)
|
||||||
reported, err := inv.DescribedOf(ctx, n.Name)
|
reported, err := inv.DescribedOf(ctx, n.Name)
|
||||||
@@ -345,6 +379,10 @@ func gatherFacts(ctx context.Context, open *stores, busVersion string) (snapshot
|
|||||||
return snapshot.Facts{}, err
|
return snapshot.Facts{}, err
|
||||||
}
|
}
|
||||||
for _, layer := range layers {
|
for _, layer := range layers {
|
||||||
|
if layer.Config {
|
||||||
|
// Mesh and node config are said once, beside the machine and the mesh (novox/hq issue 630).
|
||||||
|
continue
|
||||||
|
}
|
||||||
if layer.From == catalogue.MeshWideLayer {
|
if layer.From == catalogue.MeshWideLayer {
|
||||||
if !meshWide[module] {
|
if !meshWide[module] {
|
||||||
meshWide[module] = true
|
meshWide[module] = true
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ func aMeshWithSecrets(t *testing.T) (*stores, []string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
secrets := []string{"Hunter2-Is-Not-A-Password-9f8e7d", "0123456789abcdefABCDEF0123456789zz"}
|
secrets := []string{"Hunter2-Is-Not-A-Password-9f8e7d", "0123456789abcdefABCDEF0123456789zz"}
|
||||||
if err := open.inventory.SetSettings(ctx, "", "files", map[string]any{
|
if err := open.inventory.KeepSettings(ctx, "", "files", map[string]any{
|
||||||
"admin_password": secrets[0], "upstream": "10.77.0.9", "hub": "anchor"}); err != nil {
|
"admin_password": secrets[0], "upstream": "10.77.0.9", "hub": "anchor"}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ var handActVerbs = []handActVerb{
|
|||||||
{Verb: handActIssued, Decision: "a credential the provider's software issued, kept for its consumers (ADR 0305)"},
|
{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"},
|
{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 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 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: "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)"},
|
{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:])
|
return adoptCommand(ctx, args[1:])
|
||||||
case "settings":
|
case "settings":
|
||||||
return settingsCommand(ctx, args[1:])
|
return settingsCommand(ctx, args[1:])
|
||||||
|
case "config":
|
||||||
|
return configCommand(ctx, args[1:])
|
||||||
case "secret":
|
case "secret":
|
||||||
return secretCommand(ctx, args[1:])
|
return secretCommand(ctx, args[1:])
|
||||||
case "operator":
|
case "operator":
|
||||||
|
|||||||
@@ -296,6 +296,24 @@ func judgeChange(ctx context.Context, in mergeCheckInput) (mergeVerdict, error)
|
|||||||
}
|
}
|
||||||
v.Failures = append(v.Failures, failures...)
|
v.Failures = append(v.Failures, failures...)
|
||||||
}
|
}
|
||||||
|
// The list of predefined keys a head's catalogue tree carries, which the change composes with
|
||||||
|
// (novox/hq issue 630); the mesh's own list otherwise.
|
||||||
|
var changedList catalogue.Predefined
|
||||||
|
for _, h := range heads {
|
||||||
|
if h.Tree == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(filepath.Join(h.Tree, catalogue.PredefinedFile))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
list, err := catalogue.ParsePredefined(raw)
|
||||||
|
if err != nil {
|
||||||
|
v.Failures = append(v.Failures, err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
changedList = list
|
||||||
|
}
|
||||||
if len(in.group) > 0 {
|
if len(in.group) > 0 {
|
||||||
say("a delivery group: %d head(s) composed together", len(heads))
|
say("a delivery group: %d head(s) composed together", len(heads))
|
||||||
}
|
}
|
||||||
@@ -319,7 +337,7 @@ func judgeChange(ctx context.Context, in mergeCheckInput) (mergeVerdict, error)
|
|||||||
|
|
||||||
// Both compositions, each in a store of its own.
|
// Both compositions, each in a store of its own.
|
||||||
say("composing every machine as the mesh is")
|
say("composing every machine as the mesh is")
|
||||||
before, _, notes, err := composeEveryMachine(ctx, in, base, sources, nil)
|
before, _, notes, err := composeEveryMachine(ctx, in, base, sources, nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return v, fmt.Errorf("the mesh as the snapshot says it is could not be raised: %w", err)
|
return v, fmt.Errorf("the mesh as the snapshot says it is could not be raised: %w", err)
|
||||||
}
|
}
|
||||||
@@ -332,7 +350,7 @@ func judgeChange(ctx context.Context, in mergeCheckInput) (mergeVerdict, error)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.Strings(added)
|
sort.Strings(added)
|
||||||
after, tried, notes, err := composeEveryMachine(ctx, in, change, sources, added)
|
after, tried, notes, err := composeEveryMachine(ctx, in, change, sources, added, changedList)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return v, fmt.Errorf("the mesh with the change could not be raised: %w", err)
|
return v, fmt.Errorf("the mesh with the change could not be raised: %w", err)
|
||||||
}
|
}
|
||||||
@@ -748,13 +766,13 @@ func standInBuild(m catalogue.Manifest) (catalogue.Manifest, error) {
|
|||||||
// grant to compose — and answers each machine's composition. Notes are what of the snapshot could not
|
// grant to compose — and answers each machine's composition. Notes are what of the snapshot could not
|
||||||
// be raised: a refusal to register a module, keep a setting or a pin.
|
// be raised: a refusal to register a module, keep a setting or a pin.
|
||||||
func composeEveryMachine(ctx context.Context, in mergeCheckInput, shelf map[string]catalogue.Manifest,
|
func composeEveryMachine(ctx context.Context, in mergeCheckInput, shelf map[string]catalogue.Manifest,
|
||||||
sources map[string]inventory.Source, trials []string) (map[string]mergeComposed, map[string]mergeTrial, []string, error) {
|
sources map[string]inventory.Source, trials []string, list catalogue.Predefined) (map[string]mergeComposed, map[string]mergeTrial, []string, error) {
|
||||||
open, drop, err := throwawayStores(ctx, in.admin)
|
open, drop, err := throwawayStores(ctx, in.admin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
defer drop()
|
defer drop()
|
||||||
notes, err := raiseFromFacts(ctx, open, in.facts, shelf, sources)
|
notes, err := raiseFromFacts(ctx, open, in.facts, shelf, sources, list)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, notes, err
|
return nil, nil, notes, err
|
||||||
}
|
}
|
||||||
@@ -950,9 +968,22 @@ func throwawayStores(ctx context.Context, admin string) (*stores, func(), error)
|
|||||||
// for each standing in for the one it holds, its assignments, the seats it holds, its pins and its
|
// for each standing in for the one it holds, its assignments, the seats it holds, its pins and its
|
||||||
// settings, and a stand-in for every secret a person gave.
|
// settings, and a stand-in for every secret a person gave.
|
||||||
func raiseFromFacts(ctx context.Context, open *stores, f snapshot.Facts, shelf map[string]catalogue.Manifest,
|
func raiseFromFacts(ctx context.Context, open *stores, f snapshot.Facts, shelf map[string]catalogue.Manifest,
|
||||||
sources map[string]inventory.Source) ([]string, error) {
|
sources map[string]inventory.Source, list catalogue.Predefined) ([]string, error) {
|
||||||
inv := open.inventory
|
inv := open.inventory
|
||||||
var notes []string
|
var notes []string
|
||||||
|
// The list of predefined keys before any module, which registration judges its preferences against
|
||||||
|
// (novox/hq issue 630): the change's own when its catalogue tree carries one, the mesh's otherwise.
|
||||||
|
if list == nil && len(f.Predefined) > 0 {
|
||||||
|
list = catalogue.Predefined{}
|
||||||
|
for key, k := range f.Predefined {
|
||||||
|
list[key] = catalogue.PredefinedKey{Type: k.Type, Why: k.Why}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(list) > 0 {
|
||||||
|
if err := inv.SetPredefined(ctx, list, "", ""); err != nil {
|
||||||
|
notes = append(notes, fmt.Sprintf("the list of predefined keys is not kept: %s", oneLine(err.Error())))
|
||||||
|
}
|
||||||
|
}
|
||||||
names := make([]string, 0, len(shelf))
|
names := make([]string, 0, len(shelf))
|
||||||
for name := range shelf {
|
for name := range shelf {
|
||||||
names = append(names, name)
|
names = append(names, name)
|
||||||
@@ -1045,6 +1076,20 @@ func raiseFromFacts(ctx context.Context, open *stores, f snapshot.Facts, shelf m
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Mesh and node config, as the mesh holds them (novox/hq issue 630).
|
||||||
|
for _, key := range sortedAnyKeys(f.Config) {
|
||||||
|
if err := inv.SetConfig(ctx, key, "", f.Config[key], "the facts snapshot", "raised from the facts snapshot"); err != nil {
|
||||||
|
notes = append(notes, fmt.Sprintf("the mesh config of %s is not kept: %s", key, oneLine(err.Error())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range f.Machines {
|
||||||
|
for _, key := range sortedAnyKeys(m.Config) {
|
||||||
|
if err := inv.SetConfig(ctx, key, m.Name, m.Config[key], "the facts snapshot", "raised from the facts snapshot"); err != nil {
|
||||||
|
notes = append(notes, fmt.Sprintf("%s's node config of %s is not kept: %s", m.Described(), key,
|
||||||
|
oneLine(err.Error())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, s := range f.Settings {
|
for _, s := range f.Settings {
|
||||||
if err := inv.KeepSettings(ctx, "", s.Module, standInPaths(s.Values)); err != nil {
|
if err := inv.KeepSettings(ctx, "", s.Module, standInPaths(s.Values)); err != nil {
|
||||||
notes = append(notes, fmt.Sprintf("the mesh's settings of %s are not kept: %s", s.Module, oneLine(err.Error())))
|
notes = append(notes, fmt.Sprintf("the mesh's settings of %s are not kept: %s", s.Module, oneLine(err.Error())))
|
||||||
@@ -1312,3 +1357,13 @@ func firstOr(items, otherwise []string) string {
|
|||||||
}
|
}
|
||||||
return "it says nothing more"
|
return "it says nothing more"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sortedAnyKeys is a map's keys, sorted.
|
||||||
|
func sortedAnyKeys(in map[string]any) []string {
|
||||||
|
keys := make([]string, 0, len(in))
|
||||||
|
for k := range in {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|||||||
@@ -571,18 +571,14 @@ func settingsCommand(ctx context.Context, args []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m, ok := known[positionals[0]]
|
m, ok := known[positionals[0]]
|
||||||
if !ok || len(m.Settings) == 0 {
|
if !ok || len(catalogue.PreferencesOf(m)) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var layers []catalogue.Layer
|
var layers []catalogue.Layer
|
||||||
if *node != "" {
|
if *node != "" {
|
||||||
if mesh, has, err := inv.Layer(ctx, "", positionals[0]); err != nil {
|
// Every source on the node: mesh config, node config, then the module's own (novox/hq issue 630).
|
||||||
|
if layers, err = inv.SettingsFor(ctx, *node, positionals[0]); err != nil {
|
||||||
return err
|
return err
|
||||||
} else if has {
|
|
||||||
layers = append(layers, catalogue.Layer{From: catalogue.MeshWideLayer, Values: mesh})
|
|
||||||
}
|
|
||||||
if has {
|
|
||||||
layers = append(layers, catalogue.Layer{From: *node, Values: values})
|
|
||||||
}
|
}
|
||||||
} else if has {
|
} else if has {
|
||||||
layers = append(layers, catalogue.Layer{From: catalogue.MeshWideLayer, Values: values})
|
layers = append(layers, catalogue.Layer{From: catalogue.MeshWideLayer, Values: values})
|
||||||
@@ -612,7 +608,7 @@ func settingsCommand(ctx context.Context, args []string) error {
|
|||||||
var listed []preferencesOf
|
var listed []preferencesOf
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
m := e.Manifest
|
m := e.Manifest
|
||||||
if len(m.Settings) == 0 || (only != "" && m.Module != only) {
|
if len(catalogue.PreferencesOf(m)) == 0 || (only != "" && m.Module != only) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if *node != "" && !containsString(e.On, *node) {
|
if *node != "" && !containsString(e.On, *node) {
|
||||||
@@ -682,7 +678,11 @@ func describeEffective(module, where string, values []catalogue.SettingSource) s
|
|||||||
fmt.Fprintf(&b, "%s on %s, every value and where it comes from:\n", module, where)
|
fmt.Fprintf(&b, "%s on %s, every value and where it comes from:\n", module, where)
|
||||||
for _, v := range values {
|
for _, v := range values {
|
||||||
value, _ := json.Marshal(v.Value)
|
value, _ := json.Marshal(v.Value)
|
||||||
fmt.Fprintf(&b, " %s = %s (%s", v.Key, value, v.From)
|
from := v.From
|
||||||
|
if v.From == where && where != "the whole mesh" {
|
||||||
|
from = valueSource(module, where, v)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, " %s = %s (%s", v.Key, value, from)
|
||||||
if v.HasDefault && !v.FromDefault {
|
if v.HasDefault && !v.FromDefault {
|
||||||
d, _ := json.Marshal(v.Default)
|
d, _ := json.Marshal(v.Default)
|
||||||
fmt.Fprintf(&b, "; the default is %s", d)
|
fmt.Fprintf(&b, "; the default is %s", d)
|
||||||
@@ -723,28 +723,27 @@ func describePreferences(modules []preferencesOf) string {
|
|||||||
on = "on " + strings.Join(p.Nodes, ", ")
|
on = "on " + strings.Join(p.Nodes, ", ")
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&b, "%s (%s)\n", p.Manifest.Module, on)
|
fmt.Fprintf(&b, "%s (%s)\n", p.Manifest.Module, on)
|
||||||
keys := make([]string, 0, len(p.Manifest.Settings))
|
declared := catalogue.PreferencesOf(p.Manifest)
|
||||||
for k := range p.Manifest.Settings {
|
keys := make([]string, 0, len(declared))
|
||||||
|
for k := range declared {
|
||||||
keys = append(keys, k)
|
keys = append(keys, k)
|
||||||
}
|
}
|
||||||
sort.Strings(keys)
|
sort.Strings(keys)
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
d := p.Manifest.Settings[k]
|
d := declared[k]
|
||||||
def, _ := json.Marshal(d.Default)
|
def, _ := json.Marshal(d.Default)
|
||||||
fmt.Fprintf(&b, " %s, default %s: %s\n", k, def, d.Why)
|
falls := ""
|
||||||
|
if d.Predefined != "" {
|
||||||
|
falls = ", falls back to the predefined key " + d.Predefined
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, " %s, default %s%s: %s\n", k, def, falls, d.Why)
|
||||||
for _, n := range p.Nodes {
|
for _, n := range p.Nodes {
|
||||||
for _, s := range p.On[n] {
|
for _, s := range p.On[n] {
|
||||||
if s.Key != k {
|
if s.Key != k {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
v, _ := json.Marshal(s.Value)
|
v, _ := json.Marshal(s.Value)
|
||||||
from := s.From
|
fmt.Fprintf(&b, " %s: %s (%s)\n", n, v, valueSource(p.Manifest.Module, n, s))
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -752,6 +751,18 @@ func describePreferences(modules []preferencesOf) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// valueSource is where a value on a node comes from, as the four sources are said (novox/hq issue 630):
|
||||||
|
// "default", "mesh config", "node config (<node>)", or "<module> on <node>" for the module's own value.
|
||||||
|
func valueSource(module, node string, s catalogue.SettingSource) string {
|
||||||
|
switch {
|
||||||
|
case s.FromDefault:
|
||||||
|
return catalogue.DefaultLayer
|
||||||
|
case s.From == catalogue.MeshConfigLayer, s.From == catalogue.NodeConfigLayer(node), s.From == catalogue.MeshWideLayer:
|
||||||
|
return s.From
|
||||||
|
}
|
||||||
|
return module + " on " + node
|
||||||
|
}
|
||||||
|
|
||||||
// nodeFlag is ` --node <node>` for a machine's layer, nothing for the whole mesh's.
|
// nodeFlag is ` --node <node>` for a machine's layer, nothing for the whole mesh's.
|
||||||
func nodeFlag(node string) string {
|
func nodeFlag(node string) string {
|
||||||
if node == "" {
|
if node == "" {
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ func checkWait(module, machine string, w inventory.Wait, f operatorWaitFacts) er
|
|||||||
case w.Provision != "":
|
case w.Provision != "":
|
||||||
return checkProvisionWait(module, machine, w, m, f)
|
return checkProvisionWait(module, machine, w, m, f)
|
||||||
case w.Setting != "":
|
case w.Setting != "":
|
||||||
if _, declared := m.Settings[w.Setting]; !declared {
|
if !catalogue.DeclaresPreference(m, w.Setting) {
|
||||||
return fmt.Errorf("says it waits for the setting %s, which %s does not declare", w.Setting, module)
|
return fmt.Errorf("says it waits for the setting %s, which %s does not declare", w.Setting, module)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ func (r *rootReader) executeServed(ctx context.Context, machine string) (bool, s
|
|||||||
}
|
}
|
||||||
holders++
|
holders++
|
||||||
var setting *any
|
var setting *any
|
||||||
if _, declared := e.Manifest.Settings[loginShellVerb]; declared {
|
if catalogue.DeclaresPreference(e.Manifest, loginShellVerb) {
|
||||||
layers, err := r.settings(ctx, machine, e.Manifest.Module)
|
layers, err := r.settings(ctx, machine, e.Manifest.Module)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
served = true
|
served = true
|
||||||
|
|||||||
@@ -542,6 +542,11 @@ const (
|
|||||||
|
|
||||||
// proposeCommand is `settings propose`, on this controller's stores and bus.
|
// proposeCommand is `settings propose`, on this controller's stores and bus.
|
||||||
func proposeCommand(ctx context.Context, module, node, valuesArg string, clear, replace bool) error {
|
func proposeCommand(ctx context.Context, module, node, valuesArg string, clear, replace bool) error {
|
||||||
|
if node == "" && !clear {
|
||||||
|
// There is no module layer for the whole mesh (novox/hq issue 630); clearing one left over still works.
|
||||||
|
return fmt.Errorf("%s: there is no module layer for the whole mesh — an assignment is one module on one "+
|
||||||
|
"node (novox/hq issue 630). Propose it for one node with --node. Nothing was proposed", module)
|
||||||
|
}
|
||||||
var values map[string]any
|
var values map[string]any
|
||||||
if !clear {
|
if !clear {
|
||||||
if valuesArg == "" {
|
if valuesArg == "" {
|
||||||
|
|||||||
@@ -881,6 +881,48 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
|||||||
argv = append(argv, "--adopted")
|
argv = append(argv, "--adopted")
|
||||||
}
|
}
|
||||||
return argv, nil
|
return argv, nil
|
||||||
|
case "config":
|
||||||
|
// Config values from four sources (novox/hq issue 630): mesh config and node config for a predefined
|
||||||
|
// key, and a module's own value on one node. A listing without key; a set or clear says why.
|
||||||
|
key := str("key")
|
||||||
|
if str("module") != "" && str("node") == "" && (key != "" || on("clear")) {
|
||||||
|
return nil, errors.New("config: there is no module layer for the whole mesh — an assignment is one module " +
|
||||||
|
"on one node (novox/hq issue 630); give node, or leave module out to set mesh config for a predefined key")
|
||||||
|
}
|
||||||
|
place := func(argv []string) []string {
|
||||||
|
if n := str("node"); n != "" {
|
||||||
|
argv = append(argv, "--node", n)
|
||||||
|
}
|
||||||
|
if m := str("module"); m != "" {
|
||||||
|
argv = append(argv, "--module", m)
|
||||||
|
}
|
||||||
|
return argv
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case key == "":
|
||||||
|
if str("value") != "" || on("clear") || on("history") || str("why") != "" {
|
||||||
|
return nil, errors.New("config: a set, a clear or a history names its key")
|
||||||
|
}
|
||||||
|
return place([]string{"config", "list"}), nil
|
||||||
|
case on("history"):
|
||||||
|
if str("value") != "" || on("clear") || str("module") != "" || str("why") != "" {
|
||||||
|
return nil, errors.New("config: history reads a key's mesh or node config, and takes only node")
|
||||||
|
}
|
||||||
|
return place([]string{"config", "history", key}), nil
|
||||||
|
case on("clear") && str("value") != "":
|
||||||
|
return nil, errors.New("config: a value or clear, not both")
|
||||||
|
case on("clear"):
|
||||||
|
if err := need("why"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return append(place([]string{"config", "clear", key}), "--why", str("why")), nil
|
||||||
|
case str("value") != "":
|
||||||
|
if err := need("why"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return append(place([]string{"config", "set", key, str("value")}), "--why", str("why")), nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("config: with key, give value, clear or history")
|
||||||
case "settings":
|
case "settings":
|
||||||
// Every module's preferences, their defaults and each machine's value (novox/hq ADR 0262):
|
// 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
|
// the one interface for them, so no module builds a settings tool of its own. Asked for by
|
||||||
@@ -1614,6 +1656,7 @@ var commandReadForms = map[string]func(rest []string) bool{
|
|||||||
"node": func(r []string) bool { return subIn(r, "list", "show") },
|
"node": func(r []string) bool { return subIn(r, "list", "show") },
|
||||||
"module": func(r []string) bool { return subIn(r, "list") },
|
"module": func(r []string) bool { return subIn(r, "list") },
|
||||||
"settings": func(r []string) bool { return subIn(r, "show", "preferences", "proposals") },
|
"settings": func(r []string) bool { return subIn(r, "show", "preferences", "proposals") },
|
||||||
|
"config": func(r []string) bool { return flagsOnly(r) || subIn(r, "list", "history") },
|
||||||
"retire": func(r []string) bool { return subIn(r, "list") },
|
"retire": func(r []string) bool { return subIn(r, "list") },
|
||||||
"cleanup": func(r []string) bool { return subIn(r, "list") },
|
"cleanup": func(r []string) bool { return subIn(r, "list") },
|
||||||
"delivery": func(r []string) bool { return subIn(r, "plan", "walks") },
|
"delivery": func(r []string) bool { return subIn(r, "plan", "walks") },
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ func TestSettingsSayWhereEachValueComesFrom(t *testing.T) {
|
|||||||
{Key: "width", Value: float64(250), From: catalogue.DefaultLayer, FromDefault: true, Default: float64(250), HasDefault: true},
|
{Key: "width", Value: float64(250), From: catalogue.DefaultLayer, FromDefault: true, Default: float64(250), HasDefault: true},
|
||||||
})
|
})
|
||||||
want := "dunst on laptop, every value and where it comes from:\n" +
|
want := "dunst on laptop, every value and where it comes from:\n" +
|
||||||
" font-size = 13 (laptop; the default is 10)\n" +
|
" font-size = 13 (dunst on laptop; the default is 10)\n" +
|
||||||
" width = 250 (default)\n"
|
" width = 250 (default)\n"
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Fatalf("said\n%s\nwant\n%s", got, want)
|
t.Fatalf("said\n%s\nwant\n%s", got, want)
|
||||||
@@ -66,7 +66,7 @@ func TestPreferencesSayEachMachinesValueAndItsSource(t *testing.T) {
|
|||||||
want := "dunst (on desk, laptop)\n" +
|
want := "dunst (on desk, laptop)\n" +
|
||||||
" font-size, default 10: readable at 100 DPI\n" +
|
" font-size, default 10: readable at 100 DPI\n" +
|
||||||
" desk: 10 (default)\n" +
|
" desk: 10 (default)\n" +
|
||||||
" laptop: 16 (the node)\n" +
|
" laptop: 16 (dunst on laptop)\n" +
|
||||||
" width, default 250: forty characters\n" +
|
" width, default 250: forty characters\n" +
|
||||||
" desk: 300 (the mesh)\n" +
|
" desk: 300 (the mesh)\n" +
|
||||||
" laptop: 250 (default)\n"
|
" laptop: 250 (default)\n"
|
||||||
@@ -96,7 +96,7 @@ func TestPreferencesListedFromTheStores(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
all := stdoutOf(t, func() error { return settingsCommand(ctx, []string{"preferences"}) })
|
all := stdoutOf(t, func() error { return settingsCommand(ctx, []string{"preferences"}) })
|
||||||
if !strings.Contains(all, "notes (on laptop)") || !strings.Contains(all, "laptop: 16 (the node)") ||
|
if !strings.Contains(all, "notes (on laptop)") || !strings.Contains(all, "laptop: 16 (notes on laptop)") ||
|
||||||
!strings.Contains(all, "font-size, default 10: readable at 100 DPI") {
|
!strings.Contains(all, "font-size, default 10: readable at 100 DPI") {
|
||||||
t.Fatalf("the listing:\n%s", all)
|
t.Fatalf("the listing:\n%s", all)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -263,8 +263,18 @@ func TestTheAgentsManagedSettingsAreRefusedThroughAVerb(t *testing.T) {
|
|||||||
if err := atTheTerminal(t, "settings", "set", "claude-code", allow, "--node", "laptop"); err != nil {
|
if err := atTheTerminal(t, "settings", "set", "claude-code", allow, "--node", "laptop"); err != nil {
|
||||||
t.Fatalf("the managed settings at the terminal: %v", err)
|
t.Fatalf("the managed settings at the terminal: %v", err)
|
||||||
}
|
}
|
||||||
if err := atTheTerminal(t, "settings", "set", "claude-code", server); err != nil {
|
// There is no module layer for the whole mesh (novox/hq issue 630), at the terminal either; one the mesh
|
||||||
t.Fatalf("a tool server for the mesh at the terminal: %v", err)
|
// still holds from before is kept as it is, and is the terminal's as before.
|
||||||
|
if err := atTheTerminal(t, "settings", "set", "claude-code", server); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "no module layer for the whole mesh") {
|
||||||
|
t.Fatalf("a tool server for the whole mesh at the terminal: %v", err)
|
||||||
|
}
|
||||||
|
var held map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(server), &held); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := open.inventory.KeepSettings(ctx, "", "claude-code", held); err != nil {
|
||||||
|
t.Fatalf("a whole-mesh layer held from before: %v", err)
|
||||||
}
|
}
|
||||||
kept := layer("laptop")
|
kept := layer("laptop")
|
||||||
// Through a verb they are neither changed, dropped nor cleared.
|
// Through a verb they are neither changed, dropped nor cleared.
|
||||||
@@ -359,15 +369,15 @@ func TestOnlyATrustedFilesNamedKeyIsRefusedThroughAVerb(t *testing.T) {
|
|||||||
if _, err := assign(ctx, open, "anchor", "mailer"); err != nil {
|
if _, err := assign(ctx, open, "anchor", "mailer"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := throughVerb(t, "settings", map[string]any{"module": "mailer", "values": `{"tags":{"todo":{"name":"To do"}}}`}); err != nil {
|
if err := throughVerb(t, "settings", map[string]any{"module": "mailer", "node": "anchor", "values": `{"tags":{"todo":{"name":"To do"}}}`}); err != nil {
|
||||||
t.Fatalf("a key of a file nothing trusts is the verb's: %v", err)
|
t.Fatalf("a key of a file nothing trusts is the verb's: %v", err)
|
||||||
}
|
}
|
||||||
err := throughVerb(t, "settings", map[string]any{"module": "mailer", "replace": "true",
|
err := throughVerb(t, "settings", map[string]any{"module": "mailer", "node": "anchor", "replace": "true",
|
||||||
"values": `{"tags":{"todo":{"name":"To do"}},"accounts":{"mail":{"address":"me@mail.example"}}}`})
|
"values": `{"tags":{"todo":{"name":"To do"}},"accounts":{"mail":{"address":"me@mail.example"}}}`})
|
||||||
if err == nil || !strings.Contains(err.Error(), "controller's terminal") || !strings.Contains(err.Error(), "accounts") {
|
if err == nil || !strings.Contains(err.Error(), "controller's terminal") || !strings.Contains(err.Error(), "accounts") {
|
||||||
t.Fatalf("a key of a trusted file is the terminal's: %v", err)
|
t.Fatalf("a key of a trusted file is the terminal's: %v", err)
|
||||||
}
|
}
|
||||||
values, _, err := open.inventory.Layer(ctx, "", "mailer")
|
values, _, err := open.inventory.Layer(ctx, "anchor", "mailer")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,11 @@ type Result struct {
|
|||||||
// recipe and the package it compiles; for any other, nothing is said and the whole of what the build
|
// recipe and the package it compiles; for any other, nothing is said and the whole of what the build
|
||||||
// sees stays its source, as before.
|
// sees stays its source, as before.
|
||||||
Sources []BuildSource
|
Sources []BuildSource
|
||||||
|
|
||||||
|
// Predefined is the repository's list of predefined keys (catalogue.PredefinedFile at its root) as
|
||||||
|
// read at the commit built, when it has one (novox/hq issue 630): what the controller keeps as the
|
||||||
|
// list mesh and node config hold values for. Empty for a repository without one.
|
||||||
|
Predefined json.RawMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildSource is the build source a build read in one repository (novox/hq ADR 0267).
|
// BuildSource is the build source a build read in one repository (novox/hq ADR 0267).
|
||||||
@@ -233,6 +238,25 @@ func build(ctx context.Context, run Runner, publish Publisher,
|
|||||||
say("manifest", "INVALID: %v", err)
|
say("manifest", "INVALID: %v", err)
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
|
// The repository's list of predefined keys, at its root (novox/hq issue 630): read and judged here,
|
||||||
|
// where a changed list is first read, and handed on for the controller to keep. A preference that
|
||||||
|
// falls back to a key the list does not hold, or with another type, is refused here as at registration.
|
||||||
|
var predefined json.RawMessage
|
||||||
|
if raw, err := os.ReadFile(filepath.Join(tree, catalogue.PredefinedFile)); err == nil {
|
||||||
|
list, err := catalogue.ParsePredefined(raw)
|
||||||
|
if err != nil {
|
||||||
|
say("manifest", "INVALID: %v", err)
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
if problems := catalogue.PredefinedProblems(manifest, list); len(problems) > 0 {
|
||||||
|
err := fmt.Errorf("%s", strings.Join(problems, "; "))
|
||||||
|
say("manifest", "INVALID: %v", err)
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
predefined, _ = json.Marshal(list)
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return Result{}, fmt.Errorf("%s's %s cannot be read: %w", repository, catalogue.PredefinedFile, err)
|
||||||
|
}
|
||||||
say("manifest", "%s v%s — %d artifact(s)", manifest.Module, manifest.Version, artifactCount(manifest))
|
say("manifest", "%s v%s — %d artifact(s)", manifest.Module, manifest.Version, artifactCount(manifest))
|
||||||
|
|
||||||
// What it is made from, for its source fingerprint: the module's own tree first.
|
// What it is made from, for its source fingerprint: the module's own tree first.
|
||||||
@@ -365,7 +389,7 @@ func build(ctx context.Context, run Runner, publish Publisher,
|
|||||||
}
|
}
|
||||||
return Result{Manifest: resolved, Commit: commit, Built: built,
|
return Result{Manifest: resolved, Commit: commit, Built: built,
|
||||||
Against: against(within, manifest, stoodOn), Read: readBy(manifest), Source: fingerprint,
|
Against: against(within, manifest, stoodOn), Read: readBy(manifest), Source: fingerprint,
|
||||||
Trunk: trunk, OnTrunk: onTrunk, Branches: branches, Sources: sources}, nil
|
Trunk: trunk, OnTrunk: onTrunk, Branches: branches, Sources: sources, Predefined: predefined}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// branchesHolding is every branch of a fresh clone's origin the commit is on, without `origin/`.
|
// branchesHolding is every branch of a fresh clone's origin the commit is on, without `origin/`.
|
||||||
|
|||||||
@@ -530,6 +530,12 @@ type Manifest struct {
|
|||||||
// stays refused by name until a layer sets it. The mesh's layer, then the node's, override it.
|
// stays refused by name until a layer sets it. The mesh's layer, then the node's, override it.
|
||||||
Settings map[string]SettingDeclaration `json:"settings,omitempty"`
|
Settings map[string]SettingDeclaration `json:"settings,omitempty"`
|
||||||
|
|
||||||
|
// Preferences are the values this module reads that a machine may tune (novox/hq issue 630): each
|
||||||
|
// key's type, default and why, and the predefined key it falls back to, if any. The value resolves
|
||||||
|
// from the default, then mesh config and node config for the predefined key, then this module's own
|
||||||
|
// value on the node. `settings` entries of kind preference are read as preferences while they move.
|
||||||
|
Preferences map[string]Preference `json:"preferences,omitempty"`
|
||||||
|
|
||||||
// unknown is the first key this manifest has that this controller does not know, at any depth, when
|
// unknown is the first key this manifest has that this controller does not know, at any depth, when
|
||||||
// it was read from the store (novox/hq ADR 0262): a manifest a newer controller registered.
|
// it was read from the store (novox/hq ADR 0262): a manifest a newer controller registered.
|
||||||
// ParseManifest refuses it; the store's catalogue still loads, and the module is left out of every
|
// ParseManifest refuses it; the store's catalogue still loads, and the module is left out of every
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A config value resolves from four sources, the last set winning (novox/hq issue 630):
|
||||||
|
//
|
||||||
|
// 1. the default in the module's manifest;
|
||||||
|
// 2. mesh config: a mesh-wide value for a predefined key, such as `font-size`;
|
||||||
|
// 3. node config: a value for a predefined key for one node;
|
||||||
|
// 4. module-assignment config: that module's own value on that node, the most specific.
|
||||||
|
//
|
||||||
|
// An assignment is one module on one node: there is no module layer for the whole mesh. Mesh and node
|
||||||
|
// config hold values only for the keys on a fixed, reviewable list — the catalogue's `predefined.json` —
|
||||||
|
// that any module may fall back to, by naming one as its preference's `predefined`. A preference without
|
||||||
|
// `predefined` reads only its default and its module-assignment config. A preference is never trusted.
|
||||||
|
|
||||||
|
// Preference is one value a module reads that a machine may tune, as the manifest declares it under
|
||||||
|
// `preferences`.
|
||||||
|
type Preference struct {
|
||||||
|
// Type is "number", "string" or "boolean".
|
||||||
|
Type string `json:"type"`
|
||||||
|
// Default is the value when no source sets it, of Type.
|
||||||
|
Default any `json:"default"`
|
||||||
|
// Predefined names the key on the catalogue's list this preference falls back to: mesh config,
|
||||||
|
// then node config, for that key. Empty reads only the default and the module-assignment config.
|
||||||
|
Predefined string `json:"predefined,omitempty"`
|
||||||
|
// Why this default: one sentence for whoever wonders whether to change it.
|
||||||
|
Why string `json:"why"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// The types a preference and a predefined key may have.
|
||||||
|
const (
|
||||||
|
TypeNumber = "number"
|
||||||
|
TypeString = "string"
|
||||||
|
TypeBoolean = "boolean"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PredefinedFile is the catalogue repository's root file holding the predefined keys.
|
||||||
|
const PredefinedFile = "predefined.json"
|
||||||
|
|
||||||
|
// PredefinedKey is one key mesh and node config may hold a value for.
|
||||||
|
type PredefinedKey struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Why string `json:"why"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Predefined is the list of predefined keys, by key.
|
||||||
|
type Predefined map[string]PredefinedKey
|
||||||
|
|
||||||
|
// Keys is the list's keys, sorted.
|
||||||
|
func (p Predefined) Keys() []string {
|
||||||
|
keys := make([]string, 0, len(p))
|
||||||
|
for k := range p {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
// predefinedName is what a predefined key looks like: the shape of a setting key.
|
||||||
|
var predefinedName = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`)
|
||||||
|
|
||||||
|
// ParsePredefined reads `predefined.json` strictly, and refuses a key whose name, type or why is wrong.
|
||||||
|
func ParsePredefined(raw []byte) (Predefined, error) {
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
var list Predefined
|
||||||
|
if err := decoder.Decode(&list); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s is not a list of predefined keys ({\"<key>\": {\"type\": …, \"why\": …}}): %w",
|
||||||
|
PredefinedFile, err)
|
||||||
|
}
|
||||||
|
var problems []string
|
||||||
|
for _, key := range list.Keys() {
|
||||||
|
k := list[key]
|
||||||
|
if !predefinedName.MatchString(key) {
|
||||||
|
problems = append(problems, fmt.Sprintf("%q is not a usable key: lower-case letters, digits, dots, dashes and underscores", key))
|
||||||
|
}
|
||||||
|
if meshWords[key] {
|
||||||
|
problems = append(problems, fmt.Sprintf("%q is a key the mesh reads itself", key))
|
||||||
|
}
|
||||||
|
if !knownType(k.Type) {
|
||||||
|
problems = append(problems, fmt.Sprintf("%q has the type %q; a type is %q, %q or %q", key, k.Type,
|
||||||
|
TypeNumber, TypeString, TypeBoolean))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(k.Why) == "" {
|
||||||
|
problems = append(problems, fmt.Sprintf("%q has no why: say in one sentence what the key is for", key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(problems) > 0 {
|
||||||
|
return nil, fmt.Errorf("%s: %s", PredefinedFile, strings.Join(problems, "; "))
|
||||||
|
}
|
||||||
|
if list == nil {
|
||||||
|
list = Predefined{}
|
||||||
|
}
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func knownType(t string) bool {
|
||||||
|
return t == TypeNumber || t == TypeString || t == TypeBoolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// TypeOf is the type a JSON value has, as a preference names it, or "" for one that is none of them.
|
||||||
|
func TypeOf(v any) string {
|
||||||
|
switch v.(type) {
|
||||||
|
case float64, int, int64, json.Number:
|
||||||
|
return TypeNumber
|
||||||
|
case string:
|
||||||
|
return TypeString
|
||||||
|
case bool:
|
||||||
|
return TypeBoolean
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreferencesOf is every preference a module declares: those under `preferences`, and — while the
|
||||||
|
// catalogue moves to them — every `settings` entry of kind preference, its type read from its default.
|
||||||
|
func PreferencesOf(m Manifest) map[string]Preference {
|
||||||
|
if len(m.Preferences) == 0 && len(m.Settings) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]Preference, len(m.Preferences)+len(m.Settings))
|
||||||
|
for key, d := range m.Settings {
|
||||||
|
out[key] = Preference{Type: TypeOf(d.Default), Default: d.Default, Why: d.Why}
|
||||||
|
}
|
||||||
|
for key, p := range m.Preferences {
|
||||||
|
out[key] = p
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeclaresPreference is whether a module declares a preference by this key.
|
||||||
|
func DeclaresPreference(m Manifest, key string) bool {
|
||||||
|
_, has := m.Preferences[key]
|
||||||
|
if !has {
|
||||||
|
_, has = m.Settings[key]
|
||||||
|
}
|
||||||
|
return has
|
||||||
|
}
|
||||||
|
|
||||||
|
// PredefinedProblems is every way a module's preferences disagree with the list of predefined keys: a
|
||||||
|
// `predefined` not on it, or a type other than the list's.
|
||||||
|
func PredefinedProblems(m Manifest, list Predefined) []string {
|
||||||
|
var problems []string
|
||||||
|
keys := make([]string, 0, len(m.Preferences))
|
||||||
|
for k := range m.Preferences {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, key := range keys {
|
||||||
|
p := m.Preferences[key]
|
||||||
|
if p.Predefined == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k, on := list[p.Predefined]
|
||||||
|
if !on {
|
||||||
|
listed := "the list holds none"
|
||||||
|
if len(list) > 0 {
|
||||||
|
listed = "the list holds " + strings.Join(list.Keys(), ", ")
|
||||||
|
}
|
||||||
|
problems = append(problems, fmt.Sprintf("%s's preference %q falls back to the predefined key %q, which "+
|
||||||
|
"is not on the catalogue's list of predefined keys (%s; %s)", m.Module, key, p.Predefined, PredefinedFile,
|
||||||
|
listed))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if k.Type != p.Type {
|
||||||
|
problems = append(problems, fmt.Sprintf("%s's preference %q is a %s, and the predefined key %q it falls "+
|
||||||
|
"back to is a %s", m.Module, key, p.Type, p.Predefined, k.Type))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where a value from mesh or node config is said to come from.
|
||||||
|
const MeshConfigLayer = "mesh config"
|
||||||
|
|
||||||
|
// NodeConfigLayer is what the node config of one node is called where a value's source is said.
|
||||||
|
func NodeConfigLayer(node string) string { return "node config (" + node + ")" }
|
||||||
|
|
||||||
|
// ConfigLayers are the layers mesh config and node config make for one module: each of its preferences
|
||||||
|
// that names a predefined key, given that key's value. Laid between the module's defaults and its
|
||||||
|
// module-assignment config; like the defaults they fill `${setting:<key>}` only.
|
||||||
|
func ConfigLayers(m Manifest, mesh, node map[string]any, nodeName string) []Layer {
|
||||||
|
var out []Layer
|
||||||
|
for _, source := range []struct {
|
||||||
|
from string
|
||||||
|
values map[string]any
|
||||||
|
}{{MeshConfigLayer, mesh}, {NodeConfigLayer(nodeName), node}} {
|
||||||
|
values := map[string]any{}
|
||||||
|
for key, p := range m.Preferences {
|
||||||
|
if p.Predefined == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if v, set := source.values[p.Predefined]; set {
|
||||||
|
values[key] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(values) > 0 {
|
||||||
|
out = append(out, Layer{From: source.from, Values: values, Config: true})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigValueProblem refuses a value of the wrong type for a predefined key or a preference.
|
||||||
|
func ConfigValueProblem(what string, want string, v any) error {
|
||||||
|
if got := TypeOf(v); got != want {
|
||||||
|
shown, _ := json.Marshal(v)
|
||||||
|
return fmt.Errorf("%s is a %s, and %s is %s", what, want, shown, aOrNothing(got))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func aOrNothing(t string) string {
|
||||||
|
if t == "" {
|
||||||
|
return "none of a number, a string or a boolean"
|
||||||
|
}
|
||||||
|
return "a " + t
|
||||||
|
}
|
||||||
|
|
||||||
|
// preferenceProblems is every way a module's `preferences` are wrong in themselves.
|
||||||
|
func preferenceProblems(m Manifest) []string {
|
||||||
|
var problems []string
|
||||||
|
keys := make([]string, 0, len(m.Preferences))
|
||||||
|
for k := range m.Preferences {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, key := range keys {
|
||||||
|
p := m.Preferences[key]
|
||||||
|
say := func(format string, args ...any) {
|
||||||
|
problems = append(problems, fmt.Sprintf("%s's preference %q: ", m.Module, key)+fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
|
if _, twice := m.Settings[key]; twice {
|
||||||
|
say("declared under settings too; declare it once, under preferences")
|
||||||
|
}
|
||||||
|
if !knownType(p.Type) {
|
||||||
|
say("the type is %q; a type is %q, %q or %q", p.Type, TypeNumber, TypeString, TypeBoolean)
|
||||||
|
} else if p.Default == nil {
|
||||||
|
say("no default: a preference has one")
|
||||||
|
} else if got := TypeOf(p.Default); got != p.Type {
|
||||||
|
shown, _ := json.Marshal(p.Default)
|
||||||
|
say("it is a %s, and its default %s is %s", p.Type, shown, aOrNothing(got))
|
||||||
|
}
|
||||||
|
if p.Predefined != "" && !predefinedName.MatchString(p.Predefined) {
|
||||||
|
say("%q is not a predefined key's name", p.Predefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|
||||||
|
// PredefinedNamed is every predefined key a module's preferences fall back to, sorted.
|
||||||
|
func PredefinedNamed(m Manifest) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []string
|
||||||
|
for _, p := range m.Preferences {
|
||||||
|
if p.Predefined != "" && !seen[p.Predefined] {
|
||||||
|
seen[p.Predefined] = true
|
||||||
|
out = append(out, p.Predefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The list of predefined keys is read strictly: a type and a why for each key (novox/hq issue 630).
|
||||||
|
func TestThePredefinedListIsReadStrictly(t *testing.T) {
|
||||||
|
list, err := ParsePredefined([]byte(`{"font-size": {"type": "number", "why": "the size text is drawn at"},
|
||||||
|
"theme": {"type": "string", "why": "light or dark"}}`))
|
||||||
|
if err != nil || len(list) != 2 || list["font-size"].Type != TypeNumber {
|
||||||
|
t.Fatalf("a good list: %v %v", list, err)
|
||||||
|
}
|
||||||
|
for raw, want := range map[string]string{
|
||||||
|
`{"font-size": {"type": "integer", "why": "x"}}`: `the type "integer"`,
|
||||||
|
`{"font-size": {"type": "number"}}`: "no why",
|
||||||
|
`{"Font Size": {"type": "number", "why": "x"}}`: "not a usable key",
|
||||||
|
`{"font-size": {"type": "number", "why": "x", "a": 1}}`: "unknown field",
|
||||||
|
`{"ports": {"type": "number", "why": "x"}}`: "the mesh reads itself",
|
||||||
|
} {
|
||||||
|
if _, err := ParsePredefined([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("%s: %v, want %q", raw, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A manifest declares preferences with a type, a default of that type, a why and maybe a predefined key;
|
||||||
|
// a `settings` entry of kind preference is read as one, its type from its default.
|
||||||
|
func TestAManifestDeclaresPreferences(t *testing.T) {
|
||||||
|
file := `"resources": [{"id": "rc", "type": "file", "path": "/etc/rc", "mode": "0644", "trusted": false,
|
||||||
|
"content": "size = ${setting:font-size} width = ${setting:width}"}]`
|
||||||
|
m, err := ParseManifest([]byte(`{"module": "dunst", "version": "1",
|
||||||
|
"preferences": {"font-size": {"type": "number", "default": 10, "predefined": "font-size", "why": "100 DPI"}},
|
||||||
|
"settings": {"width": {"kind": "preference", "default": 250, "why": "forty characters"}}, ` + file + `}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
prefs := PreferencesOf(m)
|
||||||
|
if prefs["font-size"].Predefined != "font-size" || prefs["width"].Type != TypeNumber || prefs["width"].Default != float64(250) {
|
||||||
|
t.Fatalf("the preferences: %+v", prefs)
|
||||||
|
}
|
||||||
|
if got := PredefinedNamed(m); len(got) != 1 || got[0] != "font-size" {
|
||||||
|
t.Fatalf("the predefined keys named: %v", got)
|
||||||
|
}
|
||||||
|
for body, want := range map[string]string{
|
||||||
|
`"preferences": {"font-size": {"type": "number", "default": "10", "why": "x"}, "width": {"type": "number", "default": 1, "why": "x"}}`: `its default "10" is a string`,
|
||||||
|
`"preferences": {"font-size": {"type": "float", "default": 10, "why": "x"}, "width": {"type": "number", "default": 1, "why": "x"}}`: `the type is "float"`,
|
||||||
|
`"preferences": {"font-size": {"type": "number", "why": "x"}, "width": {"type": "number", "default": 1, "why": "x"}}`: "no default",
|
||||||
|
`"preferences": {"font-size": {"type": "number", "default": 10}, "width": {"type": "number", "default": 1, "why": "x"}}`: "no why",
|
||||||
|
} {
|
||||||
|
if _, err := ParseManifest([]byte(`{"module": "dunst", "version": "1", ` + body + `, ` + file + `}`)); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("%s: %v, want %q", body, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A preference falls back only to a key on the list, with the list's type.
|
||||||
|
func TestAPreferencesPredefinedKeyIsOnTheListWithItsType(t *testing.T) {
|
||||||
|
m := Manifest{Module: "dunst", Preferences: map[string]Preference{
|
||||||
|
"font-size": {Type: TypeNumber, Default: float64(10), Predefined: "font-size", Why: "x"}}}
|
||||||
|
if p := PredefinedProblems(m, Predefined{"font-size": {Type: TypeNumber, Why: "x"}}); len(p) != 0 {
|
||||||
|
t.Fatalf("on the list: %v", p)
|
||||||
|
}
|
||||||
|
if p := PredefinedProblems(m, Predefined{"theme": {Type: TypeString, Why: "x"}}); len(p) != 1 ||
|
||||||
|
!strings.Contains(p[0], "not on the catalogue's list") || !strings.Contains(p[0], "theme") {
|
||||||
|
t.Fatalf("not on the list: %v", p)
|
||||||
|
}
|
||||||
|
if p := PredefinedProblems(m, Predefined{"font-size": {Type: TypeString, Why: "x"}}); len(p) != 1 ||
|
||||||
|
!strings.Contains(p[0], "is a string") {
|
||||||
|
t.Fatalf("another type: %v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mesh and node config fill `${setting:<key>}` between the default and the module's own layer, and — like
|
||||||
|
// the default — never reach a mergeable file.
|
||||||
|
func TestConfigLayersFillAPlaceholderAndNoMergeableFile(t *testing.T) {
|
||||||
|
m := Manifest{Module: "dunst", Preferences: map[string]Preference{
|
||||||
|
"font-size": {Type: TypeNumber, Default: float64(10), Predefined: "font-size", Why: "x"},
|
||||||
|
"width": {Type: TypeNumber, Default: float64(250), Why: "x"}}}
|
||||||
|
fill := func(layers []Layer) string {
|
||||||
|
t.Helper()
|
||||||
|
r := map[string]any{"id": "rc", "type": "file", "content": "font ${setting:font-size} width ${setting:width}"}
|
||||||
|
if err := settingInto(r, WithDefaults(m, layers), m.Module); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return r["content"].(string)
|
||||||
|
}
|
||||||
|
mesh := map[string]any{"font-size": float64(11), "width": float64(999)}
|
||||||
|
node := map[string]any{"font-size": float64(13)}
|
||||||
|
if got := fill(nil); got != "font 10 width 250" {
|
||||||
|
t.Fatalf("the defaults: %s", got)
|
||||||
|
}
|
||||||
|
if got := fill(ConfigLayers(m, mesh, nil, "g14")); got != "font 11 width 250" {
|
||||||
|
t.Fatalf("mesh config, which a preference without predefined does not read: %s", got)
|
||||||
|
}
|
||||||
|
both := ConfigLayers(m, mesh, node, "g14")
|
||||||
|
if got := fill(both); got != "font 13 width 250" {
|
||||||
|
t.Fatalf("node config over mesh config: %s", got)
|
||||||
|
}
|
||||||
|
own := append(both, Layer{From: "g14", Values: map[string]any{"font-size": float64(15)}})
|
||||||
|
if got := fill(own); got != "font 15 width 250" {
|
||||||
|
t.Fatalf("the module's own value over both: %s", got)
|
||||||
|
}
|
||||||
|
sources := map[string]string{}
|
||||||
|
for _, s := range Effective(m, both) {
|
||||||
|
sources[s.Key] = s.From
|
||||||
|
}
|
||||||
|
if sources["font-size"] != NodeConfigLayer("g14") || sources["width"] != DefaultLayer {
|
||||||
|
t.Fatalf("where each comes from: %v", sources)
|
||||||
|
}
|
||||||
|
merged, err := ApplySettings(map[string]any{"id": "conf", "type": "file", "merge": MergeJSON, "content": "{}"}, both)
|
||||||
|
if err != nil || merged["content"] != "{}\n" {
|
||||||
|
t.Fatalf("config reached a mergeable file: %v %v", merged["content"], err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -114,15 +114,23 @@ func operatorsWord(key string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// SettingProblems is every way a definition's setting defaults are wrong, in its own words.
|
// SettingProblems is every way a definition's setting defaults and preferences are wrong, in its own
|
||||||
|
// words. The list of predefined keys is not needed here; PredefinedProblems judges against it.
|
||||||
func SettingProblems(m Manifest) []string {
|
func SettingProblems(m Manifest) []string {
|
||||||
if len(m.Settings) == 0 {
|
declared := PreferencesOf(m)
|
||||||
|
if len(declared) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
used := settingKeysUsedBy(m)
|
used := settingKeysUsedBy(m)
|
||||||
var problems []string
|
problems := preferenceProblems(m)
|
||||||
for _, key := range sortedSettingKeys(m.Settings) {
|
keys := make([]string, 0, len(declared))
|
||||||
d := m.Settings[key]
|
for k := range declared {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, key := range keys {
|
||||||
|
d := declared[key]
|
||||||
|
_, old := m.Settings[key]
|
||||||
say := func(format string, args ...any) {
|
say := func(format string, args ...any) {
|
||||||
problems = append(problems, fmt.Sprintf("%s's setting %q: ", m.Module, key)+fmt.Sprintf(format, args...))
|
problems = append(problems, fmt.Sprintf("%s's setting %q: ", m.Module, key)+fmt.Sprintf(format, args...))
|
||||||
}
|
}
|
||||||
@@ -134,23 +142,27 @@ func SettingProblems(m Manifest) []string {
|
|||||||
say("the mesh reads %q itself, and a module gives it no default", key)
|
say("the mesh reads %q itself, and a module gives it no default", key)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if d.Kind != KindPreference {
|
if old && m.Settings[key].Kind != KindPreference {
|
||||||
say("kind is %q, and only a %q has a default (novox/hq ADR 0262)", d.Kind, KindPreference)
|
say("kind is %q, and only a %q has a default (novox/hq ADR 0262)", m.Settings[key].Kind, KindPreference)
|
||||||
}
|
}
|
||||||
if word := operatorsWord(key); word != "" {
|
if word := operatorsWord(key); word != "" {
|
||||||
say("a key naming %q is the operator's value, and has no default — it is the "+
|
say("a key naming %q is the operator's value, and has no default — it is the "+
|
||||||
"assignment's, never the definition's (novox/hq ADR 0112, ADR 0262)", word)
|
"assignment's, never the definition's (novox/hq ADR 0112, ADR 0262)", word)
|
||||||
}
|
}
|
||||||
switch v := d.Default.(type) {
|
if old {
|
||||||
case string:
|
switch v := d.Default.(type) {
|
||||||
if strings.TrimSpace(v) == "" {
|
case string:
|
||||||
say("an empty default is no default; give the value, or declare nothing")
|
if strings.TrimSpace(v) == "" {
|
||||||
|
say("an empty default is no default; give the value, or declare nothing")
|
||||||
|
}
|
||||||
|
case float64, bool:
|
||||||
|
case nil:
|
||||||
|
say("no default: a key without one is the operator's, and is not declared here")
|
||||||
|
default:
|
||||||
|
say("a default is a string, a number or a boolean, and this is %T", d.Default)
|
||||||
}
|
}
|
||||||
case float64, bool:
|
} else if v, isString := d.Default.(string); isString && strings.TrimSpace(v) == "" {
|
||||||
case nil:
|
say("an empty default is no default; give the value, or declare nothing")
|
||||||
say("no default: a key without one is the operator's, and is not declared here")
|
|
||||||
default:
|
|
||||||
say("a default is a string, a number or a boolean, and this is %T", d.Default)
|
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(d.Why) == "" {
|
if strings.TrimSpace(d.Why) == "" {
|
||||||
say("no why: say in one sentence why this default")
|
say("no why: say in one sentence why this default")
|
||||||
@@ -164,11 +176,12 @@ func SettingProblems(m Manifest) []string {
|
|||||||
|
|
||||||
// Defaults is the layer a module's own defaults make, or nothing when it gives none.
|
// Defaults is the layer a module's own defaults make, or nothing when it gives none.
|
||||||
func Defaults(m Manifest) (Layer, bool) {
|
func Defaults(m Manifest) (Layer, bool) {
|
||||||
if len(m.Settings) == 0 {
|
declared := PreferencesOf(m)
|
||||||
|
if len(declared) == 0 {
|
||||||
return Layer{}, false
|
return Layer{}, false
|
||||||
}
|
}
|
||||||
values := map[string]any{}
|
values := map[string]any{}
|
||||||
for key, d := range m.Settings {
|
for key, d := range declared {
|
||||||
if d.Default != nil {
|
if d.Default != nil {
|
||||||
values[key] = d.Default
|
values[key] = d.Default
|
||||||
}
|
}
|
||||||
@@ -189,7 +202,7 @@ func WithDefaults(m Manifest, layers []Layer) []Layer {
|
|||||||
type SettingSource struct {
|
type SettingSource struct {
|
||||||
Key string
|
Key string
|
||||||
Value any
|
Value any
|
||||||
// From is DefaultLayer, MeshWideLayer or the node's name.
|
// From is DefaultLayer, MeshConfigLayer, NodeConfigLayer(node), MeshWideLayer or the node's name.
|
||||||
From string
|
From string
|
||||||
// FromDefault is whether the value is the module's default, whatever From reads.
|
// FromDefault is whether the value is the module's default, whatever From reads.
|
||||||
FromDefault bool
|
FromDefault bool
|
||||||
@@ -199,10 +212,11 @@ type SettingSource struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Effective is every key a module gives a default or a layer sets, with its value and where it came
|
// Effective is every key a module gives a default or a layer sets, with its value and where it came
|
||||||
// from: the default, then the mesh's layer, then the node's — later wins.
|
// from: the default, then mesh config, then node config, then the module's own layer on the node —
|
||||||
|
// later wins (novox/hq issue 630).
|
||||||
func Effective(m Manifest, layers []Layer) []SettingSource {
|
func Effective(m Manifest, layers []Layer) []SettingSource {
|
||||||
byKey := map[string]*SettingSource{}
|
byKey := map[string]*SettingSource{}
|
||||||
for key, d := range m.Settings {
|
for key, d := range PreferencesOf(m) {
|
||||||
byKey[key] = &SettingSource{Key: key, Value: d.Default, From: DefaultLayer, FromDefault: true,
|
byKey[key] = &SettingSource{Key: key, Value: d.Default, From: DefaultLayer, FromDefault: true,
|
||||||
Default: d.Default, HasDefault: true}
|
Default: d.Default, HasDefault: true}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ func wholeID(v string) (uint64, bool) {
|
|||||||
func OwnerSettingProblems(m Manifest) []string {
|
func OwnerSettingProblems(m Manifest) []string {
|
||||||
var problems []string
|
var problems []string
|
||||||
asked := settingKeysAskedOutsideOwners(m)
|
asked := settingKeysAskedOutsideOwners(m)
|
||||||
for k := range m.Settings {
|
for k := range PreferencesOf(m) {
|
||||||
asked[k] = true
|
asked[k] = true
|
||||||
}
|
}
|
||||||
for _, r := range m.Resources {
|
for _, r := range m.Resources {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func onlyTaken(layers []Layer, taken map[string]bool) []Layer {
|
|||||||
values[key] = value
|
values[key] = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
kept = append(kept, Layer{From: layer.From, Values: values, Default: layer.Default})
|
kept = append(kept, Layer{From: layer.From, Values: values, Default: layer.Default, Config: layer.Config})
|
||||||
}
|
}
|
||||||
return kept
|
return kept
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@ func TakenSettingsProblems(m Manifest) []string {
|
|||||||
// Said only when none of the module's mergeable files names its keys: when one does, §2 already
|
// Said only when none of the module's mergeable files names its keys: when one does, §2 already
|
||||||
// names every file that does not, and a file is not reported twice.
|
// names every file that does not, and a file is not reported twice.
|
||||||
func WrittenSettingsProblems(m Manifest) []string {
|
func WrittenSettingsProblems(m Manifest) []string {
|
||||||
if len(m.Settings) == 0 {
|
if len(PreferencesOf(m)) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var silent []string
|
var silent []string
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ type Layer struct {
|
|||||||
// Default marks the layer a module's own defaults make (novox/hq ADR 0262). Marked rather than
|
// Default marks the layer a module's own defaults make (novox/hq ADR 0262). Marked rather than
|
||||||
// recognised by From, which is a node's name for a node's layer, and a node may be called anything.
|
// recognised by From, which is a node's name for a node's layer, and a node may be called anything.
|
||||||
Default bool
|
Default bool
|
||||||
|
// Config marks a layer of mesh config or node config (novox/hq issue 630): values for predefined keys,
|
||||||
|
// given to the preferences that fall back to them. Like the defaults, it fills `${setting:<key>}` only.
|
||||||
|
Config bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplySettings produces a resource's final content from the module's own and the layers over it.
|
// ApplySettings produces a resource's final content from the module's own and the layers over it.
|
||||||
@@ -136,7 +139,7 @@ func overridden(base map[string]any, layers []Layer, what string) (map[string]an
|
|||||||
values[key] = value
|
values[key] = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
kept = append(kept, Layer{From: layer.From, Values: values, Default: layer.Default})
|
kept = append(kept, Layer{From: layer.From, Values: values, Default: layer.Default, Config: layer.Config})
|
||||||
}
|
}
|
||||||
merged, err := settle(base, kept, nil, what)
|
merged, err := settle(base, kept, nil, what)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -173,7 +176,7 @@ func settle(base map[string]any, layers []Layer, protected map[string]bool, what
|
|||||||
map[string]any, error) {
|
map[string]any, error) {
|
||||||
merged := deepCopy(base)
|
merged := deepCopy(base)
|
||||||
for _, layer := range layers {
|
for _, layer := range layers {
|
||||||
if layer.Default {
|
if layer.Default || layer.Config {
|
||||||
// A module's own defaults fill ${setting:<key>} only (novox/hq ADR 0262). A mergeable
|
// A module's own defaults fill ${setting:<key>} only (novox/hq ADR 0262). A mergeable
|
||||||
// file's content already is its defaults, and a contribution or served fact declares its
|
// file's content already is its defaults, and a contribution or served fact declares its
|
||||||
// own; laid on here, a default would reach every mergeable file of its module (issue 168).
|
// own; laid on here, a default would reach every mergeable file of its module (issue 168).
|
||||||
@@ -363,7 +366,7 @@ func UnusedSettings(m Manifest, layers []Layer) []string {
|
|||||||
}
|
}
|
||||||
// A key the module gives a default reaches what asks for it (novox/hq ADR 0262): setting it
|
// A key the module gives a default reaches what asks for it (novox/hq ADR 0262): setting it
|
||||||
// overrides the default, which is how a running module gains a setting without a gap between.
|
// overrides the default, which is how a running module gains a setting without a gap between.
|
||||||
for key := range m.Settings {
|
for key := range PreferencesOf(m) {
|
||||||
lands[key] = true
|
lands[key] = true
|
||||||
}
|
}
|
||||||
for _, values := range m.Contributes {
|
for _, values := range m.Contributes {
|
||||||
|
|||||||
@@ -299,10 +299,33 @@ var ControllerVerbs = []Verb{
|
|||||||
"adopted": "\"true\" when the machine is in use and joins adopted",
|
"adopted": "\"true\" when the machine is in use and joins adopted",
|
||||||
}, nil, "adopted"),
|
}, nil, "adopted"),
|
||||||
Replaces: []string{"mesh-controller token issue", "wg set"}},
|
Replaces: []string{"mesh-controller token issue", "wg set"}},
|
||||||
{Name: "settings", Description: "Read or set what an assignment is configured with: a module's settings for the whole " +
|
{Name: "config", Description: "Read or set config values (novox/hq issue 630). A module's preference resolves from " +
|
||||||
"mesh, or for one machine. Without values or clear, answers the layer as it stands — read it before setting it — " +
|
"four sources, the last set winning: its default; mesh config, a mesh-wide value for a predefined key; node " +
|
||||||
|
"config, a value for a predefined key on one node; and the module's own value on that node. Mesh and node " +
|
||||||
|
"config hold values only for the predefined keys, the catalogue's predefined.json, which a preference names " +
|
||||||
|
"as its predefined; there is no module layer for the whole mesh. Without key: the predefined keys with their " +
|
||||||
|
"mesh and node config, then each module's preferences with the value on each node and its source (default, " +
|
||||||
|
"mesh config, node config (<node>), <module> on <node>); node and module narrow it. With key and value (JSON; " +
|
||||||
|
"a bare word is a string): sets mesh config, or node config with node, or with module and node the module's own " +
|
||||||
|
"value on that node — refused for a key not on the list, a value of the wrong type, and a module without " +
|
||||||
|
"node. With clear instead of value: removes it, and the next source down applies. A set or clear says why, " +
|
||||||
|
"is recorded in the hand-act log with the caller, keeps the value it replaced in the history, and takes effect " +
|
||||||
|
"at the next push of the machines it reaches, which the answer names. With history: a key's past mesh or node " +
|
||||||
|
"config values.",
|
||||||
|
Input: schema(map[string]string{
|
||||||
|
"key": "a predefined key (mesh or node config), or with module one of that module's preferences",
|
||||||
|
"value": "the value as JSON (12, \"dark\", true); a bare word is a string",
|
||||||
|
"node": "one node: its node config, or with module that module's value there",
|
||||||
|
"module": "a module: its own value on node; needs node",
|
||||||
|
"clear": "\"true\" to remove the value instead of setting it",
|
||||||
|
"history": "\"true\": the key's past mesh config values, or with node its node config's",
|
||||||
|
"why": "why it is set or cleared, kept with the value and in the hand-act log; required to set or clear",
|
||||||
|
}, nil, "clear", "history")},
|
||||||
|
{Name: "settings", Description: "Read or set what an assignment is configured with: a module's settings on one " +
|
||||||
|
"machine; there is no module layer for the whole mesh (novox/hq issue 630) — a set or proposal without node is " +
|
||||||
|
"refused, and a mesh-wide value of a predefined key is the config verb's. 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, " +
|
"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); with list \"preferences\", or with no module, every " +
|
"mesh config, node config, or the module on the machine (novox/hq ADR 0262, issue 630); with list \"preferences\", or with no module, every " +
|
||||||
"module's preferences and each machine's value; " +
|
"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 (+), " +
|
"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 " +
|
"changes (~) and removes (-); a set that would remove a key is refused unless replace says it is meant " +
|
||||||
@@ -317,7 +340,7 @@ var ControllerVerbs = []Verb{
|
|||||||
Input: schema(map[string]string{
|
Input: schema(map[string]string{
|
||||||
"module": "the module's name; with list, only that module's",
|
"module": "the module's name; with list, only that module's",
|
||||||
"values": "the settings as a JSON object, for set or propose",
|
"values": "the settings as a JSON object, for set or propose",
|
||||||
"node": "one machine; the whole mesh when absent",
|
"node": "one machine; needed to set or propose values (no module layer for the whole mesh); without it, clear removes a whole-mesh layer left over",
|
||||||
"clear": "\"true\" to remove the layer instead of setting it, or to propose its removal; not with values",
|
"clear": "\"true\" to remove the layer instead of setting it, or to propose its removal; not with values",
|
||||||
"replace": "\"true\": with values, the set (or the proposal) is meant to remove the keys the layer had and it does not name",
|
"replace": "\"true\": with values, the set (or the proposal) 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",
|
"history": "\"true\": without values or clear, the layers this one replaced, the latest first",
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ type Facts struct {
|
|||||||
Modules []Module `json:"modules"`
|
Modules []Module `json:"modules"`
|
||||||
// Settings are the mesh-wide layer of every module's settings, scrubbed.
|
// Settings are the mesh-wide layer of every module's settings, scrubbed.
|
||||||
Settings []Settings `json:"settings,omitempty"`
|
Settings []Settings `json:"settings,omitempty"`
|
||||||
|
// Predefined is the list of predefined keys the mesh holds, and Config the mesh config's value for
|
||||||
|
// each it sets, scrubbed (novox/hq issue 630). A machine's node config is on the machine.
|
||||||
|
Predefined map[string]PredefinedKey `json:"predefined,omitempty"`
|
||||||
|
Config map[string]any `json:"config,omitempty"`
|
||||||
// Edges are the build dependencies between modules, as the mesh recorded them — what a merge's
|
// Edges are the build dependencies between modules, as the mesh recorded them — what a merge's
|
||||||
// rebuild width is computed from.
|
// rebuild width is computed from.
|
||||||
Edges []Edge `json:"edges,omitempty"`
|
Edges []Edge `json:"edges,omitempty"`
|
||||||
@@ -143,6 +147,9 @@ type Machine struct {
|
|||||||
Pins []Pin `json:"pins,omitempty"`
|
Pins []Pin `json:"pins,omitempty"`
|
||||||
// Settings are its own layer of each module's settings, scrubbed.
|
// Settings are its own layer of each module's settings, scrubbed.
|
||||||
Settings []Settings `json:"settings,omitempty"`
|
Settings []Settings `json:"settings,omitempty"`
|
||||||
|
// Config is the node config of the machine: its value for each predefined key it sets, scrubbed
|
||||||
|
// (novox/hq issue 630).
|
||||||
|
Config map[string]any `json:"config,omitempty"`
|
||||||
// Accepted are the secrets a person gave the mesh for modules here, by name only: the mesh cannot
|
// Accepted are the secrets a person gave the mesh for modules here, by name only: the mesh cannot
|
||||||
// make them, so a check composing this machine gives each a stand-in instead of refusing it.
|
// make them, so a check composing this machine gives each a stand-in instead of refusing it.
|
||||||
Accepted []Accepted `json:"accepted,omitempty"`
|
Accepted []Accepted `json:"accepted,omitempty"`
|
||||||
@@ -173,6 +180,12 @@ type Accepted struct {
|
|||||||
Local string `json:"local,omitempty"`
|
Local string `json:"local,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PredefinedKey is one key of the list of predefined keys.
|
||||||
|
type PredefinedKey struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Why string `json:"why"`
|
||||||
|
}
|
||||||
|
|
||||||
// Settings is one layer of one module's settings.
|
// Settings is one layer of one module's settings.
|
||||||
type Settings struct {
|
type Settings struct {
|
||||||
Module string `json:"module"`
|
Module string `json:"module"`
|
||||||
|
|||||||
@@ -117,6 +117,18 @@ func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, fr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// **A preference falls back only to a key on the catalogue's list, and with its type** (novox/hq issue
|
||||||
|
// 630): mesh and node config hold values for the predefined keys alone, so a `predefined` the list does
|
||||||
|
// not hold is a fallback nobody can ever set.
|
||||||
|
if names := catalogue.PredefinedNamed(m); len(names) > 0 {
|
||||||
|
list, err := i.Predefined(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if problems := catalogue.PredefinedProblems(m, list); len(problems) > 0 {
|
||||||
|
return fmt.Errorf("%s is not registered: %s", m.Module, strings.Join(problems, "; "))
|
||||||
|
}
|
||||||
|
}
|
||||||
raw, err := json.Marshal(m)
|
raw, err := json.Marshal(m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -853,6 +865,14 @@ func (i *Inventory) setSettings(ctx context.Context, nodeName, module string, va
|
|||||||
} else if _, err := i.declared(ctx, module); err != nil {
|
} else if _, err := i.declared(ctx, module); err != nil {
|
||||||
return fmt.Errorf("%w: %s", ErrNoSuchModule, module)
|
return fmt.Errorf("%w: %s", ErrNoSuchModule, module)
|
||||||
}
|
}
|
||||||
|
if nodeName == "" && judge {
|
||||||
|
// **There is no module layer for the whole mesh** (novox/hq issue 630): an assignment is one module
|
||||||
|
// on one node. What every node should share is mesh config, for a predefined key. A layer the mesh
|
||||||
|
// already holds is still kept as it is (KeepSettings, the merge gate raising a store from facts).
|
||||||
|
return fmt.Errorf("%s: there is no module layer for the whole mesh — an assignment is one module on "+
|
||||||
|
"one node (novox/hq issue 630). Set it on each node with --node, or, for a predefined key, as mesh "+
|
||||||
|
"config with the config verb. Nothing was changed", module)
|
||||||
|
}
|
||||||
if nodeName == "" {
|
if nodeName == "" {
|
||||||
// A port is a fact about one machine (novox/hq ADR 0100). Refused here, in composition's
|
// A port is a fact about one machine (novox/hq ADR 0100). Refused here, in composition's
|
||||||
// words: stored, it refuses every node running the module at composition, and the mesh
|
// words: stored, it refuses every node running the module at composition, and the mesh
|
||||||
@@ -1133,7 +1153,8 @@ func (i *Inventory) ClearSettingsBy(ctx context.Context, nodeName, module, clear
|
|||||||
return tx.Commit(ctx)
|
return tx.Commit(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SettingsFor is the layers that apply to one module on one node, in the order they are applied.
|
// SettingsFor is the layers that apply to one module on one node, in the order they are applied: mesh
|
||||||
|
// config, node config (both marked Config), then the module's own layer on the node.
|
||||||
//
|
//
|
||||||
// The mesh's first, then the node's, so a node that differs is expressed by differing rather
|
// The mesh's first, then the node's, so a node that differs is expressed by differing rather
|
||||||
// than by restating everything the rest of the mesh already says.
|
// than by restating everything the rest of the mesh already says.
|
||||||
@@ -1151,7 +1172,12 @@ func (i *Inventory) SettingsFor(ctx context.Context, nodeName, module string) ([
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var layers []catalogue.Layer
|
// Mesh config, then node config, for the predefined keys the module's preferences fall back to
|
||||||
|
// (novox/hq issue 630): under the module's own layers, over its defaults.
|
||||||
|
layers, err := i.configLayers(ctx, node.ID, nodeName, module)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var meshWide bool
|
var meshWide bool
|
||||||
var raw []byte
|
var raw []byte
|
||||||
|
|||||||
@@ -0,0 +1,339 @@
|
|||||||
|
package inventory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mesh config and node config (novox/hq issue 630): a value for a predefined key, for the whole mesh or for
|
||||||
|
// one node, that every module whose preference names the key falls back to. The list of predefined keys is
|
||||||
|
// the catalogue's predefined.json, kept here as the last build of the catalogue carried it.
|
||||||
|
|
||||||
|
// ErrNotPredefined is a key mesh or node config was asked to hold that is not on the list.
|
||||||
|
var ErrNotPredefined = errors.New("not a predefined key")
|
||||||
|
|
||||||
|
// SetPredefined replaces the list of predefined keys with the one a build of `repository` at `commit`
|
||||||
|
// carried. Replaced whole: the file is a statement of the whole list.
|
||||||
|
func (i *Inventory) SetPredefined(ctx context.Context, list catalogue.Predefined, repository, commit string) error {
|
||||||
|
tx, err := i.store.Pool().Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
|
||||||
|
if _, err := tx.Exec(ctx, `delete from predefined_key`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, key := range list.Keys() {
|
||||||
|
k := list[key]
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`insert into predefined_key (key, type, why, repository, commit) values ($1, $2, $3, $4, $5)`,
|
||||||
|
key, k.Type, k.Why, nullable(repository), nullable(commit)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Predefined is the list of predefined keys the mesh holds; empty before a build of the catalogue carried one.
|
||||||
|
func (i *Inventory) Predefined(ctx context.Context) (catalogue.Predefined, error) {
|
||||||
|
rows, err := i.store.Pool().Query(ctx, `select key, type, why from predefined_key`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := catalogue.Predefined{}
|
||||||
|
for rows.Next() {
|
||||||
|
var key string
|
||||||
|
var k catalogue.PredefinedKey
|
||||||
|
if err := rows.Scan(&key, &k.Type, &k.Why); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[key] = k
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigValue is one value of mesh config (Node "") or node config.
|
||||||
|
type ConfigValue struct {
|
||||||
|
Key string
|
||||||
|
Node string
|
||||||
|
Value any
|
||||||
|
SetAt time.Time
|
||||||
|
SetBy string
|
||||||
|
Why string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config is every value of mesh config and node config, by key, the mesh's first, then by node.
|
||||||
|
func (i *Inventory) Config(ctx context.Context) ([]ConfigValue, error) {
|
||||||
|
rows, err := i.store.Pool().Query(ctx,
|
||||||
|
`select c.key, coalesce(n.name, ''), c.value, c.set_at, coalesce(c.set_by, ''), c.why
|
||||||
|
from config c left join node n on n.id = c.node
|
||||||
|
order by c.key, c.node is not null, n.name`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []ConfigValue
|
||||||
|
for rows.Next() {
|
||||||
|
var v ConfigValue
|
||||||
|
var raw []byte
|
||||||
|
if err := rows.Scan(&v.Key, &v.Node, &raw, &v.SetAt, &v.SetBy, &v.Why); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &v.Value); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetConfig sets a predefined key's value for the whole mesh (node "") or for one node. Refused for a key not
|
||||||
|
// on the list and for a value not of the key's type. The value it replaces is kept in config_history.
|
||||||
|
func (i *Inventory) SetConfig(ctx context.Context, key, nodeName string, value any, setBy, why string) error {
|
||||||
|
if strings.TrimSpace(why) == "" {
|
||||||
|
return errors.New("config says why: one sentence, kept with the value")
|
||||||
|
}
|
||||||
|
if err := i.JudgeConfig(ctx, key, nodeName, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
nodeID, err := i.layerNode(ctx, nodeName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tx, err := i.store.Pool().Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
|
||||||
|
if _, err := tx.Exec(ctx, keepConfigSQL, key, nodeID, "set", why); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`delete from config where key = $1 and node is not distinct from $2::uuid`, key, nodeID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`insert into config (key, node, value, set_by, why) values ($1, $2::uuid, $3, $4, $5)`,
|
||||||
|
key, nodeID, raw, nullable(setBy), why); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// JudgeConfig refuses, before anything is done, a value mesh or node config may not hold: a key not on the
|
||||||
|
// list of predefined keys, or a value not of the key's type.
|
||||||
|
func (i *Inventory) JudgeConfig(ctx context.Context, key, nodeName string, value any) error {
|
||||||
|
list, err := i.Predefined(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
k, on := list[key]
|
||||||
|
if !on {
|
||||||
|
return notPredefined(key, nodeName, list)
|
||||||
|
}
|
||||||
|
return catalogue.ConfigValueProblem(fmt.Sprintf("the predefined key %q", key), k.Type, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearConfig removes a predefined key's value for the whole mesh (node "") or for one node, keeping it in
|
||||||
|
// config_history; false when there was none. A key no longer on the list may still be cleared.
|
||||||
|
func (i *Inventory) ClearConfig(ctx context.Context, key, nodeName, clearedBy, why string) (bool, error) {
|
||||||
|
if strings.TrimSpace(why) == "" {
|
||||||
|
return false, errors.New("config says why: one sentence, kept with the history")
|
||||||
|
}
|
||||||
|
nodeID, err := i.layerNode(ctx, nodeName)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
tx, err := i.store.Pool().Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
|
||||||
|
if _, err := tx.Exec(ctx, keepConfigSQL, key, nodeID, "clear", why); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if clearedBy != "" {
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`update config_history set replaced_by = 'clear by ' || $3
|
||||||
|
where key = $1 and node is not distinct from $2::uuid
|
||||||
|
and replaced_at = (select max(replaced_at) from config_history
|
||||||
|
where key = $1 and node is not distinct from $2::uuid)`,
|
||||||
|
key, nodeID, clearedBy); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tag, err := tx.Exec(ctx, `delete from config where key = $1 and node is not distinct from $2::uuid`, key, nodeID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return tag.RowsAffected() > 0, tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PastConfig is one value of mesh or node config that was replaced or cleared.
|
||||||
|
type PastConfig struct {
|
||||||
|
Value any
|
||||||
|
SetAt time.Time
|
||||||
|
SetBy string
|
||||||
|
Why string
|
||||||
|
ReplacedAt time.Time
|
||||||
|
ReplacedBy string
|
||||||
|
ReplacedWhy string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigHistory is the values a predefined key had for the whole mesh (node "") or one node, latest first.
|
||||||
|
func (i *Inventory) ConfigHistory(ctx context.Context, key, nodeName string) ([]PastConfig, error) {
|
||||||
|
nodeID, err := i.layerNode(ctx, nodeName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := i.store.Pool().Query(ctx,
|
||||||
|
`select value, coalesce(set_at, 'epoch'), coalesce(set_by, ''), coalesce(why, ''), replaced_at, replaced_by,
|
||||||
|
coalesce(replaced_why, '')
|
||||||
|
from config_history where key = $1 and node is not distinct from $2::uuid order by replaced_at desc`,
|
||||||
|
key, nodeID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []PastConfig
|
||||||
|
for rows.Next() {
|
||||||
|
var p PastConfig
|
||||||
|
var raw []byte
|
||||||
|
if err := rows.Scan(&raw, &p.SetAt, &p.SetBy, &p.Why, &p.ReplacedAt, &p.ReplacedBy, &p.ReplacedWhy); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &p.Value); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// keepConfigSQL copies the value about to be replaced or cleared into the history, with why it goes.
|
||||||
|
const keepConfigSQL = `insert into config_history (key, node, value, set_at, set_by, why, replaced_by, replaced_why)
|
||||||
|
select key, node, value, set_at, set_by, why, $3, $4 from config
|
||||||
|
where key = $1 and node is not distinct from $2::uuid`
|
||||||
|
|
||||||
|
func notPredefined(key, nodeName string, list catalogue.Predefined) error {
|
||||||
|
where := "mesh config"
|
||||||
|
if nodeName != "" {
|
||||||
|
where = "node config"
|
||||||
|
}
|
||||||
|
listed := "the list holds none yet: it arrives with a build of the catalogue carrying " + catalogue.PredefinedFile
|
||||||
|
if len(list) > 0 {
|
||||||
|
listed = "the list holds " + strings.Join(list.Keys(), ", ")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w: %s holds values only for the predefined keys, and %q is not one (%s); a module's own "+
|
||||||
|
"value is set with module and node", ErrNotPredefined, where, key, listed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// configLayers are the layers mesh config and node config make for one module on one node: nothing when
|
||||||
|
// neither holds a value, or the module names no predefined key.
|
||||||
|
func (i *Inventory) configLayers(ctx context.Context, nodeID, nodeName, module string) ([]catalogue.Layer, error) {
|
||||||
|
rows, err := i.store.Pool().Query(ctx,
|
||||||
|
`select key, node is null, value from config where node is null or node = $1`, nodeID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
mesh, node := map[string]any{}, map[string]any{}
|
||||||
|
for rows.Next() {
|
||||||
|
var key string
|
||||||
|
var meshWide bool
|
||||||
|
var raw []byte
|
||||||
|
if err := rows.Scan(&key, &meshWide, &raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var v any
|
||||||
|
if err := json.Unmarshal(raw, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if meshWide {
|
||||||
|
mesh[key] = v
|
||||||
|
} else {
|
||||||
|
node[key] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(mesh)+len(node) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
// Only the module's preferences, read from its stored definition: a whole catalogue per module per
|
||||||
|
// node would be read for every machine planned.
|
||||||
|
var raw []byte
|
||||||
|
err = i.store.Pool().QueryRow(ctx, `select manifest -> 'preferences' from module where name = $1`, module).Scan(&raw)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) || len(raw) == 0 || string(raw) == "null" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var prefs map[string]catalogue.Preference
|
||||||
|
if err := json.Unmarshal(raw, &prefs); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s's preferences cannot be read: %w", module, err)
|
||||||
|
}
|
||||||
|
return catalogue.ConfigLayers(catalogue.Manifest{Module: module, Preferences: prefs}, mesh, node, nodeName), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetModuleValue sets or clears one key of a module's own layer on one node — its module-assignment config —
|
||||||
|
// keeping the rest of the layer, judged as `settings set` judges it, with the layer it replaces kept.
|
||||||
|
// value nil clears the key; the layer goes when no key is left.
|
||||||
|
func (i *Inventory) SetModuleValue(ctx context.Context, nodeName, module, key string, value any, setBy string) (
|
||||||
|
before any, had bool, err error) {
|
||||||
|
if nodeName == "" {
|
||||||
|
return nil, false, errors.New("there is no module layer for the whole mesh: a module's own value is " +
|
||||||
|
"set on one node (novox/hq issue 630); for every node, set mesh config for a predefined key")
|
||||||
|
}
|
||||||
|
layer, has, err := i.Layer(ctx, nodeName, module)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
values := map[string]any{}
|
||||||
|
for k, v := range layer {
|
||||||
|
values[k] = v
|
||||||
|
}
|
||||||
|
before, had = values[key]
|
||||||
|
if value == nil {
|
||||||
|
if !had {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
delete(values, key)
|
||||||
|
if len(values) == 0 && has {
|
||||||
|
return before, had, i.ClearSettingsBy(ctx, nodeName, module, setBy)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
values[key] = value
|
||||||
|
}
|
||||||
|
return before, had, i.SetSettingsBy(ctx, nodeName, module, values, setBy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigKeysSorted is the keys of a set of config values, sorted, for saying them.
|
||||||
|
func ConfigKeysSorted(values []ConfigValue) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []string
|
||||||
|
for _, v := range values {
|
||||||
|
if !seen[v.Key] {
|
||||||
|
seen[v.Key] = true
|
||||||
|
out = append(out, v.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
package inventory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dunstLike is a module whose font-size falls back to the predefined key font-size (novox/hq issue 630).
|
||||||
|
func dunstLike() catalogue.Manifest {
|
||||||
|
return catalogue.Manifest{Module: "dunst", Version: "1",
|
||||||
|
Preferences: map[string]catalogue.Preference{
|
||||||
|
"font-size": {Type: catalogue.TypeNumber, Default: float64(10), Predefined: "font-size", Why: "readable at 100 DPI"},
|
||||||
|
"width": {Type: catalogue.TypeNumber, Default: float64(250), Why: "forty characters"},
|
||||||
|
},
|
||||||
|
Resources: []map[string]any{{"id": "rc", "type": "file", "path": "/etc/dunstrc", "mode": "0644",
|
||||||
|
"content": "font = Inter ${setting:font-size}\nwidth = ${setting:width}\n"}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fontList() catalogue.Predefined {
|
||||||
|
return catalogue.Predefined{"font-size": {Type: catalogue.TypeNumber, Why: "the size text is drawn at"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// effectiveOn is a module's value for a key on a node, and its source, as composition reads them.
|
||||||
|
func effectiveOn(t *testing.T, inv *Inventory, node, key string) (any, string) {
|
||||||
|
t.Helper()
|
||||||
|
layers, err := inv.SettingsFor(t.Context(), node, "dunst")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, s := range catalogue.Effective(dunstLike(), layers) {
|
||||||
|
if s.Key == key {
|
||||||
|
if s.FromDefault {
|
||||||
|
return s.Value, catalogue.DefaultLayer
|
||||||
|
}
|
||||||
|
return s.Value, s.From
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("no %s", key)
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each of the four sources in turn, the most specific winning, and clearing each falls back to the next
|
||||||
|
// one down (novox/hq issue 630).
|
||||||
|
func TestAConfigValueComesFromFourSourcesTheMostSpecificWinning(t *testing.T) {
|
||||||
|
inv := fresh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
for _, n := range []string{"g14", "shanks"} {
|
||||||
|
if _, err := inv.AddNode(ctx, n); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := inv.SetPredefined(ctx, fontList(), "novox/mesh-catalog", "c1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := inv.RegisterModule(ctx, dunstLike(), Source{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, n := range []string{"g14", "shanks"} {
|
||||||
|
if _, err := inv.Assign(ctx, n, "dunst"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
want := func(node string, value float64, from string) {
|
||||||
|
t.Helper()
|
||||||
|
v, f := effectiveOn(t, inv, node, "font-size")
|
||||||
|
if v != value || f != from {
|
||||||
|
t.Fatalf("font-size on %s: %v from %q, want %v from %q", node, v, f, value, from)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
want("g14", 10, "default")
|
||||||
|
|
||||||
|
if err := inv.SetConfig(ctx, "font-size", "", float64(11), "the operator", "a larger default everywhere"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want("g14", 11, "mesh config")
|
||||||
|
want("shanks", 11, "mesh config")
|
||||||
|
|
||||||
|
if err := inv.SetConfig(ctx, "font-size", "g14", float64(13), "the operator", "a dense screen"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want("g14", 13, "node config (g14)")
|
||||||
|
want("shanks", 11, "mesh config")
|
||||||
|
|
||||||
|
if _, _, err := inv.SetModuleValue(ctx, "g14", "dunst", "font-size", float64(15), "the operator"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want("g14", 15, "g14")
|
||||||
|
want("shanks", 11, "mesh config")
|
||||||
|
|
||||||
|
// A preference without predefined reads only its default and the module's own value.
|
||||||
|
if v, f := effectiveOn(t, inv, "g14", "width"); v != float64(250) || f != "default" {
|
||||||
|
t.Fatalf("width: %v from %s", v, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clearing each falls back to the next source down.
|
||||||
|
if _, _, err := inv.SetModuleValue(ctx, "g14", "dunst", "font-size", nil, "the operator"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want("g14", 13, "node config (g14)")
|
||||||
|
if removed, err := inv.ClearConfig(ctx, "font-size", "g14", "the operator", "back to the mesh's"); err != nil || !removed {
|
||||||
|
t.Fatalf("clear node config: %v %v", removed, err)
|
||||||
|
}
|
||||||
|
want("g14", 11, "mesh config")
|
||||||
|
if removed, err := inv.ClearConfig(ctx, "font-size", "", "the operator", "back to the default"); err != nil || !removed {
|
||||||
|
t.Fatalf("clear mesh config: %v %v", removed, err)
|
||||||
|
}
|
||||||
|
want("g14", 10, "default")
|
||||||
|
if _, has, _ := inv.Layer(ctx, "g14", "dunst"); has {
|
||||||
|
t.Fatal("the module's layer was left empty rather than removed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// What each replaced is kept, with why.
|
||||||
|
past, err := inv.ConfigHistory(ctx, "font-size", "g14")
|
||||||
|
if err != nil || len(past) != 1 || past[0].Value != float64(13) || past[0].Why != "a dense screen" ||
|
||||||
|
past[0].ReplacedBy != "clear by the operator" || past[0].ReplacedWhy != "back to the mesh's" {
|
||||||
|
t.Fatalf("node config history: %+v %v", past, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mesh and node config hold values for predefined keys only, of their type; a module value needs a node.
|
||||||
|
func TestConfigRefusesAKeyNotPredefinedAWrongTypeAndAModuleValueForTheWholeMesh(t *testing.T) {
|
||||||
|
inv := fresh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
if _, err := inv.AddNode(ctx, "g14"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err := inv.SetConfig(ctx, "font-size", "", float64(12), "the operator", "bigger")
|
||||||
|
if !errors.Is(err, ErrNotPredefined) || !strings.Contains(err.Error(), "the list holds none yet") {
|
||||||
|
t.Fatalf("a key before any list: %v", err)
|
||||||
|
}
|
||||||
|
if err := inv.SetPredefined(ctx, fontList(), "", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err = inv.SetConfig(ctx, "colour", "g14", "blue", "the operator", "blue")
|
||||||
|
if !errors.Is(err, ErrNotPredefined) || !strings.Contains(err.Error(), "node config") ||
|
||||||
|
!strings.Contains(err.Error(), "font-size") {
|
||||||
|
t.Fatalf("a key not on the list: %v", err)
|
||||||
|
}
|
||||||
|
if err := inv.SetConfig(ctx, "font-size", "", "large", "the operator", "bigger"); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "is a number") {
|
||||||
|
t.Fatalf("a string for a number: %v", err)
|
||||||
|
}
|
||||||
|
if err := inv.SetConfig(ctx, "font-size", "", float64(12), "the operator", ""); err == nil {
|
||||||
|
t.Fatal("a value without why was kept")
|
||||||
|
}
|
||||||
|
if err := inv.SetConfig(ctx, "font-size", "nowhere", float64(12), "the operator", "x"); err == nil {
|
||||||
|
t.Fatal("node config for a node the mesh does not know was kept")
|
||||||
|
}
|
||||||
|
if _, _, err := inv.SetModuleValue(ctx, "", "dunst", "font-size", float64(12), "the operator"); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "no module layer for the whole mesh") {
|
||||||
|
t.Fatalf("a module value for the whole mesh: %v", err)
|
||||||
|
}
|
||||||
|
if values, err := inv.Config(ctx); err != nil || len(values) != 0 {
|
||||||
|
t.Fatalf("a refused value was kept: %v %v", values, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registration refuses a preference whose predefined key is not on the list, or whose type differs.
|
||||||
|
func TestRegistrationJudgesPreferencesAgainstThePredefinedList(t *testing.T) {
|
||||||
|
inv := fresh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
err := inv.RegisterModule(ctx, dunstLike(), Source{})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `predefined key "font-size"`) {
|
||||||
|
t.Fatalf("a predefined key before any list: %v", err)
|
||||||
|
}
|
||||||
|
if err := inv.SetPredefined(ctx, catalogue.Predefined{"font-size": {Type: catalogue.TypeString, Why: "x"}}, "", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err = inv.RegisterModule(ctx, dunstLike(), Source{})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "is a number") || !strings.Contains(err.Error(), "is a string") {
|
||||||
|
t.Fatalf("a type other than the list's: %v", err)
|
||||||
|
}
|
||||||
|
if err := inv.SetPredefined(ctx, fontList(), "", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := inv.RegisterModule(ctx, dunstLike(), Source{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The module layer for the whole mesh is moved into each assigned node's layer by migration 0096: the mesh's
|
||||||
|
// values under the node's, maps merged key by key, every replaced layer kept in the history (novox/hq issue 630).
|
||||||
|
func TestTheWholeMeshModuleLayerMovesIntoEachAssignedNode(t *testing.T) {
|
||||||
|
inv := fresh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
for _, n := range []string{"novox", "g14", "shanks"} {
|
||||||
|
if _, err := inv.AddNode(ctx, n); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m := catalogue.Manifest{Module: "artifact-store-tools", Version: "1",
|
||||||
|
Resources: []map[string]any{{"id": "conf", "type": "file", "path": "/etc/t.json", "content": "{}", "merge": "json"}}}
|
||||||
|
if err := inv.RegisterModule(ctx, m, Source{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, n := range []string{"novox", "g14"} {
|
||||||
|
if _, err := inv.Assign(ctx, n, "artifact-store-tools"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// As held before the migration: a whole-mesh layer, and g14's own layer over it.
|
||||||
|
if err := inv.KeepSettings(ctx, "", "artifact-store-tools", map[string]any{
|
||||||
|
"older-store-names": "registry-api.novox.be", "nested": map[string]any{"a": 1, "b": 1}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := inv.SetSettings(ctx, "g14", "artifact-store-tools", map[string]any{
|
||||||
|
"nested": map[string]any{"b": 2}, "own": true}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
migrations, err := Migrations()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var moving string
|
||||||
|
for _, mig := range migrations {
|
||||||
|
if mig.Number == 96 {
|
||||||
|
moving = mig.SQL[strings.Index(mig.SQL, "create function pg_temp"):]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if moving == "" {
|
||||||
|
t.Fatal("migration 0096 moves no whole-mesh layer")
|
||||||
|
}
|
||||||
|
tx, err := inv.store.Pool().Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, moving); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
layer := func(node string) string {
|
||||||
|
t.Helper()
|
||||||
|
values, _, err := inv.Layer(ctx, node, "artifact-store-tools")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(values)
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
if got := layer("novox"); got != `{"nested":{"a":1,"b":1},"older-store-names":"registry-api.novox.be"}` {
|
||||||
|
t.Fatalf("novox's layer: %s", got)
|
||||||
|
}
|
||||||
|
if got := layer("g14"); got != `{"nested":{"a":1,"b":2},"older-store-names":"registry-api.novox.be","own":true}` {
|
||||||
|
t.Fatalf("g14's layer, the mesh's under its own: %s", got)
|
||||||
|
}
|
||||||
|
if got := layer("shanks"); got != "null" && got != "{}" {
|
||||||
|
t.Fatalf("a node the module is not assigned to gained a layer: %s", got)
|
||||||
|
}
|
||||||
|
if _, has, _ := inv.Layer(ctx, "", "artifact-store-tools"); has {
|
||||||
|
t.Fatal("the whole-mesh layer is still there")
|
||||||
|
}
|
||||||
|
mesh, err := inv.SettingsHistory(ctx, "", "artifact-store-tools")
|
||||||
|
if err != nil || len(mesh) != 1 || mesh[0].ReplacedBy != "moved-to-nodes" ||
|
||||||
|
mesh[0].Values["older-store-names"] != "registry-api.novox.be" {
|
||||||
|
t.Fatalf("the whole-mesh layer's history: %+v %v", mesh, err)
|
||||||
|
}
|
||||||
|
past, err := inv.SettingsHistory(ctx, "g14", "artifact-store-tools")
|
||||||
|
if err != nil || len(past) != 1 || past[0].ReplacedBy != "moved-to-nodes" || past[0].Values["own"] != true {
|
||||||
|
t.Fatalf("g14's replaced layer: %+v %v", past, err)
|
||||||
|
}
|
||||||
|
// And composition reads the same values on each node as before.
|
||||||
|
layers, err := inv.SettingsFor(ctx, "g14", "artifact-store-tools")
|
||||||
|
if err != nil || len(layers) != 1 {
|
||||||
|
t.Fatalf("g14's layers: %+v %v", layers, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ func TestRegisteringAModuleAgainKeepsWhatTheMeshHoldsForIt(t *testing.T) {
|
|||||||
if err := inv.RegisterModule(ctx, m, Source{}); err != nil {
|
if err := inv.RegisterModule(ctx, m, Source{}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := inv.SetSettings(ctx, "", "step-ca", map[string]any{"issuer": "the mesh"}); err != nil {
|
if err := inv.KeepSettings(ctx, "", "step-ca", map[string]any{"issuer": "the mesh"}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := inv.SetSettings(ctx, "anchor", "step-ca", map[string]any{"port": 9000}); err != nil {
|
if err := inv.SetSettings(ctx, "anchor", "step-ca", map[string]any{"port": 9000}); err != nil {
|
||||||
@@ -162,7 +162,7 @@ func TestDiscardingAModuleSaysWhatWentWithIt(t *testing.T) {
|
|||||||
if err := inv.RegisterModule(ctx, withOwnSecret(manifest("step-ca", nil, nil), "password"), Source{}); err != nil {
|
if err := inv.RegisterModule(ctx, withOwnSecret(manifest("step-ca", nil, nil), "password"), Source{}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := inv.SetSettings(ctx, "", "step-ca", map[string]any{"issuer": "the mesh"}); err != nil {
|
if err := inv.KeepSettings(ctx, "", "step-ca", map[string]any{"issuer": "the mesh"}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := inv.AcceptSecretForModule(ctx, "anchor", "step-ca", "password", "sealed"); err != nil {
|
if err := inv.AcceptSecretForModule(ctx, "anchor", "step-ca", "password", "sealed"); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
-- A config value comes from four sources, the last set winning (novox/hq issue 630): the module's
|
||||||
|
-- default, mesh config, node config, and the module's own value on the node. Mesh config and node config
|
||||||
|
-- hold values only for the predefined keys, a fixed list kept in the catalogue (`predefined.json`). An
|
||||||
|
-- assignment is one module on one node: there is no module layer for the whole mesh any more.
|
||||||
|
|
||||||
|
-- The list of predefined keys, as the catalogue's predefined.json last said it. Replaced whole when a
|
||||||
|
-- build of the catalogue carries the file; `repository` and `commit` say which.
|
||||||
|
create table predefined_key (
|
||||||
|
key text primary key,
|
||||||
|
type text not null check (type in ('number', 'string', 'boolean')),
|
||||||
|
why text not null,
|
||||||
|
repository text,
|
||||||
|
commit text,
|
||||||
|
listed_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Mesh config (node null) and node config: one value per predefined key, and per node. Not a foreign key
|
||||||
|
-- to predefined_key: a key the list drops keeps its value, said as reaching nothing, rather than losing it.
|
||||||
|
create table config (
|
||||||
|
key text not null,
|
||||||
|
node uuid references node(id) on delete cascade,
|
||||||
|
value jsonb not null,
|
||||||
|
set_at timestamptz not null default now(),
|
||||||
|
set_by text,
|
||||||
|
why text not null
|
||||||
|
);
|
||||||
|
create unique index config_for_the_mesh on config (key) where node is null;
|
||||||
|
create unique index config_for_a_node on config (key, node) where node is not null;
|
||||||
|
|
||||||
|
-- Every value that was replaced or cleared, with who set it and why, and who replaced it and why.
|
||||||
|
create table config_history (
|
||||||
|
key text not null,
|
||||||
|
node uuid references node(id) on delete cascade,
|
||||||
|
value jsonb not null,
|
||||||
|
set_at timestamptz,
|
||||||
|
set_by text,
|
||||||
|
why text,
|
||||||
|
replaced_at timestamptz not null default now(),
|
||||||
|
-- set · clear
|
||||||
|
replaced_by text not null,
|
||||||
|
replaced_why text
|
||||||
|
);
|
||||||
|
create index config_history_by_key on config_history (key, node, replaced_at desc);
|
||||||
|
|
||||||
|
-- The module layer for the whole mesh goes: each one is laid under the layer of every node the module is
|
||||||
|
-- assigned to — the mesh's values first, the node's over them, maps merged key by key as composition
|
||||||
|
-- merged them — so every machine composes what it did. Every layer replaced, and the mesh-wide layer
|
||||||
|
-- itself, is kept in settings_history (ADR 0217) with the reason `moved-to-nodes`. A mesh-wide layer of
|
||||||
|
-- a module assigned nowhere is kept in the history only: no machine read it.
|
||||||
|
create function pg_temp.config_630_merge(under jsonb, over jsonb) returns jsonb language plpgsql immutable as $$
|
||||||
|
declare
|
||||||
|
merged jsonb;
|
||||||
|
k text;
|
||||||
|
begin
|
||||||
|
if jsonb_typeof(under) is distinct from 'object' or jsonb_typeof(over) is distinct from 'object' then
|
||||||
|
return over;
|
||||||
|
end if;
|
||||||
|
merged := under;
|
||||||
|
for k in select jsonb_object_keys(over) loop
|
||||||
|
if merged ? k then
|
||||||
|
merged := jsonb_set(merged, array[k], pg_temp.config_630_merge(merged -> k, over -> k));
|
||||||
|
else
|
||||||
|
merged := merged || jsonb_build_object(k, over -> k);
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
return merged;
|
||||||
|
end
|
||||||
|
$$;
|
||||||
|
|
||||||
|
insert into settings_history (node, module, values, set_at, replaced_by, set_by)
|
||||||
|
select t.node, t.module, t.values, t.set_at, 'moved-to-nodes', t.set_by
|
||||||
|
from settings t
|
||||||
|
join settings s on s.module = t.module and s.node is null
|
||||||
|
where t.node is not null
|
||||||
|
and exists (select 1 from assignment a where a.node = t.node and a.module = t.module);
|
||||||
|
|
||||||
|
insert into settings_history (node, module, values, set_at, replaced_by, set_by)
|
||||||
|
select null, module, values, set_at, 'moved-to-nodes', set_by from settings where node is null;
|
||||||
|
|
||||||
|
insert into settings (node, module, values, set_at, set_by)
|
||||||
|
select a.node, s.module, s.values, now(),
|
||||||
|
coalesce(s.set_by || '; ', '') || 'moved from the whole mesh''s layer to this node''s (novox/hq issue 630)'
|
||||||
|
from settings s
|
||||||
|
join assignment a on a.module = s.module
|
||||||
|
where s.node is null
|
||||||
|
on conflict (node, module) where node is not null
|
||||||
|
do update set values = pg_temp.config_630_merge(excluded.values, settings.values),
|
||||||
|
set_at = now(),
|
||||||
|
set_by = coalesce(settings.set_by || '; ', '') ||
|
||||||
|
'the whole mesh''s layer laid under it (novox/hq issue 630)';
|
||||||
|
|
||||||
|
delete from settings where node is null;
|
||||||
@@ -40,15 +40,17 @@ func TestASettingIsJudgedWhereItIsStored(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// A key that reaches nothing is refused too, where the spelling can still be fixed; a module
|
// A key that reaches nothing is refused too, where the spelling can still be fixed; a module
|
||||||
// with a mergeable file takes any key.
|
// with a mergeable file takes any key.
|
||||||
err = inv.SetSettings(ctx, "", "plain", map[string]any{"colour": "blue"})
|
err = inv.SetSettings(ctx, "anchor", "plain", map[string]any{"colour": "blue"})
|
||||||
if err == nil || !strings.Contains(err.Error(), "reach nothing") || !strings.Contains(err.Error(), `"colour"`) {
|
if err == nil || !strings.Contains(err.Error(), "reach nothing") || !strings.Contains(err.Error(), `"colour"`) {
|
||||||
t.Fatalf("a stray key was stored: %v", err)
|
t.Fatalf("a stray key was stored: %v", err)
|
||||||
}
|
}
|
||||||
if err := inv.SetSettings(ctx, "", "web", map[string]any{"colour": "blue"}); err != nil {
|
// There is no module layer for the whole mesh (novox/hq issue 630): a new one is refused.
|
||||||
t.Fatal(err)
|
err = inv.SetSettings(ctx, "", "web", map[string]any{"colour": "blue"})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "no module layer for the whole mesh") {
|
||||||
|
t.Fatalf("a module layer for the whole mesh was stored: %v", err)
|
||||||
}
|
}
|
||||||
// The mesh-wide layer is under the node's when the node's is judged.
|
if err := inv.SetSettings(ctx, "anchor", "web", map[string]any{"colour": "blue",
|
||||||
if err := inv.SetSettings(ctx, "anchor", "web", map[string]any{catalogue.PortsSetting: map[string]any{"8080": 10000}}); err != nil {
|
catalogue.PortsSetting: map[string]any{"8080": 10000}}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// A kept network is for an adopted machine only (rule 4).
|
// A kept network is for an adopted machine only (rule 4).
|
||||||
|
|||||||
@@ -56,10 +56,10 @@ func TestTheLayerASetReplacesIsKeptAndReadable(t *testing.T) {
|
|||||||
t.Errorf("the replaced layer still has what the set dropped: %+v", past[1])
|
t.Errorf("the replaced layer still has what the set dropped: %+v", past[1])
|
||||||
}
|
}
|
||||||
// The mesh-wide layer keeps its own history, apart from the node's.
|
// The mesh-wide layer keeps its own history, apart from the node's.
|
||||||
if err := inv.SetSettings(ctx, "", "web", map[string]any{"colour": "green"}); err != nil {
|
if err := inv.KeepSettings(ctx, "", "web", map[string]any{"colour": "green"}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := inv.SetSettings(ctx, "", "web", map[string]any{"colour": "grey"}); err != nil {
|
if err := inv.KeepSettings(ctx, "", "web", map[string]any{"colour": "grey"}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
mesh, err := inv.SettingsHistory(ctx, "", "web")
|
mesh, err := inv.SettingsHistory(ctx, "", "web")
|
||||||
|
|||||||
@@ -250,6 +250,11 @@ type BuildResult struct {
|
|||||||
// the repository's default is the branch it follows.
|
// the repository's default is the branch it follows.
|
||||||
Branches []string `json:"branches,omitempty"`
|
Branches []string `json:"branches,omitempty"`
|
||||||
|
|
||||||
|
// Predefined is the built repository's list of predefined keys, its root predefined.json at the commit
|
||||||
|
// built, when it has one (novox/hq issue 630). The controller keeps it, when it registers the build,
|
||||||
|
// as the list mesh and node config hold values for. Empty from a builder that predates it.
|
||||||
|
Predefined json.RawMessage `json:"predefined,omitempty"`
|
||||||
|
|
||||||
// SourceFingerprint is what the build was made from, hashed (novox/hq issue 280): the module's
|
// SourceFingerprint is what the build was made from, hashed (novox/hq issue 280): the module's
|
||||||
// tree at the commit, the trees of the contexts it read, its bases and toolchains by digest. Two
|
// tree at the commit, the trees of the contexts it read, its bases and toolchains by digest. Two
|
||||||
// builds with one fingerprint are one build, however their digests differ — an image is not
|
// builds with one fingerprint are one build, however their digests differ — an image is not
|
||||||
|
|||||||
@@ -69,6 +69,7 @@
|
|||||||
"issue",
|
"issue",
|
||||||
"token",
|
"token",
|
||||||
"settings",
|
"settings",
|
||||||
|
"config",
|
||||||
"command",
|
"command",
|
||||||
"queue",
|
"queue",
|
||||||
"cancel",
|
"cancel",
|
||||||
|
|||||||
Reference in New Issue
Block a user