Compare commits

..
Author SHA1 Message Date
mesh-admin 660649eeed Test config end to end into a composed file, and hold the settings tests to no whole-mesh module layer (hq issue 630)
mesh/delivery stopped: the pull request closed unmerged
mesh/merge-gate out of date: judged against trunk 6f1a05366c1d, the trunk is now 8ddf8b700558; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 6f1a05366c1d, the trunk is now 8ddf8b700558; judged again on a recheck or when it is merged
2026-10-13 13:19:30 +00:00
mesh-admin c9b63f61bd Serve config as a seat verb, and say each value's source as one of the four (hq issue 630)
The listings named a node's own value "the node", which now could be node config; they say "<module> on <node>" for the module's own value instead.
2026-10-13 13:18:33 +00:00
mesh-admin 3f9b935c25 Keep the list from a catalogue build, and carry it and mesh and node config through the facts and the gate (hq issue 630)
The merge gate must compose with the same config the mesh holds, and judge a catalogue change against the list it brings.
2026-10-13 13:17:48 +00:00
mesh-admin 0c31ca0eaf Add the config command: mesh, node and module values in one call, with why and history (hq issue 630)
One place to set each of the three sources over a default, refusing what is not a predefined key and a module value without a node, recorded in the hand-act log like any act by hand.
2026-10-13 13:16:54 +00:00
mesh-admin ea4c318404 Test the four sources, the refusals and the move of whole-mesh module layers (hq issue 630) 2026-10-13 13:15:46 +00:00
mesh-admin df5254f308 Keep mesh and node config with their history, and move whole-mesh module layers onto each assigned node (hq issue 630)
An assignment is one module on one node, so a new whole-mesh layer is refused and the ones held are laid under each assigned node's layer, every replaced layer kept, so no machine composes differently.
2026-10-13 13:14:42 +00:00
mesh-admin fba6a7c454 Carry the catalogue's predefined.json with a build, and declare the config verb (hq issue 630)
The controller only learns manifests through builds, so the list of predefined keys travels the same way: the builder reads it at the catalogue's root, judges the manifest against it and hands it on.
2026-10-13 13:13:24 +00:00
mesh-admin 3d16515771 Declare preferences with a type and a predefined key, and lay mesh and node config between default and node (hq issue 630)
A preference may fall back to one reviewed mesh-wide or node-wide value instead of a module layer for the whole mesh; like a default, config fills ${setting:} only.
2026-10-13 13:08:37 +00:00
49 changed files with 2197 additions and 1515 deletions
+1
View File
@@ -327,6 +327,7 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
}
result.Against = built.Against
result.SourceFingerprint = built.Source
result.Predefined = built.Predefined
result.Trunk, result.OnTrunk, result.Branches = built.Trunk, built.OnTrunk, built.Branches
for _, r := range built.Read {
result.Read = append(result.Read, link.ReadRepository{Repository: r.Repository, Ref: r.Ref})
+27
View File
@@ -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 errors.Is(err, inventory.ErrSuperseded) {
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
}
// 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
}
+45
View File
@@ -47,6 +47,13 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error {
shelf := catalogue.Shelf{}
faulted := map[string]bool{}
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 {
raw, err := os.ReadFile(path)
if err != nil {
@@ -92,6 +99,20 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error {
failed += len(named)
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
}
@@ -285,3 +306,27 @@ func manifestsUnder(dir string) ([]string, error) {
sort.Strings(found)
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
}
}
+383
View File
@@ -0,0 +1,383 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"sort"
"strings"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
)
// configCommand reads and sets config values (novox/hq issue 630). A value resolves from four sources, the
// last set winning: the module's default, mesh config, node config, and the module's own value on a node
// (its module-assignment config). Mesh and node config hold values only for the predefined keys, the
// catalogue's list; a module's own value is set on one node, never for the whole mesh.
//
// config [list] [--node <node>] [--module <module>]
// config set <key> <value> [--node <node> [--module <module>]] --why <text>
// config clear <key> [--node <node> [--module <module>]] --why <text>
// config history <key> [--node <node>]
//
// A set or clear is recorded in the hand-act log with the caller and why, kept in the history, and takes
// effect at the next push of the machines it reaches, which it names — as `settings set` does.
func configCommand(ctx context.Context, args []string) error {
sub := "list"
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
sub, args = args[0], args[1:]
}
set := flag.NewFlagSet("config", flag.ContinueOnError)
node := set.String("node", "", "one node: its node config, or with --module the module's own value there")
module := set.String("module", "", "a module: its own value on --node (module-assignment config)")
acts := addHandActFlags(set)
positionals, err := parseAround(set, args)
if err != nil {
return err
}
if *module != "" && *node == "" && sub != "list" {
return fmt.Errorf("config: %s names no node, and there is no module layer for the whole mesh — an "+
"assignment is one module on one node (novox/hq issue 630). Give node, or set mesh config for a "+
"predefined key without module. Nothing was changed", *module)
}
open, err := openStores(ctx)
if err != nil {
return err
}
defer open.Close()
inv := open.inventory
switch sub {
case "list":
if len(positionals) > 0 {
return errors.New("config list [--node <node>] [--module <module>]")
}
return configList(ctx, inv, *node, *module)
case "history":
if len(positionals) != 1 || *module != "" {
return errors.New("config history <key> [--node <node>] — a module's own values are in `settings show <module> --node <node> --history`")
}
past, err := inv.ConfigHistory(ctx, positionals[0], *node)
if err != nil {
return err
}
where := configWhere(*node, "")
if len(past) == 0 {
fmt.Printf("%s, %s: no value has been replaced\n", positionals[0], where)
return nil
}
for _, p := range past {
v, _ := json.Marshal(p.Value)
fmt.Printf("%s, %s: %s until %s (%s", positionals[0], where, v,
p.ReplacedAt.Local().Format("2006-01-02 15:04:05"), p.ReplacedBy)
if p.ReplacedWhy != "" {
fmt.Printf(": %s", p.ReplacedWhy)
}
fmt.Printf("); %s, because %s\n", orUnknown(p.SetBy), orUnknown(p.Why))
}
return nil
case "set", "clear":
default:
return fmt.Errorf("config has no %q; it has list, set, clear and history", sub)
}
// set and clear.
if err := acts.require("config " + sub); err != nil {
return err
}
why := strings.TrimSpace(*acts.why)
var key string
var value any
switch {
case sub == "set" && len(positionals) == 2:
key = positionals[0]
if err := json.Unmarshal([]byte(positionals[1]), &value); err != nil {
// A bare word is a string: `config set theme dark` means "dark".
value = positionals[1]
}
if value == nil {
return errors.New("config set takes a value; to remove one, config clear")
}
case sub == "clear" && len(positionals) == 1:
key = positionals[0]
default:
return errors.New("config set <key> <value> [--node <node> [--module <module>]] --why <text>, or " +
"config clear <key> [--node <node> [--module <module>]] --why <text>")
}
if *node != "" {
if _, err := inv.NodeByName(ctx, *node); err != nil {
return err
}
}
where := configWhere(*node, *module)
// The module's own value on one node: a preference it declares, of its type, judged as `settings set`
// judges a layer, its trusted keys refused through a verb exactly as there (novox/hq issue 339, 340).
if *module != "" {
shelf, err := inv.Catalogue(ctx)
if err != nil {
return err
}
m, known := shelf[*module]
if !known {
return fmt.Errorf("%w: %s", inventory.ErrNoSuchModule, *module)
}
p, declared := catalogue.PreferencesOf(m)[key]
if !declared {
return fmt.Errorf("config: %s declares no preference %q; config sets a module's preferences (%s) — "+
"any other setting is the settings verb's. Nothing was changed", *module, key,
preferencesOrNone(sortedPreferenceKeys(m)))
}
if value != nil {
if err := catalogue.ConfigValueProblem(fmt.Sprintf("%s's preference %q", *module, key), p.Type, value); err != nil {
return err
}
}
before, _, err := inv.Layer(ctx, *node, *module)
if err != nil {
return err
}
after := map[string]any{}
for k, v := range before {
after[k] = v
}
if value == nil {
delete(after, key)
} else {
after[key] = value
}
if err := refuseTerminalSettingsThroughAVerb(ctx, inv, before, after, *module, *node); err != nil {
return err
}
acts.record(ctx, "config", configActArgs(sub, positionals, *node, *module))
was, had, err := inv.SetModuleValue(ctx, *node, *module, key, value, setByWords()+": "+why)
if err != nil {
return err
}
sayConfigChange(key, where, value, was, had)
fmt.Printf(" takes effect at the next push: run `plan %s --diff` to see it, `push %s` to send it\n", *node, *node)
return nil
}
// Mesh config or node config: a predefined key only, judged before the act is recorded.
if value != nil {
if err := inv.JudgeConfig(ctx, key, *node, value); err != nil {
return err
}
}
acts.record(ctx, "config", configActArgs(sub, positionals, *node, ""))
before, had := configValueOf(ctx, inv, key, *node)
if value == nil {
removed, err := inv.ClearConfig(ctx, key, *node, setByWords(), why)
if err != nil {
return err
}
if !removed {
fmt.Printf("%s, %s: no value was set; nothing changed\n", key, where)
return nil
}
} else if err := inv.SetConfig(ctx, key, *node, value, setByWords(), why); err != nil {
return err
}
sayConfigChange(key, where, value, before, had)
reached, err := reachedBy(ctx, inv, key, *node)
if err != nil {
return err
}
if len(reached) == 0 {
fmt.Println(" no module assigned there falls back to it yet; nothing to push")
return nil
}
fmt.Printf(" takes effect at the next push of %s: run `push %s` to send it\n", strings.Join(reached, ", "),
strings.Join(reached, " "))
return nil
}
// configList lists the predefined keys with their mesh and node config, then each module's preferences
// with the value on each node it is assigned to and where it comes from.
func configList(ctx context.Context, inv *inventory.Inventory, node, module string) error {
if node != "" {
if _, err := inv.NodeByName(ctx, node); err != nil {
return err
}
}
list, err := inv.Predefined(ctx)
if err != nil {
return err
}
values, err := inv.Config(ctx)
if err != nil {
return err
}
var b strings.Builder
if len(list) == 0 {
fmt.Fprintf(&b, "no predefined keys: the list arrives with a build of the catalogue carrying %s\n",
catalogue.PredefinedFile)
} else {
fmt.Fprintf(&b, "predefined keys (the catalogue's %s), with mesh config and node config:\n", catalogue.PredefinedFile)
}
for _, key := range list.Keys() {
k := list[key]
fmt.Fprintf(&b, " %s (%s): %s\n", key, k.Type, k.Why)
held := false
for _, v := range values {
if v.Key != key || (node != "" && v.Node != "" && v.Node != node) {
continue
}
held = true
shown, _ := json.Marshal(v.Value)
fmt.Fprintf(&b, " %s: %s — %s, because %s\n", configWhere(v.Node, ""), shown, orUnknown(v.SetBy), v.Why)
}
if !held {
b.WriteString(" no mesh or node config: each module's default\n")
}
}
for _, key := range inventory.ConfigKeysSorted(values) {
if _, on := list[key]; on {
continue
}
for _, v := range values {
if v.Key == key {
shown, _ := json.Marshal(v.Value)
fmt.Fprintf(&b, " %s is no longer a predefined key, and %s still holds %s for it: it reaches nothing; "+
"config clear %s%s\n", key, configWhere(v.Node, ""), shown, key, nodeFlag(v.Node))
}
}
}
entries, err := inv.Catalogued(ctx)
if err != nil {
return err
}
var listed []preferencesOf
for _, e := range entries {
m := e.Manifest
if len(catalogue.PreferencesOf(m)) == 0 || (module != "" && m.Module != module) {
continue
}
if node != "" && !containsString(e.On, node) {
continue
}
p := preferencesOf{Manifest: m, On: map[string][]catalogue.SettingSource{}}
for _, n := range e.On {
if node != "" && n != node {
continue
}
p.Nodes = append(p.Nodes, n)
layers, err := inv.SettingsFor(ctx, n, m.Module)
if err != nil {
return err
}
p.On[n] = catalogue.Effective(m, layers)
}
listed = append(listed, p)
}
b.WriteString("\npreferences, and on each node the value and where it comes from (default, mesh config, " +
"node config, or the module on the node):\n")
b.WriteString(describePreferences(listed))
fmt.Print(b.String())
return nil
}
// configWhere is how a config value's place is said.
func configWhere(node, module string) string {
switch {
case module != "":
return module + " on " + node
case node != "":
return catalogue.NodeConfigLayer(node)
}
return catalogue.MeshConfigLayer
}
// configValueOf is the value mesh config (node "") or a node's config holds for a key.
func configValueOf(ctx context.Context, inv *inventory.Inventory, key, node string) (any, bool) {
values, err := inv.Config(ctx)
if err != nil {
return nil, false
}
for _, v := range values {
if v.Key == key && v.Node == node {
return v.Value, true
}
}
return nil, false
}
// reachedBy is the nodes a value of mesh config (node "") or a node's config for a key reaches: those
// running a module whose preference falls back to it.
func reachedBy(ctx context.Context, inv *inventory.Inventory, key, node string) ([]string, error) {
entries, err := inv.Catalogued(ctx)
if err != nil {
return nil, err
}
nodes := map[string]bool{}
for _, e := range entries {
if !containsString(catalogue.PredefinedNamed(e.Manifest), key) {
continue
}
for _, n := range e.On {
if node == "" || n == node {
nodes[n] = true
}
}
}
out := make([]string, 0, len(nodes))
for n := range nodes {
out = append(out, n)
}
sort.Strings(out)
return out, nil
}
func sayConfigChange(key, where string, value, before any, had bool) {
was := "nothing"
if had {
w, _ := json.Marshal(before)
was = string(w)
}
if value == nil {
fmt.Printf("%s, %s: cleared (was %s); the next source down applies\n", key, where, was)
return
}
now, _ := json.Marshal(value)
fmt.Printf("%s, %s: %s (was %s); the value it replaced is kept\n", key, where, now, was)
}
func configActArgs(sub string, positionals []string, node, module string) []string {
args := append([]string{sub}, positionals...)
if node != "" {
args = append(args, "--node", node)
}
if module != "" {
args = append(args, "--module", module)
}
return args
}
func sortedPreferenceKeys(m catalogue.Manifest) []string {
declared := catalogue.PreferencesOf(m)
keys := make([]string, 0, len(declared))
for k := range declared {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
func orUnknown(s string) string {
if strings.TrimSpace(s) == "" {
return "not kept"
}
return s
}
func preferencesOrNone(keys []string) string {
if len(keys) == 0 {
return "it declares none"
}
return strings.Join(keys, ", ")
}
+187
View File
@@ -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())
}
}
+38
View File
@@ -263,6 +263,37 @@ func gatherFacts(ctx context.Context, open *stores, busVersion string) (snapshot
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)
hostShelf := shelf[hostModule]
meshWide := map[string]bool{}
@@ -280,6 +311,9 @@ func gatherFacts(ctx context.Context, open *stores, busVersion string) (snapshot
if n.HostVersion != "" {
engines[n.HostVersion] = true
}
if values := nodeConfig[n.Name]; len(values) > 0 {
m.Config = scrub.Values(values)
}
m.System = systemOf(hostShelf, n.HostVersion)
m.Libc = libcOf(m.System)
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
}
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 !meshWide[module] {
meshWide[module] = true
+1 -1
View File
@@ -31,7 +31,7 @@ func aMeshWithSecrets(t *testing.T) (*stores, []string) {
}
}
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 {
t.Fatal(err)
}
+2
View File
@@ -89,6 +89,8 @@ var handActVerbs = []handActVerb{
{Verb: handActIssued, Decision: "a credential the provider's software issued, kept for its consumers (ADR 0305)"},
{Verb: "hand-act drill", Decision: "a drill is a person's deliberate test of the mesh, never a repair"},
// A person's decisions by design.
// A config value is what a person chooses the mesh's modules use (novox/hq issue 630), never a repair.
{Verb: "config", Decision: "a config value is a person's choice of what the modules use, not a repair (issue 630)"},
{Verb: "retire approve", Decision: "nothing is retired past its bound without a person (ADR 0230)"},
{Verb: "retire reject", Decision: "keeping a consumer active is a person's word (ADR 0230)"},
{Verb: "cleanup delete", Decision: "nothing retired is deleted without a person (ADR 0230)"},
+2
View File
@@ -171,6 +171,8 @@ func run() error {
return adoptCommand(ctx, args[1:])
case "settings":
return settingsCommand(ctx, args[1:])
case "config":
return configCommand(ctx, args[1:])
case "secret":
return secretCommand(ctx, args[1:])
case "operator":
+60 -5
View File
@@ -296,6 +296,24 @@ func judgeChange(ctx context.Context, in mergeCheckInput) (mergeVerdict, error)
}
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 {
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.
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 {
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)
after, tried, notes, err := composeEveryMachine(ctx, in, change, sources, added)
after, tried, notes, err := composeEveryMachine(ctx, in, change, sources, added, changedList)
if err != nil {
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
// 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,
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)
if err != nil {
return nil, nil, nil, err
}
defer drop()
notes, err := raiseFromFacts(ctx, open, in.facts, shelf, sources)
notes, err := raiseFromFacts(ctx, open, in.facts, shelf, sources, list)
if err != nil {
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
// 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,
sources map[string]inventory.Source) ([]string, error) {
sources map[string]inventory.Source, list catalogue.Predefined) ([]string, error) {
inv := open.inventory
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))
for name := range shelf {
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 {
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())))
@@ -1312,3 +1357,13 @@ func firstOr(items, otherwise []string) string {
}
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
}
+31 -20
View File
@@ -571,18 +571,14 @@ func settingsCommand(ctx context.Context, args []string) error {
return err
}
m, ok := known[positionals[0]]
if !ok || len(m.Settings) == 0 {
if !ok || len(catalogue.PreferencesOf(m)) == 0 {
return nil
}
var layers []catalogue.Layer
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
} 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 {
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
for _, e := range entries {
m := e.Manifest
if len(m.Settings) == 0 || (only != "" && m.Module != only) {
if len(catalogue.PreferencesOf(m)) == 0 || (only != "" && m.Module != only) {
continue
}
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)
for _, v := range values {
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 {
d, _ := json.Marshal(v.Default)
fmt.Fprintf(&b, "; the default is %s", d)
@@ -723,28 +723,27 @@ func describePreferences(modules []preferencesOf) string {
on = "on " + strings.Join(p.Nodes, ", ")
}
fmt.Fprintf(&b, "%s (%s)\n", p.Manifest.Module, on)
keys := make([]string, 0, len(p.Manifest.Settings))
for k := range p.Manifest.Settings {
declared := catalogue.PreferencesOf(p.Manifest)
keys := make([]string, 0, len(declared))
for k := range declared {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
d := p.Manifest.Settings[k]
d := declared[k]
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 _, s := range p.On[n] {
if s.Key != k {
continue
}
v, _ := json.Marshal(s.Value)
from := s.From
if s.FromDefault {
from = catalogue.DefaultLayer
} else if s.From != catalogue.MeshWideLayer {
from = "the node"
}
fmt.Fprintf(&b, " %s: %s (%s)\n", n, v, from)
fmt.Fprintf(&b, " %s: %s (%s)\n", n, v, valueSource(p.Manifest.Module, n, s))
}
}
}
@@ -752,6 +751,18 @@ func describePreferences(modules []preferencesOf) 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.
func nodeFlag(node string) string {
if node == "" {
+1 -1
View File
@@ -88,7 +88,7 @@ func checkWait(module, machine string, w inventory.Wait, f operatorWaitFacts) er
case w.Provision != "":
return checkProvisionWait(module, machine, w, m, f)
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 nil
+1 -1
View File
@@ -211,7 +211,7 @@ func (r *rootReader) executeServed(ctx context.Context, machine string) (bool, s
}
holders++
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)
if err != nil {
served = true
+71 -177
View File
@@ -18,9 +18,8 @@ package main
// it beside the layer (`settings` says it back), and records the warrant in the hand-act log on the bus, where a
// person's decisions are read; the router edits the ask on every channel to its outcome. No seat event of its
// own: nothing consumes one, and the installer's first user list in the node-engine's repository names every
// controller event, so one would cost a node-engine change for a fact without a reader. No push is left for
// afterwards: the warrant that sets the layer opens the walk that sends it (novox/hq ADR 0344, settings_walk.go),
// so the operator's one Approve is the act that makes the change take effect.
// controller event, so one would cost a node-engine change for a fact without a reader. The push afterwards is a
// separate act, as it is for a layer set at the terminal.
//
// **What the operator reads** is the module, the machine, each key with its exact new value and, for a changed
// key, the value it replaces. A value that may not leave the mesh (an address, a path, a secret's shape: the
@@ -28,13 +27,6 @@ package main
// ask says so, and the whole is read with `settings proposals <id>` — whose fingerprint must be the one on the
// phone. Fail closed: a proposal nothing can carry to the operator is refused at once, in words, and never left
// waiting for an answer that cannot come.
//
// **On a channel that proves who answers, the values are shown WHOLE** (novox/hq issue 383, the operator's
// decision of 2026-10-10): a mount point, a share name or a private address is the thing being approved and must
// be readable, so the ask carries them whole beside the explanation (asks.Ask.Whole), the router shows that only
// on a kind that verifies its sender, and only a value shaped like a secret is withheld there. The message is the
// headline, one line per key, who proposed it and when; the fingerprint and how to read the proposal whole are
// the Details answer's (asks.Ask.Details). The masking stays for conditions and for channels that prove nothing.
import (
"context"
@@ -55,7 +47,6 @@ import (
"git.novox.be/novox/mesh-sdk/go/asks"
"github.com/novox/mesh-controller/internal/conditions"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
"github.com/novox/mesh-controller/internal/outward"
)
@@ -163,48 +154,47 @@ func (p settingsProposal) ask(id string, machines []string) (asks.Ask, map[strin
if len([]rune(headline)) > asks.HeadlineLength {
headline = verb + " settings?"
}
// The message reads at a glance (issues 383 and 639): a summary of what changes where, then at most three
// points, a key each; every key with its whole value, who proposed it, the fingerprint and how to read it at the
// terminal are its Details, which the router notes after the answers.
shown, shownAll, shownWhole := p.change(machines, false)
whole, wholeAll, _ := p.change(machines, true)
summary := p.summary()
compose := func(points []string) string {
return strings.Join(append([]string{summary}, points...), "\n")
shown, whole := p.change(machines)
var b strings.Builder
if p.Clear {
fmt.Fprintf(&b, "Clear the settings of %s on %s, back to what the module says?\n\n", p.Module, p.where())
} else {
fmt.Fprintf(&b, "Set the settings of %s on %s to these values?\n\n", p.Module, p.where())
}
// The Details: every setting it sets, one line each, uncut — whole where the sender is proven (WholeDetails),
// masked elsewhere — then who proposed it, the fingerprint and how to read it at the terminal.
details := func(lines []string) string {
var d strings.Builder
d.WriteString(strings.Join(lines, "\n") + "\n")
d.WriteString(p.onApprove() + "\n")
fmt.Fprintf(&d, "Proposed by %s at %s. Fingerprint %s.\n", sayable(p.From, machines), p.At.Local().Format("15:04 on 2 Jan"),
fingerprint(p.Digest))
if !shownWhole {
d.WriteString("Where who answers is not proven, its values are shown as ‹address›, ‹path› or ‹withheld›.\n")
}
fmt.Fprintf(&d, "Read it whole, with this fingerprint: settings proposals %s at the controller's terminal, or the "+
"controller's settings verb with proposal %s through the mesh MCP server.", id, id)
return d.String()
fmt.Fprintf(&b, "Proposed by %s, at %s. ", sayable(p.From, machines), p.At.Local().Format("15:04 on 2 Jan"))
switch {
case p.Clear && p.HadLayer:
b.WriteString("The layer it removes:\n\n")
case p.Clear:
b.WriteString("There is no layer to remove; approving changes nothing.")
case !p.HadLayer:
b.WriteString("There is no layer yet; this is the whole of it:\n\n")
default:
b.WriteString("The layer is replaced whole; what changes against it:\n\n")
}
q := asks.Ask{ID: id, Headline: headline, Explanation: compose(shown), Who: asks.Operator,
b.WriteString(shown)
fmt.Fprintf(&b, "\n\nFingerprint %s.", fingerprint(p.Digest))
if !whole {
b.WriteString(" Parts shown as ‹address›, ‹path› or ‹withheld› may not leave the mesh: read it whole, with " +
"this fingerprint, through the mesh MCP server (mesh-controller.settings, proposal " + id + ") or with " +
"mesh-cli settings proposals " + id + ".")
}
if p.Clear {
b.WriteString(" Approved, the layer is removed at once and the machine takes it at its next push.")
} else {
b.WriteString(" Approved, the layer is set at once and the machine takes it at its next push.")
}
q := asks.Ask{ID: id, Headline: headline, Explanation: b.String(), Who: asks.Operator,
Expires: p.At.Add(askApproveFor), OnExpiry: "the proposal is discarded; nothing changes",
About: p.about(), Details: details(shownAll)}
// Only where a value was masked: an ask whose values all pass the content rule shows the same everywhere.
if w := compose(whole); w != q.Explanation {
q.Whole = w
}
if w := details(wholeAll); w != q.Details {
q.WholeDetails = w
}
About: p.about()}
options := map[string]int{}
for i, act := range p.actions(id) {
binds, _ := asks.ActDigest(boundAct(act))
oid := optionID(act.Label)
options[oid] = i
does := "the settings are set and sent to " + p.reaches() + " at once, each machine judged"
does := "the settings are set; nothing is pushed yet"
if p.Clear {
does = "the layer is removed and that is sent to " + p.reaches() + " at once, each machine judged"
does = "the layer is removed; nothing is pushed yet"
}
if act.Arguments["answer"] == answerDecline {
does = "nothing changes; the proposal is discarded"
@@ -215,99 +205,55 @@ func (p settingsProposal) ask(id string, machines []string) (asks.Ask, map[strin
return q, options
}
// summary is the proposal's first line, what changes where: how many settings, how many stay, and that Approve sends
// the layer at once by a walk, one machine at a time (novox/hq ADR 0344); the rest of what Approve does
// (onApprove) is the Details'.
func (p settingsProposal) summary() string {
if p.Clear {
if !p.HadLayer {
return "There is no layer to remove; approving changes nothing."
}
return fmt.Sprintf("Removes the %d setting(s) of %s set on %s; approved, the layer is removed and that is sent at once "+
"by a walk to %s, one machine at a time, each judged.", len(leaves(p.Before, "")), p.Module, p.where(), p.reaches())
}
added, changed, removed := settingsChange(p.Before, p.Values)
n := len(added) + len(changed) + len(removed)
unchanged := len(leaves(p.Values, "")) - len(added) - len(changed)
if n == 0 {
return fmt.Sprintf("Nothing changes: the %d setting(s) of %s on %s are as they stand.", unchanged, p.Module, p.where())
}
stay := ""
if unchanged > 0 {
stay = fmt.Sprintf(", and %d stay as they are", unchanged)
}
return fmt.Sprintf("Changes %d setting(s) of %s on %s%s; approved, the layer is set and sent at once by a walk to %s, "+
"one machine at a time, each judged.", n, p.Module, p.where(), stay, p.reaches())
}
// shownMost is the most of a change the phone is shown, in bytes; the rest is read whole with `settings proposals`.
const shownMost = 1400
// change is what changes: as at most asks.MostPoints points for the glance, as every line whole for the note, and
// whether every value was shown exactly: "+ key: value"
// added, "~ key: value (was: old)" changed, "- key (was: old)" removed. Each value is shown under the content rule
// (sayableValue), or — exact, for a channel that proves who answers — as it is, withheld only when shaped like a
// secret (wholeValue). More changes than points: the first two, and the keys of the rest; a point longer than a
// phone shows at a glance is cut and says so. The note carries every line uncut (the review of #304: Approve sets
// exactly what the operator can read).
func (p settingsProposal) change(machines []string, exact bool) ([]string, []string, bool) {
// change is what changes, line by line, each value shown under the content rule, and whether every value was
// shown whole: "+ key: value" added, "~ key: value (was: old)" changed, "- key (was: old)" removed, and the
// count of keys unchanged.
func (p settingsProposal) change(machines []string) (string, bool) {
whole := true
say := func(v any) string {
s, w := sayableValue(v, machines)
if exact {
s, w = wholeValue(v, machines)
}
whole = whole && w
return s
}
type line struct{ key, text string }
var lines []line
var lines []string
if p.Clear {
was := leaves(p.Before, "")
for _, k := range layerKeys(was) {
lines = append(lines, line{k, fmt.Sprintf("- %s: %s", k, say(was[k]))})
lines = append(lines, fmt.Sprintf("- %s: %s", k, say(was[k])))
}
} else {
added, changed, removed := settingsChange(p.Before, p.Values)
was, now := leaves(p.Before, ""), leaves(p.Values, "")
for _, k := range added {
lines = append(lines, line{k, fmt.Sprintf("+ %s: %s", k, say(now[k]))})
lines = append(lines, fmt.Sprintf("+ %s: %s", k, say(now[k])))
}
for _, k := range changed {
lines = append(lines, line{k, fmt.Sprintf("~ %s: %s (was: %s)", k, say(now[k]), say(was[k]))})
lines = append(lines, fmt.Sprintf("~ %s: %s (was: %s)", k, say(now[k]), say(was[k])))
}
for _, k := range removed {
lines = append(lines, line{k, fmt.Sprintf("- %s (was: %s)", k, say(was[k]))})
lines = append(lines, fmt.Sprintf("- %s (was: %s)", k, say(was[k])))
}
unchanged := len(now) - len(added) - len(changed)
switch {
case len(lines) == 0 && unchanged > 0:
lines = append(lines, fmt.Sprintf("= nothing changes: the %d key(s) are as they stand", unchanged))
case unchanged > 0:
lines = append(lines, fmt.Sprintf("= %d key(s) unchanged", unchanged))
}
}
all := make([]string, len(lines))
for i, l := range lines {
all[i] = l.text
}
// What the note holds of a value: whole where the glance is the whole one (shown only where the sender is
// proven, with the whole details beside it), under the content rule elsewhere — so the marker says no more than
// the note it points to (the review of ADR 0347).
inNote := "in the note"
if exact {
inNote = "whole in the note"
}
var out []string
for i, l := range lines {
if len(lines) > asks.MostPoints && i == asks.MostPoints-1 {
var rest []string
for _, r := range lines[i:] {
rest = append(rest, r.key)
}
out = append(out, fmt.Sprintf("and %d more, each %s: %s", len(rest), inNote, strings.Join(rest, ", ")))
break
out := strings.Join(lines, "\n")
if len(out) > shownMost {
cut := shownMost
for cut > 0 && out[cut] != '\n' {
cut--
}
out = append(out, l.text)
out = out[:cut] + fmt.Sprintf("\n… NOT SHOWN IN FULL here: %d more bytes", len(strings.Join(lines, "\n"))-cut)
whole = false
}
// A point too long for a glance is cut, and says so: its value is the note's.
cut := "… (cut: " + inNote + ")"
for i, l := range out {
if r := []rune(l); len(r) > asks.PointLength {
out[i] = string(r[:asks.PointLength-len([]rune(cut))]) + cut
}
}
return out, all, whole
return out, whole
}
func layerKeys(m map[string]any) []string {
@@ -354,27 +300,6 @@ func sayableValue(v any, machines []string) (string, bool) {
return out, out == text
}
// wholeValue is a value as a channel that proves who answers is shown it (asks.Ask.Whole), and whether it was
// shown whole: as it is, unless it is shaped like a secret (outward.Secret), which is withheld whole — never in
// part, so no half of a key reaches the phone.
func wholeValue(v any, machines []string) (string, bool) {
text, ok := v.(string)
if !ok {
raw, err := json.Marshal(v)
if err != nil {
return markWithheld, false
}
text = string(raw)
}
if text == "" {
return `""`, true
}
if _, ok := outward.Secret(text, machines...); !ok {
return markWithheld, false
}
return text, true
}
// sayable is a text with what may not leave the mesh replaced in place by a marker; what the markers cannot make
// pass is withheld whole.
func sayable(text string, machines []string) string {
@@ -507,12 +432,6 @@ func (pr proposer) propose(ctx context.Context, in proposeInput) (string, error)
return refuse("the ask's words would carry %s, which may not leave the mesh, and the change could not be "+
"shown without it; %s", refusal, atTheTerminalInstead(in))
}
// The whole words are shown only where the sender is proven, and never a secret's shape: wholeValue withholds
// one, and the router would refuse the ask if one were left, so it is refused here first, in words.
if refusal, ok := outward.Secret(q.Whole, machines...); !ok {
return refuse("the change shown whole would carry %s, which may not leave the mesh on any channel; %s",
refusal, atTheTerminalInstead(in))
}
// Fail closed, before anything is kept: no router, no grant, no channel means no ask.
if pr.routerHere != nil {
here, err := pr.routerHere(ctx)
@@ -600,29 +519,15 @@ func (pr proposer) propose(ctx context.Context, in proposeInput) (string, error)
fingerprint(p.Digest))
fmt.Fprintf(&b, " until %s nothing changes: the layer is set only on Approve, and discarded on Decline or at expiry\n",
q.Expires.Local().Format("2006-01-02 15:04"))
fmt.Fprintf(&b, " `settings proposals %s` shows it whole; once approved, a walk sends it to %s at once, one machine at "+
"a time, each judged, and its end is told as every walk's is (novox/hq ADR 0344)", id, p.reaches())
fmt.Fprintf(&b, " `settings proposals %s` shows it whole; once approved, `push %s` sends it", id, pushWord(p.Node))
return b.String(), nil
}
// reaches is the machines an approved layer is sent to, in words: the one it names, or every machine running the
// module.
func (p settingsProposal) reaches() string {
if p.Node == "" {
return "every machine running " + p.Module
func pushWord(node string) string {
if node == "" {
return "--behind"
}
return p.Node
}
// onApprove is what the ask says Approve does (novox/hq ADR 0344 §4): the one Approve sets the layer and sends it.
func (p settingsProposal) onApprove() string {
what := "the layer is set"
if p.Clear {
what = "the layer is removed"
}
return fmt.Sprintf("Approved, %s and sent at once to %s, one machine at a time, each judged, every build there "+
"kept; its end is told as every walk's is. A module that reads its settings only when it starts takes them at "+
"its next start.", what, p.reaches())
return node
}
func newProposalID() string {
@@ -637,6 +542,11 @@ const (
// proposeCommand is `settings propose`, on this controller's stores and bus.
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
if !clear {
if valuesArg == "" {
@@ -883,22 +793,6 @@ func setLayerIn(open *stores) setOnWarrant {
changed = "nothing changed"
}
}
// **And sends it** (novox/hq ADR 0344): the layer the operator approved is walked to the machines it reaches
// at once. A walk that cannot be opened is said as the act's failure — the layer is set and nothing sends it.
walked, err := openSettingsWalk(ctx, open, inventory.PlanSettings{Module: p.Module, Node: p.Node, Ask: askID,
Change: changed})
if err != nil {
return "", fmt.Errorf("%s (ask %s) is set, and the walk that sends it could not be opened: %v — `push %s` sends it",
changed, askID, err, pushTo(p.Node))
}
return changed + " (ask " + askID + "); " + walked, nil
return changed + " (ask " + askID + ")", nil
}
}
// pushTo is the push that sends a layer by hand when its walk could not: the machine, or every one behind.
func pushTo(node string) string {
if node == "" {
return "--behind"
}
return node
}
+14 -189
View File
@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"slices"
@@ -117,43 +116,24 @@ func TestAProposalAsksAtTheLevelApproveWithTheExactChange(t *testing.T) {
if q.Options[0].Binds == q.Options[1].Binds {
t.Error("Approve and Decline bind the same act")
}
// The message reads at a glance (issues 383 and 639): a summary, then one point per key — and nothing else:
// who proposed it, the fingerprint and the how-to are the Details, which the router notes after the answers.
summary := "Changes 3 setting(s) of mounts on shanks; approved, the layer is set and sent at once by a walk to shanks, one machine at a time, each judged."
if q.Explanation != summary+"\n+ sources: recalbox=‹address›@‹path›:ro\n~ shares: library=‹path› (was: none)\n- old (was: x)" {
t.Errorf("the explanation:\n%s", q.Explanation)
}
if err := asks.Glance(q.Explanation); err != nil {
t.Error(err)
for _, line := range []string{
"Set the settings of mounts on shanks to these values?",
"Proposed by g14/claude-code, through the mesh-controller seat, at " + r.now.Local().Format("15:04 on 2 Jan") + ".",
"+ sources: recalbox=‹address›@‹path›:ro",
"~ shares: library=‹path› (was: none)",
"- old (was: x)",
"Fingerprint " + fingerprint(layerDigest(mountsSources)) + ".",
"read it whole, with this fingerprint",
} {
if !strings.Contains(q.Explanation, line) {
t.Errorf("the explanation lacks %q:\n%s", line, q.Explanation)
}
}
for _, leak := range []string{"nas.lan", "/mnt/recalbox", "/mnt/library", "smb://"} {
if strings.Contains(q.Explanation, leak) {
t.Errorf("the explanation carries %q, which may not leave the mesh", leak)
}
}
// Where the sender is proven, the values whole: the mount point and the share are what is approved.
if q.Whole != summary+"\n+ sources: recalbox=smb://nas.lan/recalbox@/mnt/recalbox:ro\n~ shares: library=/mnt/library (was: none)\n- old (was: x)" ||
asks.Glance(q.Whole) != nil {
t.Errorf("the whole words:\n%s", q.Whole)
}
for _, line := range []string{
"Proposed by g14/claude-code, through the mesh-controller seat at " + r.now.Local().Format("15:04 on 2 Jan") +
". Fingerprint " + fingerprint(layerDigest(mountsSources)) + ".",
"Where who answers is not proven, its values are shown as ‹address›, ‹path› or ‹withheld›.",
"Read it whole, with this fingerprint: settings proposals " + kept(r).ID + " at the controller's terminal",
} {
if !strings.Contains(q.Details, line) {
t.Errorf("Details lack %q:\n%s", line, q.Details)
}
}
if refusal, ok := outward.Check(q.Details, "anchor", "laptop", "shanks"); !ok {
t.Errorf("the router would withhold a line of the details: %s", refusal)
}
for _, howTo := range []string{"ingerprint", "settings proposals", "settings verb", "not proven", "Proposed by"} {
if strings.Contains(q.Explanation, howTo) || strings.Contains(q.Whole, howTo) {
t.Errorf("the message carries the how-to %q", howTo)
}
}
all := []string{q.Headline, q.Explanation, q.OnExpiry}
for _, o := range q.Options {
all = append(all, o.Label, o.Does)
@@ -161,9 +141,6 @@ func TestAProposalAsksAtTheLevelApproveWithTheExactChange(t *testing.T) {
if refusal, ok := outward.Check(strings.Join(all, "\n"), "anchor", "laptop", "shanks"); !ok {
t.Errorf("the router would refuse the ask: %s", refusal)
}
if refusal, ok := outward.Secret(q.Whole, "anchor", "laptop", "shanks"); !ok {
t.Errorf("the router would refuse the whole words: %s", refusal)
}
// Kept as the controller's own ask, about no condition, with the proposal whole and its digests.
kept := r.theProposal(t)
p := kept.Proposal
@@ -196,98 +173,11 @@ func TestAMeshWideLayerIsProposed(t *testing.T) {
}
q := r.askSent(t)
if q.Headline != "Set notes on the whole mesh?" || q.About != "settings.notes.mesh" ||
q.Explanation != "Changes 1 setting(s) of notes on the whole mesh; approved, the layer is set and sent at once by a walk to every machine running notes, one machine at a time, each judged.\n+ x: 1" ||
q.Whole != "" {
!strings.Contains(q.Explanation, "Set the settings of notes on the whole mesh to these values?") {
t.Errorf("%+v", q)
}
}
// kept is the one proposal the rig keeps.
func kept(r *proposerRig) asked {
for _, a := range r.store {
if a.Proposal != nil {
return a
}
}
return asked{}
}
// The switch between the masked and the whole change (issue 383): the same change, under the content rule and
// exact, differs in the masked values alone; a value shaped like a secret is withheld in both, whole, with its
// key still named; and a change no value of which is masked carries no whole words of its own.
func TestAProposalShowsItsValuesWholeOnlyWhereTheSenderIsProvenAndNeverASecret(t *testing.T) {
machines := []string{"anchor", "laptop", "shanks"}
r := newProposerRig(t)
values := map[string]any{"shares": "media=/storage/media", "sources": "recalbox=smb://nas.lan/recalbox@/mnt/recalbox", "font": "Inter 13"}
if _, err := r.pr.propose(context.Background(), proposeInput{module: "mounts", node: "shanks", values: values}); err != nil {
t.Fatal(err)
}
q := r.askSent(t)
masked, _, _ := kept(r).Proposal.change(machines, false)
whole, _, _ := kept(r).Proposal.change(machines, true)
if !strings.HasSuffix(q.Explanation, "\n"+strings.Join(masked, "\n")) || !strings.HasSuffix(q.Whole, "\n"+strings.Join(whole, "\n")) {
t.Fatalf("the ask does not carry the change masked and whole:\n%s\n--\n%s", q.Explanation, q.Whole)
}
if want := []string{"+ font: Inter 13", "+ shares: media=‹path›", "+ sources: recalbox=‹address›@‹path›"}; strings.Join(masked, "\n") != strings.Join(want, "\n") {
t.Errorf("masked: %q", masked)
}
if want := []string{"+ font: Inter 13", "+ shares: media=/storage/media", "+ sources: recalbox=smb://nas.lan/recalbox@/mnt/recalbox"}; strings.Join(whole, "\n") != strings.Join(want, "\n") {
t.Errorf("whole: %q", whole)
}
if _, ok := outward.Secret(q.Whole, "shanks"); !ok {
t.Error("the router would refuse the whole words")
}
// Mutation: the masked and the whole change differ in exactly the lines whose value was masked.
differ := 0
for i := range masked {
if masked[i] != whole[i] {
differ++
if !strings.Contains(masked[i], "‹") || strings.Contains(whole[i], "‹") {
t.Errorf("the lines differ otherwise than by the mask:\n%s\n%s", masked[i], whole[i])
}
}
}
if differ != 2 {
t.Errorf("%d lines differ, and the mask covered 2", differ)
}
// A value shaped like a secret is withheld in both, whole, with its key still named.
r1 := newProposerRig(t)
secrets := map[string]any{"github": "ghp_abcdefghijklmnopqrstuvwxyz0123456789", "cert": "-----BEGIN CERTIFICATE-----"}
if _, err := r1.pr.propose(context.Background(), proposeInput{module: "mounts", node: "shanks", values: secrets}); err != nil {
t.Fatal(err)
}
q1 := r1.askSent(t)
for _, line := range []string{"+ cert: ‹withheld›", "+ github: ‹withheld›"} {
if !strings.Contains(q1.Explanation, line) {
t.Errorf("lacks %q:\n%s", line, q1.Explanation)
}
}
for _, secret := range []string{"ghp_", "BEGIN CERTIFICATE"} {
if strings.Contains(q1.Explanation, secret) || strings.Contains(q1.Whole, secret) || strings.Contains(q1.Details, secret) {
t.Errorf("the secret %q reaches the phone", secret)
}
}
// More keys than points (issue 639): the first two, then the keys of the rest, read whole at the terminal.
r3 := newProposerRig(t)
five := map[string]any{"a": "1", "b": "2", "c": "3", "d": "4", "e": "5"}
if _, err := r3.pr.propose(context.Background(), proposeInput{module: "notes", node: "laptop", values: five}); err != nil {
t.Fatal(err)
}
if q3 := r3.askSent(t); !strings.HasSuffix(q3.Explanation, "\n+ a: 1\n+ b: 2\nand 3 more, each in the note: c, d, e") || asks.Glance(q3.Explanation) != nil ||
!strings.Contains(q3.Details, "settings proposals") {
t.Errorf("five keys:\n%s", q3.Explanation)
}
// No value masked: the message is the same everywhere, and the ask says no whole words.
r2 := newProposerRig(t)
if _, err := r2.pr.propose(context.Background(), proposeInput{module: "dunst", node: "laptop", values: map[string]any{"font-size": 13, "width": 500}}); err != nil {
t.Fatal(err)
}
if q2 := r2.askSent(t); q2.Whole != "" || !strings.HasSuffix(q2.Explanation, "\n+ font-size: 13\n+ width: 500") ||
strings.Contains(q2.Details, "not proven") {
t.Errorf("%+v", q2)
}
}
// A proposal expired unanswered is kept so by the reconciling, and a warrant for it afterwards sets nothing.
func TestAnExpiredProposalIsKeptExpired(t *testing.T) {
r := newAskerRig(t)
@@ -315,8 +205,7 @@ func TestAClearIsProposedAndShowsWhatItRemoves(t *testing.T) {
}
q := r.askSent(t)
if q.Headline != "Clear notes on laptop?" || !strings.Contains(q.Explanation, "- places.data.owner: 1001:1001") ||
!strings.Contains(q.Explanation, "- places.data.path: ‹path›") || !strings.Contains(q.Whole, "- places.data.path: /srv/notes") ||
!strings.HasPrefix(q.Explanation, "Removes the 2 setting(s) of notes set on laptop; approved, the layer is removed and that is sent at once by a walk to laptop, one machine at a time") {
!strings.Contains(q.Explanation, "- places.data.path: ‹path›") {
t.Errorf("%+v", q)
}
if p := r.theProposal(t).Proposal; !p.Clear || p.Digest != layerDigest(r.before) || len(r.judged) != 0 {
@@ -855,67 +744,3 @@ func captureStdout(t *testing.T, f func()) string {
_ = w.Close()
return <-done
}
// The review of #304: Approve sets exactly what the operator can read. A proposal of five keys, one value 300
// characters long, shows two keys at a glance, says what it cut, and carries every key with its whole value in its
// Details for a channel that proves who answers (WholeDetails), and every key masked for one that does not.
func TestEveryKeyAndItsWholeValueReachTheOperator(t *testing.T) {
var long strings.Builder
long.WriteString("library=/storage")
for i := 0; long.Len() < 300; i++ {
long.WriteString(fmt.Sprintf("/album-%02d", i))
}
value := long.String()[:300]
values := map[string]any{"a": "1", "b": "2", "c": "3", "d": "4", "shares": value}
r := newProposerRig(t)
if _, err := r.pr.propose(context.Background(), proposeInput{module: "mounts", node: "shanks", values: values}); err != nil {
t.Fatal(err)
}
q := r.askSent(t)
if err := q.Check(r.now); err != nil {
t.Fatal(err)
}
// What the operator reads on a proving channel: the whole words, then the note's whole details.
visible := q.Whole + "\n" + q.WholeDetails
if q.Whole == "" {
visible = q.Explanation + "\n" + q.WholeDetails
}
for k, v := range values {
if !strings.Contains(visible, "+ "+k+": "+v.(string)) {
t.Errorf("the operator never reads %s whole:\n%s", k, visible)
}
}
// The masked glance points to a note that holds the values masked, and says no more; the whole glance, shown only
// beside the whole details, says they are whole there.
if asks.Glance(q.Explanation) != nil || !strings.HasSuffix(q.Explanation, "\nand 3 more, each in the note: c, d, shares") ||
strings.Contains(q.Explanation, "whole in the note") || !strings.HasSuffix(q.Whole, "\nand 3 more, each whole in the note: c, d, shares") {
t.Errorf("the glance:\n%s", q.Explanation)
}
for k := range values {
if !strings.Contains(q.Details, "+ "+k+": ") {
t.Errorf("the masked details lack %s:\n%s", k, q.Details)
}
}
if strings.Contains(q.Details, "/storage") {
t.Errorf("the masked details show a path:\n%s", q.Details)
}
if refusal, ok := outward.Check(q.Details, "shanks"); !ok {
t.Errorf("the router would withhold the masked details: %s", refusal)
}
if _, ok := outward.Secret(q.WholeDetails, "shanks"); !ok {
t.Error("the router would refuse the whole details")
}
// A value too long for a glance is cut there, and says so.
r2 := newProposerRig(t)
if _, err := r2.pr.propose(context.Background(), proposeInput{module: "mounts", node: "shanks", values: map[string]any{"shares": value}}); err != nil {
t.Fatal(err)
}
q2 := r2.askSent(t)
// The cut point is 160 characters, its marker inside the bound.
if last := q2.Whole[strings.LastIndex(q2.Whole, "\n")+1:]; len([]rune(last)) != asks.PointLength {
t.Errorf("the cut point is %d characters, not %d: %q", len([]rune(last)), asks.PointLength, last)
}
if !strings.HasSuffix(q2.Whole, "… (cut: whole in the note)") || !strings.HasSuffix(q2.Explanation, "‹path›") && !strings.HasSuffix(q2.Explanation, "… (cut: in the note)") || !strings.Contains(q2.WholeDetails, "+ shares: "+value) || asks.Glance(q2.Whole) != nil {
t.Errorf("the cut is not said, or the note lacks the whole:\n%s\n--\n%s", q2.Whole, q2.WholeDetails)
}
}
+1 -30
View File
@@ -2,7 +2,6 @@ package main
import (
"context"
"errors"
"fmt"
"sort"
@@ -84,27 +83,6 @@ func everyBuildKept(ctx context.Context, node string) bool {
return on != "" && on == node
}
type keepForSettingsKey struct{}
// keepingBuildsForSettings is a context whose sends compose every module of one machine — recorded or rolling out —
// at the build that machine was last sent, as keepingEveryBuild does, for a settings walk's send (novox/hq ADR 0344):
// the layer the operator approved, and nothing of any module's new code. A build held for a person, or waiting
// for a gate, stays where it is; the walk that is its own carries it.
func keepingBuildsForSettings(ctx context.Context, node string) context.Context {
return context.WithValue(ctx, keepForSettingsKey{}, node)
}
// errSettingsCannotKeep is a settings walk's send refused because a build of its machine cannot be kept: what the
// machine was last sent is not known, or a build it runs is gone from the records. It does not clear by itself, so
// the walk fails on it rather than trying again (ADR 0344).
var errSettingsCannotKeep = errors.New("the settings cannot be sent with every build kept")
// settingsKeptOn is whether this context is a settings walk's send to this machine.
func settingsKeptOn(ctx context.Context, node string) bool {
on, _ := ctx.Value(keepForSettingsKey{}).(string)
return on != "" && on == node
}
// recordedKept is, for a send under keepingRecorded, every recorded module the machine was last sent a
// build of that the mesh's build is not identical to: module → the commit it keeps. Nil when the context
// keeps nothing, or when what the machine was last sent is not known (it is then held whole elsewhere —
@@ -114,7 +92,7 @@ func settingsKeptOn(ctx context.Context, node string) bool {
// 490), and a machine whose last send is not known refuses the send: there is nothing to keep it at, and
// composing the mesh's builds would be the very move the grants step must not make.
func recordedKept(ctx context.Context, open *stores, node string) (map[string]string, error) {
every := everyBuildKept(ctx, node) || settingsKeptOn(ctx, node)
every := everyBuildKept(ctx, node)
skip, on := keptExcept(ctx)
if !on && !every {
return nil, nil
@@ -128,9 +106,6 @@ func recordedKept(ctx context.Context, open *stores, node string) (map[string]st
return nil, err
}
if !known {
if settingsKeptOn(ctx, node) {
return nil, fmt.Errorf("%w: what %s was last sent is not known (novox/hq ADR 0344)", errSettingsCannotKeep, node)
}
if every {
return nil, fmt.Errorf("what %s was last sent is not known, so the bus's user list cannot be sent "+
"there alone with every build kept (novox/hq issue 490)", node)
@@ -191,10 +166,6 @@ func keepRecorded(ctx context.Context, open *stores, node string, shelf map[stri
if err != nil {
return nil, err
}
if !found && settingsKeptOn(ctx, node) {
return nil, fmt.Errorf("%w: %s runs %s's build %s, which the build records no longer hold (novox/hq ADR 0344)",
errSettingsCannotKeep, node, m, short(kept[m]))
}
if !found && everyBuildKept(ctx, node) {
return nil, fmt.Errorf("%s runs %s's build %s, which the build records no longer hold: the bus's user "+
"list cannot be sent there alone with it kept (novox/hq issue 490)", node, m, short(kept[m]))
+1 -10
View File
@@ -616,10 +616,6 @@ func releaseBacklog(ctx context.Context, open *stores, by string) (*inventory.Pl
}
for _, p := range plans {
if p.Release != nil {
if by != "" && p.Release.Settings != nil {
return nil, fmt.Errorf("%s is sending %s; what waits is released once it ends — `plans %s` says where it is",
p.ID, p.Repository, p.ID)
}
if by != "" {
return nil, fmt.Errorf("%s is already releasing what waits; `plans %s` says where it is", p.ID, p.ID)
}
@@ -645,9 +641,7 @@ func releaseBacklog(ctx context.Context, open *stores, by string) (*inventory.Pl
return nil, err
}
for _, p := range recent {
// A settings walk that failed holds nothing back (novox/hq ADR 0344 §6): its failure is its module's
// settings on one machine, said on its own, not a build no gate passed.
if p.Release == nil || p.Release.Settings != nil {
if p.Release == nil {
continue
}
if p.State == inventory.PlanFailed {
@@ -708,9 +702,6 @@ func releaseBacklog(ctx context.Context, open *stores, by string) (*inventory.Pl
// gate, or the machine being judged judged once more; the plan done when every machine is.
func advanceRelease(ctx context.Context, open *stores, p *inventory.Plan) (bool, error) {
r := p.Release
if r.Settings != nil {
return advanceSettingsWalk(ctx, open, p)
}
now := time.Now().UTC()
if r.Gate == nil {
heard, err := releaseHeard(ctx, open)
+43
View File
@@ -881,6 +881,48 @@ func (a *verbArguments) commandLine() ([]string, error) {
argv = append(argv, "--adopted")
}
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":
// 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
@@ -1614,6 +1656,7 @@ var commandReadForms = map[string]func(rest []string) bool{
"node": func(r []string) bool { return subIn(r, "list", "show") },
"module": func(r []string) bool { return subIn(r, "list") },
"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") },
"cleanup": func(r []string) bool { return subIn(r, "list") },
"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},
})
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"
if 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" +
" font-size, default 10: readable at 100 DPI\n" +
" desk: 10 (default)\n" +
" laptop: 16 (the node)\n" +
" laptop: 16 (dunst on laptop)\n" +
" width, default 250: forty characters\n" +
" desk: 300 (the mesh)\n" +
" laptop: 250 (default)\n"
@@ -96,7 +96,7 @@ func TestPreferencesListedFromTheStores(t *testing.T) {
t.Fatal(err)
}
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") {
t.Fatalf("the listing:\n%s", all)
}
-284
View File
@@ -1,284 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"slices"
"sort"
"strings"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
)
// An approved settings change is sent by a walk at once, and the operator's one Approve is its push (novox/hq ADR
// 0344, issue 641).
//
// On 2026-10-13 the operator approved a betterbird proposal on Telegram and nothing happened: the warrant set the
// layer, and the push stayed a separate act (ADR 0277 §18) that nobody took. Now the warrant that sets the layer
// opens a **settings walk**: the walk ADR 0236 opens for builds waiting for a gate, with a layer in place of the
// builds. Its order is every machine running the module that the layer reaches, the control node last; each is
// sent and its module judged there, from the machine's report on that send, before the next is sent. The send is
// the layer alone: every module there stays at the build its machine was last sent (keepingBuildsForSettings), so a
// build held for a person (ADR 0242) or waiting for a gate stays where it is — a policy of `record` holds a build,
// never a setting — and a send that would move a running module's data stops the walk (ADR 0217 §3). The walk says its start and end on `plan-moved` (ADR 0308), and the holder of the operator's
// channel tells its end once. A failure stops the walk and leaves the layer as approved; it holds back nothing
// else.
// settingsWalkPrefix names a settings walk's id.
const settingsWalkPrefix = "settings-"
// settingsWalkFor is what a settings walk says it is for, where a merge's walk says its repository.
func settingsWalkFor(s inventory.PlanSettings) string {
where := s.Node
if where == "" {
where = "the whole mesh"
}
return fmt.Sprintf("the settings of %s on %s", s.Module, where)
}
// settingsWalkOrder is the machines a layer reaches, in the order a settings walk sends them: every machine
// running the module, or the one the layer names when it runs it there, sorted, the control node last. Pure.
func settingsWalkOrder(node string, running, controllers []string) []string {
var order, last []string
for _, n := range running {
if node != "" && n != node {
continue
}
if slices.Contains(controllers, n) {
last = append(last, n)
} else {
order = append(order, n)
}
}
sort.Strings(order)
sort.Strings(last)
return append(order, last...)
}
// openSettingsWalk opens the walk that sends an approved layer, and answers the words of what it does: the
// machines it sends, or that no machine runs the module and nothing is sent. Called on the operator's warrant,
// after the layer is set.
var openSettingsWalk = func(ctx context.Context, open *stores, s inventory.PlanSettings) (string, error) {
inv := open.inventory
running, err := inv.Running(ctx, s.Module)
if err != nil {
return "", err
}
controllers, err := inv.Running(ctx, catalogue.ControllerSeatName)
if err != nil {
return "", err
}
order := settingsWalkOrder(s.Node, running, controllers)
if len(order) == 0 {
return fmt.Sprintf("no machine runs %s there, so there is nothing to send", s.Module), nil
}
release, err := inv.HoldPlans(ctx, true)
if err != nil {
return "", err
}
defer release()
now := time.Now().UTC()
p := inventory.Plan{ID: fmt.Sprintf("%s%d", settingsWalkPrefix, now.UnixNano()), Repository: settingsWalkFor(s),
Created: now, State: inventory.PlanRolling, Tiers: [][]string{{s.Module}}, Modules: map[string]*inventory.PlanModule{},
Release: &inventory.PlanRelease{Order: order, Settings: &s},
Note: fmt.Sprintf("approved (ask %s): sent to %s, one machine at a time, each judged", s.Ask,
strings.Join(order, ", "))}
if err := inv.SavePlan(ctx, &p); err != nil {
return "", err
}
fmt.Printf("%s: %s — %s\n", p.ID, p.Repository, p.Note)
return fmt.Sprintf("%s sends it to %s, one machine at a time, each judged", p.ID, strings.Join(order, ", ")), nil
}
// settingsDataMoves is what a settings walk's send to a machine would move of a running module's data: composed as
// that send composes it, every build kept, and compared with what the machine was last sent (ADR 0217 §3).
var settingsDataMoves = func(ctx context.Context, open *stores, node string) ([]dataMove, error) {
keeping := keepingBuildsForSettings(keepingRecorded(ctx), node)
gens, err := generators(keeping, open)
if err != nil {
return nil, err
}
plan, settings, err := planFor(keeping, open, node)
if err != nil {
return nil, err
}
declared, err := declarationWith(keeping, open, node, plan, settings, gens, Reading)
if err != nil {
return nil, err
}
body, err := declared.Body()
if err != nil {
return nil, err
}
return heldMoves(keeping, open.inventory, node, body, nil)
}
// judgingOn is the open walk, other than the one named, whose gate is judging on a machine; empty when none is. A
// settings walk sends a machine only when no other walk judges there: its send would set aside the declaration
// that walk's gate reads the machine's report against.
var judgingOn = func(ctx context.Context, open *stores, self, node string) (string, error) {
plans, err := open.inventory.OpenPlans(ctx)
if err != nil {
return "", err
}
return judgingIn(plans, self, node), nil
}
// judgingIn is judgingOn over plans read. Pure.
func judgingIn(plans []inventory.Plan, self, node string) string {
judging := func(g *inventory.PlanGate) bool {
return g != nil && g.Verdict == "" && slices.Contains(g.Machines, node)
}
for _, p := range plans {
if p.ID == self || !p.Open() {
continue
}
if p.Release != nil {
if judging(p.Release.Gate) {
return p.ID
}
continue
}
for _, s := range p.Modules {
if s != nil && judging(s.Gate) {
return p.ID
}
}
}
return ""
}
// advanceSettingsWalk takes one step of a settings walk: the next machine sent and its module judged there, or the
// machine being judged judged once more; done when every machine is.
func advanceSettingsWalk(ctx context.Context, open *stores, p *inventory.Plan) (bool, error) {
r, s := p.Release, p.Release.Settings
now := time.Now().UTC()
wait := func(note string) (bool, error) {
changed := p.Note != note
p.Note = note
return changed, nil
}
if r.Gate == nil {
heard, err := releaseHeard(ctx, open)
if err != nil {
return false, err
}
for r.Next < len(r.Order) {
node := r.Order[r.Next]
if !heard[node] {
r.Skipped = append(r.Skipped, node)
r.Next++
continue
}
if id, err := judgingOn(ctx, open, p.ID, node); err != nil {
return false, err
} else if id != "" {
return wait(fmt.Sprintf("waiting before %s: %s is judging there", node, id))
}
// **A running module's data that would move is a person's word** (ADR 0217 §3): the walk stops before
// that machine, failed and said, and `push --move` sends it.
held, err := settingsDataMoves(ctx, open, node)
if errors.Is(err, errSettingsCannotKeep) {
return failBefore(p, node, err.Error())
}
if err != nil {
return false, fmt.Errorf("whether %s's data would move could not be read: %w", node, err)
}
if len(held) > 0 {
var said strings.Builder
sayDataHeld(&said, node, held)
return failBefore(p, node, "a running module's data would move there — "+
strings.Join(strings.Fields(said.String()), " "))
}
sent, grants, err := sendJudged(keepingBuildsForSettings(ctx, node), open, node)
if errors.Is(err, errSettingsCannotKeep) {
// It does not clear by itself: tried again on every tick, the walk would stay open and hold the walk
// of waiting builds until a person came.
return failBefore(p, node, err.Error())
}
if err != nil {
return false, fmt.Errorf("sending %s %s: %w", node, settingsWalkFor(*s), err)
}
// **The module's build is the one the gate judges** (issue 641 review): kept by this send, it is what a
// crossing send — a merge's walk sending the machine a new build of the module — supersedes, so this gate
// never reads that send's report as its own.
kept := ""
if builds, known, err := open.inventory.SentBuilds(ctx, node); err == nil && known {
kept = builds[s.Module]
}
r.Gate = &inventory.PlanGate{Component: coreComponent(s.Module), Machines: sent, Since: &now,
From: kept, To: kept, Sent: sentNow(ctx, open.inventory, sent), Grants: grants}
p.Note = fmt.Sprintf("sent %s %s (ask %s), every build there kept; judging %s there", node,
settingsWalkFor(*s), s.Ask, s.Module)
if said := grantsSaid(grants); said != "" {
p.Note += "; " + said
}
fmt.Printf("%s: %s\n", p.ID, p.Note)
return true, nil
}
p.State, p.Tier = inventory.PlanDone, len(p.Tiers)
p.Note = fmt.Sprintf("%s (ask %s) delivered on %s", settingsWalkFor(*s), s.Ask, strings.Join(r.Done, ", "))
if len(r.Done) == 0 {
// Sent nowhere is not delivered: said as the walk's failure, so the operator is not told it landed.
p.State = inventory.PlanFailed
p.Note = fmt.Sprintf("%s (ask %s) was sent to no machine", settingsWalkFor(*s), s.Ask)
}
if len(r.Skipped) > 0 {
p.Note += "; not heard from, left as they were, their next send carries it: " + strings.Join(r.Skipped, ", ")
}
fmt.Printf("%s: %s\n", p.ID, p.Note)
return true, nil
}
g := r.Gate
node := r.Order[r.Next]
verdict, err := judgeMoves(ctx, open, g, []judged{{module: s.Module, node: node}}, now)
if err != nil {
return false, err
}
switch verdict {
case "":
return wait(fmt.Sprintf("judging %s on %s: %s", s.Module, node, gateLine(g)))
case inventory.GatePassed:
r.Done = append(r.Done, node)
r.Next++
r.Gate = nil
return true, nil
case inventory.GateSuperseded:
p.State = inventory.PlanSuperseded
p.Note = fmt.Sprintf("superseded on %s: %s — nothing judged, nothing put back; the layer is set, and the "+
"machines not yet sent take it at their next send", node, g.Why)
fmt.Printf("%s: %s\n", p.ID, p.Note)
return true, nil
}
p.State = inventory.PlanFailed
p.Note = fmt.Sprintf("%s failed its gate on %s: %s — the layer stays as the operator approved it, and the one it "+
"replaced is in `settings %s` history", settingsWalkFor(*s), node, g.Why, s.Module) + unreached(r)
fmt.Printf("%s: %s\n", p.ID, p.Note)
return true, nil
}
// failBefore ends a settings walk failed before it sent a machine, saying why, and that the layer stays.
func failBefore(p *inventory.Plan, node, why string) (bool, error) {
r, s := p.Release, p.Release.Settings
p.State = inventory.PlanFailed
p.Note = fmt.Sprintf("%s not sent to %s: %s; the layer stays as the operator approved it", settingsWalkFor(*s),
node, why) + unreached(r)
fmt.Printf("%s: %s\n", p.ID, p.Note)
return true, nil
}
// unreached is what a stopped settings walk left without the layer, in words: the machines after the one it stopped
// at, and those passed over as away (ADR 0344 §6). Empty when there are none.
func unreached(r *inventory.PlanRelease) string {
var out string
if r.Next+1 < len(r.Order) {
out += "; not sent: " + strings.Join(r.Order[r.Next+1:], ", ")
}
if len(r.Skipped) > 0 {
out += "; not heard from, left as they were: " + strings.Join(r.Skipped, ", ")
}
return out
}
-568
View File
@@ -1,568 +0,0 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/lease"
)
// novox/hq ADR 0344, issue 641: the operator's one Approve on a settings proposal is the act that makes it take
// effect. The warrant sets the layer and opens a settings walk, which sends it to the machines it reaches one at a
// time, judging the module on each; the ask says so before the Approve, and nothing names a push.
// The order: every machine running the module, or the one the layer names, the control node last.
func TestASettingsWalkGoesToTheMachinesTheLayerReachesTheControlNodeLast(t *testing.T) {
running := []string{"shanks", "novox", "g14", "ace"}
for _, c := range []struct {
node string
want []string
}{
{"", []string{"ace", "g14", "shanks", "novox"}},
{"g14", []string{"g14"}},
{"novox", []string{"novox"}},
{"elsewhere", nil},
} {
if got := settingsWalkOrder(c.node, running, []string{"novox"}); !reflect.DeepEqual(got, c.want) {
t.Errorf("a layer on %q goes to %v, want %v", c.node, got, c.want)
}
}
}
// A settings walk sends a machine only when no other walk judges there.
func TestASettingsWalkWaitsWhileAnotherWalkJudgesOnTheMachine(t *testing.T) {
judging := &inventory.PlanGate{Machines: []string{"g14"}}
passed := &inventory.PlanGate{Machines: []string{"g14"}, Verdict: inventory.GatePassed}
plans := []inventory.Plan{
{ID: "settings-1", State: inventory.PlanRolling, Release: &inventory.PlanRelease{Gate: judging}},
{ID: "release-2", State: inventory.PlanRolling, Release: &inventory.PlanRelease{Gate: passed}},
{ID: "plan-3", State: inventory.PlanRolling, Modules: map[string]*inventory.PlanModule{"app": {Gate: judging}}},
{ID: "plan-4", State: inventory.PlanDone, Modules: map[string]*inventory.PlanModule{"app": {Gate: judging}}},
}
if id := judgingIn(plans, "settings-1", "g14"); id != "plan-3" {
t.Errorf("on g14 %q judges, want plan-3 (itself, a passed gate and an ended walk are not judging)", id)
}
if id := judgingIn(plans[1:2], "x", "g14"); id != "" {
t.Errorf("a passed gate judges: %q", id)
}
if id := judgingIn(plans, "x", "shanks"); id != "" {
t.Errorf("on shanks %q judges", id)
}
}
// The ask says, before the Approve, that the layer is sent at once and judged, and that its end is told as every
// walk's is; neither the ask nor the proposer's answer names a push any more.
func TestTheAskSaysAnApprovedLayerIsSentAtOnce(t *testing.T) {
for _, c := range []struct {
in proposeInput
where string
had bool
}{
{proposeInput{module: "mounts", node: "shanks", values: mountsSources}, "shanks", false},
{proposeInput{module: "notes", values: map[string]any{"x": "1"}}, "every machine running notes", true},
{proposeInput{module: "notes", node: "laptop", clear: true}, "laptop", true},
} {
r := newProposerRig(t)
if c.had {
r.before, r.had = map[string]any{"x": "0"}, true
}
words, err := r.pr.propose(context.Background(), c.in)
if err != nil {
t.Fatal(err)
}
q := r.askSent(t)
if err := q.Check(r.now); err != nil {
t.Fatalf("the ask is refused: %v", err)
}
// At a glance (ADR 0345): the summary says it is sent at once by a walk, one machine at a time; the rest of
// what Approve does is the Details', which the router notes after the answers.
if !strings.Contains(q.Explanation, "sent at once by a walk to "+c.where+", one machine at a time, each judged") ||
!strings.Contains(q.Details, "and sent at once to "+c.where+", one machine at a time, each judged, "+
"every build there kept; its end is told as every walk's is") {
t.Errorf("the ask does not say the layer is sent at once to %s:\n%s", c.where, q.Explanation)
}
approve := q.Options[0]
if approve.Label != "Approve" || !strings.Contains(approve.Does, "sent to "+c.where+" at once") {
t.Errorf("Approve does %q", approve.Does)
}
if !strings.Contains(words, "a walk sends it to "+c.where+" at once") {
t.Errorf("the proposer's answer:\n%s", words)
}
for _, said := range []string{q.Explanation, q.Whole, approve.Does, words} {
if strings.Contains(said, "push") {
t.Errorf("a push is still named, as if a second act were needed:\n%s", said)
}
}
}
}
// ---- on the real stores -----------------------------------------------------------------------------
// The warrant sets the layer and opens the walk that sends it, naming the machines it reaches; a layer for a
// machine that does not run the module opens none and says so.
func TestAnApprovedLayerOpensTheWalkThatSendsIt(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
register(t, open, catalogue.Manifest{Module: "notes", Version: "1",
Resources: []map[string]any{{"id": "rc", "type": "file", "path": "/etc/notes.conf", "mode": "0644",
"content": "x = ${setting:x}\n"}}})
if _, err := assign(ctx, open, "laptop", "notes"); err != nil {
t.Fatal(err)
}
// A layer set by hand opens no walk: it still waits for a push (ADR 0344 §7).
if err := atTheTerminal(t, "settings", "set", "notes", `{"x":"0"}`, "--node", "laptop"); err != nil {
t.Fatal(err)
}
if plans, _ := open.inventory.OpenPlans(ctx); len(plans) != 0 {
t.Fatalf("a layer set at the terminal opened %d walk(s)", len(plans))
}
set := setLayerIn(open)
now := time.Now()
values := map[string]any{"x": "1"}
p := settingsProposal{Module: "notes", Values: values, From: "g14/claude-code", At: now,
Digest: layerDigest(values), BeforeDigest: layerDigest(nil)}
words, err := set(ctx, p, "s9", "approved by the operator (ask s9)")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(words, "+ x (ask s9); settings-") || !strings.Contains(words, "sends it to laptop, one machine at a time") {
t.Errorf("the warrant's words: %q", words)
}
plans, err := open.inventory.OpenPlans(ctx)
if err != nil || len(plans) != 1 {
t.Fatalf("open walks: %+v %v", plans, err)
}
w := plans[0]
if w.Release == nil || w.Release.Settings == nil || w.Release.Settings.Module != "notes" || w.Release.Settings.Ask != "s9" ||
!reflect.DeepEqual(w.Release.Order, []string{"laptop"}) || w.Repository != "the settings of notes on the whole mesh" ||
!reflect.DeepEqual(w.Tiers, [][]string{{"notes"}}) {
t.Fatalf("the settings walk: %+v %+v", w, w.Release)
}
// A machine's layer where the module does not run: set, and nothing to send.
q := settingsProposal{Module: "notes", Node: "anchor", Values: values, From: "g14/claude-code", At: now,
Digest: layerDigest(values), BeforeDigest: layerDigest(nil)}
words, err = set(ctx, q, "s10", "approved by the operator (ask s10)")
if err != nil || !strings.Contains(words, "no machine runs notes there, so there is nothing to send") {
t.Fatalf("a layer nobody runs: %q %v", words, err)
}
if plans, _ := open.inventory.OpenPlans(ctx); len(plans) != 1 {
t.Fatalf("a walk was opened for a machine that does not run the module: %d open", len(plans))
}
// A walk that cannot be opened is the act's failure, said with the push that sends it by hand.
was := openSettingsWalk
openSettingsWalk = func(context.Context, *stores, inventory.PlanSettings) (string, error) {
return "", fmt.Errorf("the plans are not to be had")
}
t.Cleanup(func() { openSettingsWalk = was })
again := map[string]any{"x": "2"}
byHand := map[string]any{"x": "0"}
r := settingsProposal{Module: "notes", Node: "laptop", Values: again, Before: byHand, HadLayer: true,
From: "g14/claude-code", At: now, Digest: layerDigest(again), BeforeDigest: layerDigest(byHand)}
if _, err := set(ctx, r, "s11", "approved"); err == nil || !strings.Contains(err.Error(), "is set, and the walk that "+
"sends it could not be opened: the plans are not to be had — `push laptop` sends it") {
t.Errorf("an unopened walk: %v", err)
}
}
// walkMesh is a mesh where `app` runs c1 on anchor and laptop and nothing waits for a gate: every send applied at
// once, recorded, and judged from the reports, with a machine named broken failing what it was sent.
type walkMesh struct {
open *stores
sent [][]string
broken map[string]bool
// kept says, per send, whether it kept every build of its machine; moves is the data a machine's send would move.
kept []bool
moves map[string][]dataMove
heard map[string]bool
}
func aSettledMesh(t *testing.T) *walkMesh {
t.Helper()
open := aMesh(t)
ctx := t.Context()
inv := open.inventory
w := &walkMesh{open: open, broken: map[string]bool{}, moves: map[string][]dataMove{},
heard: map[string]bool{"anchor": true, "laptop": true}}
withConditionsInMemory(t)
was := doctorFrom
doctorFrom = nil
t.Cleanup(func() { doctorFrom = was })
old := time.Now().Add(-2 * time.Hour)
manifest, _ := json.Marshal(catalogue.Manifest{Module: "app", Version: "1"})
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-app-c1", Module: "app", Commit: "c1",
Repository: "novox/mesh-catalog", Path: "modules/app", Manifest: manifest, Asked: old, At: old,
Made: []inventory.Artifact{{Name: "x", Kind: "bundle", Reference: "sha256:app-1"}}}); err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "app", Version: "1"}, inventory.Source{
Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/app", BuiltFrom: "c1", Head: "c1", Asked: old}); err != nil {
t.Fatal(err)
}
for _, n := range []string{"anchor", "laptop"} {
if _, err := inv.Assign(ctx, n, "app"); err != nil {
t.Fatal(err)
}
if err := inv.RecordSent(ctx, nodeID(t, open, n), "d-"+n, map[string]string{"app": "c1"}); err != nil {
t.Fatal(err)
}
}
wasMoves, wasHeard, wasSend, wasGather, wasData := machineMoves, releaseHeard, sendRollout, gatherGateFacts, settingsDataMoves
settingsDataMoves = func(_ context.Context, _ *stores, node string) ([]dataMove, error) { return w.moves[node], nil }
machineMoves = func(ctx context.Context, open *stores, f moveFacts, node string, all bool) ([]inventory.CarriedMove, error) {
modules, err := open.inventory.Assigned(ctx, node)
if err != nil {
return nil, err
}
sent, known, err := open.inventory.SentBuilds(ctx, node)
if err != nil {
return nil, err
}
return f.moves(node, modules, sent, known, all), nil
}
releaseHeard = func(context.Context, *stores) (map[string]bool, error) { return w.heard, nil }
n := 0
sendRollout = func(ctx context.Context, open *stores, names []string) ([]string, error) {
w.sent = append(w.sent, append([]string(nil), names...))
w.kept = append(w.kept, len(names) == 1 && settingsKeptOn(ctx, names[0]))
current, err := open.inventory.CurrentBuilds(ctx)
if err != nil {
return nil, err
}
for _, node := range names {
modules, _ := open.inventory.Assigned(ctx, node)
carried := map[string]string{}
for _, m := range modules {
carried[m] = current[m].Commit
}
n++
digest := fmt.Sprintf("d-%s-%d", node, n)
if err := open.inventory.RecordSent(ctx, nodeID(t, open, node), digest, carried); err != nil {
return nil, err
}
if _, err := open.inventory.RecordDoing(ctx, nodeID(t, open, node), inventory.Doing{Node: node,
Outcome: inventory.OutcomeApplied, Declared: digest, Applied: 1, At: time.Now()}); err != nil {
return nil, err
}
}
return names, nil
}
gatherGateFacts = func(ctx context.Context, open *stores, component string) (gateFacts, error) {
f := gateFacts{now: time.Now(), reports: map[string]inventory.Reported{}, engines: map[string]string{},
rolledBack: map[string][]lease.Rollback{}, served: map[string]served{}}
reports, err := open.inventory.LastReports(ctx)
if err != nil {
return f, err
}
for _, r := range reports {
if w.broken[r.Node] {
r.Outcome = inventory.OutcomeFailed
}
f.reports[r.Node] = r
}
return f, nil
}
wasSettle, wasEvery, wasBound := gateSettle, gateEvery, gateBound
gateSettle, gateEvery = 0, time.Hour
t.Cleanup(func() {
machineMoves, releaseHeard, sendRollout, gatherGateFacts = wasMoves, wasHeard, wasSend, wasGather
settingsDataMoves = wasData
gateSettle, gateEvery, gateBound = wasSettle, wasEvery, wasBound
})
return w
}
func (w *walkMesh) walk(t *testing.T) inventory.Plan {
t.Helper()
plans, err := w.open.inventory.RecentPlans(t.Context(), 10)
if err != nil {
t.Fatal(err)
}
for _, p := range plans {
if p.Release != nil && p.Release.Settings != nil {
return p
}
}
t.Fatal("no settings walk")
return inventory.Plan{}
}
// The walk sends the first machine alone and judges the module there before it sends the next, and ends
// delivered on both; nothing waited for a gate, so each send carried the layer alone.
func TestASettingsWalkSendsOneMachineAtATimeEachJudged(t *testing.T) {
judgeLeavesAsTheCore(t)
w := aSettledMesh(t)
ctx := t.Context()
words, err := openSettingsWalk(ctx, w.open, inventory.PlanSettings{Module: "app", Ask: "s1", Change: "~ x"})
if err != nil || !strings.Contains(words, "sends it to anchor, laptop") {
t.Fatalf("%q %v", words, err)
}
advancePlans(ctx, w.open)
if !reflect.DeepEqual(w.sent, [][]string{{"anchor"}}) {
t.Fatalf("sent %v: the first machine alone, before it is judged", w.sent)
}
p := w.walk(t)
if p.Release.Gate == nil || p.Release.Next != 0 || !strings.Contains(p.Note, "judging app on anchor") {
t.Fatalf("the walk after its first send: %s %+v", p.Note, p.Release)
}
gateEvery = 0
for i := 0; i < 6; i++ {
advancePlans(ctx, w.open)
}
if !reflect.DeepEqual(w.sent, [][]string{{"anchor"}, {"laptop"}}) {
t.Fatalf("sent %v", w.sent)
}
p = w.walk(t)
if p.State != inventory.PlanDone || !reflect.DeepEqual(p.Release.Done, []string{"anchor", "laptop"}) ||
p.Outcome() != inventory.WalkDelivered || !strings.Contains(p.Note, "delivered on anchor, laptop") {
t.Fatalf("the walk is %s: %s %+v", p.State, p.Note, p.Release)
}
// It said its start and its end, so the operator is told once (ADR 0308).
if p.Times == nil || p.Times.Started == nil || p.Times.Ended == nil {
t.Fatalf("the walk's start and end are not marked: %+v", p.Times)
}
// Nothing waits, and no walk of waiting builds opened beside it.
if r, err := releaseBacklog(ctx, w.open, ""); err != nil || r != nil {
t.Fatalf("%+v %v", r, err)
}
}
// A machine that fails what it was sent stops the walk there: the next is not sent, the layer stays, and the
// failure holds back no walk of waiting builds.
func TestAFailedSettingsWalkStopsAndHoldsNothingBack(t *testing.T) {
w := aSettledMesh(t)
ctx := t.Context()
inv := w.open.inventory
w.broken["anchor"] = true
if err := inv.KeepSettings(ctx, "", "app", map[string]any{"x": "1"}); err != nil {
t.Fatal(err)
}
if _, err := openSettingsWalk(ctx, w.open, inventory.PlanSettings{Module: "app", Ask: "s2"}); err != nil {
t.Fatal(err)
}
for i := 0; i < 4; i++ {
advancePlans(ctx, w.open)
}
p := w.walk(t)
if p.State != inventory.PlanFailed || p.Outcome() != inventory.WalkFailed ||
!strings.Contains(p.Note, "the settings of app on the whole mesh failed its gate on anchor") ||
!strings.Contains(p.Note, "the layer stays as the operator approved it") || !strings.Contains(p.Note, "not sent: laptop") {
t.Fatalf("the walk is %s: %s", p.State, p.Note)
}
if !reflect.DeepEqual(w.sent, [][]string{{"anchor"}}) {
t.Fatalf("sent %v: anchor alone, nothing to laptop", w.sent)
}
if layer, has, err := inv.Layer(ctx, "", "app"); err != nil || !has || layer["x"] != "1" {
t.Fatalf("the layer after the failure: %v %v %v", layer, has, err)
}
// A build that waits for a gate is still walked: the failed settings walk holds nothing back.
w.broken["anchor"] = false
now := time.Now().Add(-time.Minute)
manifest, _ := json.Marshal(catalogue.Manifest{Module: "app", Version: "1"})
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-app-c2", Module: "app", Commit: "c2",
Repository: "novox/mesh-catalog", Path: "modules/app", Manifest: manifest, Asked: now, At: now,
Made: []inventory.Artifact{{Name: "x", Kind: "bundle", Reference: "sha256:app-2"}}}); err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "app", Version: "1"}, inventory.Source{
Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/app", BuiltFrom: "c2", Head: "c2", Asked: now}); err != nil {
t.Fatal(err)
}
if r, err := releaseBacklog(ctx, w.open, ""); err != nil || r == nil {
t.Fatalf("the waiting build is held back by a failed settings walk: %+v %v", r, err)
}
}
// Each send of a settings walk keeps every build of its machine: the layer goes, and a build held for a person or
// waiting for a gate stays where it is (ADR 0344 §3).
func TestASettingsWalkSendsTheLayerAloneEveryBuildKept(t *testing.T) {
w := aSettledMesh(t)
ctx := t.Context()
inv := w.open.inventory
gateEvery = 0
if _, err := openSettingsWalk(ctx, w.open, inventory.PlanSettings{Module: "app", Ask: "s3"}); err != nil {
t.Fatal(err)
}
for i := 0; i < 4; i++ {
advancePlans(ctx, w.open)
}
if !reflect.DeepEqual(w.kept, []bool{true, true}) || w.walk(t).State != inventory.PlanDone {
t.Fatalf("the sends kept every build: %v; the walk: %s", w.kept, w.walk(t).Note)
}
// What such a send keeps, on the store: a rolled-out build waiting there is kept at the one last sent.
now := time.Now().Add(-time.Minute)
manifest, _ := json.Marshal(catalogue.Manifest{Module: "app", Version: "1"})
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-app-c2", Module: "app", Commit: "c2",
Repository: "novox/mesh-catalog", Path: "modules/app", Manifest: manifest, Asked: now, At: now,
Made: []inventory.Artifact{{Name: "x", Kind: "bundle", Reference: "sha256:app-2"}}}); err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "app", Version: "1"}, inventory.Source{
Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/app", BuiltFrom: "c2", Head: "c2", Asked: now}); err != nil {
t.Fatal(err)
}
kept, err := recordedKept(keepingBuildsForSettings(ctx, "anchor"), w.open, "anchor")
if err != nil || kept["app"] != "c1" {
t.Fatalf("a settings send to anchor keeps %v (%v), want app at c1", kept, err)
}
if kept, _ := recordedKept(ctx, w.open, "anchor"); kept["app"] != "" {
t.Fatalf("an ordinary send keeps a rolled-out build: %v", kept)
}
// And so the ungated build waiting on anchor is no move of the settings send's: nothing for a gate to see.
f, err := readMoveFacts(ctx, inv)
if err != nil {
t.Fatal(err)
}
waiting, _ := machineMoves(ctx, w.open, f, "anchor", false)
if len(waiting) != 1 || waiting[0].Module != "app" {
t.Fatalf("anchor waits for %+v, want app's c2", waiting)
}
if moved, err := withoutKept(keepingBuildsForSettings(ctx, "anchor"), w.open, "anchor", waiting); err != nil || len(moved) != 0 {
t.Fatalf("a settings send to anchor would move %+v (%v)", moved, err)
}
}
// A machine whose send would move a running module's data stops the walk there, failed and said with the push that
// sends it on a person's word (ADR 0217 §3); one not heard from is passed over and named; and a walk that reached
// no machine is not said delivered.
func TestASettingsWalkStopsBeforeADataMoveAndPassesOverAMachineAway(t *testing.T) {
w := aSettledMesh(t)
ctx := t.Context()
gateEvery = 0
w.moves["laptop"] = []dataMove{{Container: "app-server", Inside: "/data", From: "/srv/a", To: "/srv/b"}}
if _, err := openSettingsWalk(ctx, w.open, inventory.PlanSettings{Module: "app", Ask: "s4"}); err != nil {
t.Fatal(err)
}
for i := 0; i < 4; i++ {
advancePlans(ctx, w.open)
}
p := w.walk(t)
if p.State != inventory.PlanFailed || !reflect.DeepEqual(w.sent, [][]string{{"anchor"}}) ||
!strings.Contains(p.Note, "not sent to laptop: a running module's data would move there") ||
!strings.Contains(p.Note, "push laptop --move app") {
t.Fatalf("sent %v; the walk is %s: %s", w.sent, p.State, p.Note)
}
v := aSettledMesh(t)
v.heard = map[string]bool{"laptop": true}
gateEvery = 0
if _, err := openSettingsWalk(ctx, v.open, inventory.PlanSettings{Module: "app", Ask: "s5"}); err != nil {
t.Fatal(err)
}
for i := 0; i < 4; i++ {
advancePlans(ctx, v.open)
}
p = v.walk(t)
if p.State != inventory.PlanDone || !reflect.DeepEqual(p.Release.Skipped, []string{"anchor"}) ||
!strings.Contains(p.Note, "delivered on laptop; not heard from, left as they were, their next send carries it: anchor") {
t.Fatalf("the walk with anchor away is %s: %s", p.State, p.Note)
}
u := aSettledMesh(t)
u.heard = map[string]bool{}
if _, err := openSettingsWalk(ctx, u.open, inventory.PlanSettings{Module: "app", Ask: "s6"}); err != nil {
t.Fatal(err)
}
advancePlans(ctx, u.open)
if p := u.walk(t); p.State != inventory.PlanFailed || p.Outcome() != inventory.WalkFailed ||
!strings.Contains(p.Note, "was sent to no machine") || len(u.sent) != 0 {
t.Fatalf("a walk that reached nobody is %s: %s", p.State, p.Note)
}
}
// The gate keeps the module's build its send kept, so a crossing send — a merge's walk sending the machine a new
// build of the module while the settings gate judges there — supersedes it, and its report is never read as the
// settings send's (issue 641 review, finding 1).
func TestACrossingSendSupersedesASettingsGate(t *testing.T) {
judgeLeavesAsTheCore(t)
w := aSettledMesh(t)
ctx := t.Context()
inv := w.open.inventory
if _, err := openSettingsWalk(ctx, w.open, inventory.PlanSettings{Module: "app", Ask: "s7"}); err != nil {
t.Fatal(err)
}
advancePlans(ctx, w.open)
p := w.walk(t)
if g := p.Release.Gate; g == nil || g.To != "c1" || g.From != "c1" {
t.Fatalf("the settings gate judges no build: %+v", p.Release.Gate)
}
// Another walk sends anchor app's c2 while the settings gate judges there.
now := time.Now().Add(-time.Minute)
manifest, _ := json.Marshal(catalogue.Manifest{Module: "app", Version: "1"})
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-app-c2", Module: "app", Commit: "c2",
Repository: "novox/mesh-catalog", Path: "modules/app", Manifest: manifest, Asked: now, At: now,
Made: []inventory.Artifact{{Name: "x", Kind: "bundle", Reference: "sha256:app-2"}}}); err != nil {
t.Fatal(err)
}
if err := inv.RecordSent(ctx, nodeID(t, w.open, "anchor"), "d-merge", map[string]string{"app": "c2"}); err != nil {
t.Fatal(err)
}
if _, err := inv.RecordDoing(ctx, nodeID(t, w.open, "anchor"), inventory.Doing{Node: "anchor",
Outcome: inventory.OutcomeApplied, Declared: "d-merge", Applied: 1, At: time.Now()}); err != nil {
t.Fatal(err)
}
gateEvery = 0
advancePlans(ctx, w.open)
p = w.walk(t)
if p.State != inventory.PlanSuperseded || p.Release.Gate == nil || p.Release.Gate.Verdict != inventory.GateSuperseded ||
!strings.Contains(p.Note, "superseded on anchor") || len(p.Release.Done) != 0 {
t.Fatalf("the settings walk after a crossing send is %s: %s %+v", p.State, p.Note, p.Release)
}
if !reflect.DeepEqual(w.sent, [][]string{{"anchor"}}) {
t.Fatalf("sent %v: laptop was sent after a superseded judging", w.sent)
}
}
// A send refusal that cannot clear by itself — what the machine was last sent is not known, or a build it runs is
// gone — fails the walk with why, rather than keeping it open and holding the walk of waiting builds; and a
// failure names the machines passed over as away (issue 641 review, findings 2 and 3).
func TestASettingsWalkFailsOnARefusalThatCannotClear(t *testing.T) {
w := aSettledMesh(t)
ctx := t.Context()
inv := w.open.inventory
// What a send to a machine never sent can keep: nothing, and said as the refusal the walk fails on.
if _, err := inv.AddNode(ctx, "fresh"); err != nil {
t.Fatal(err)
}
if _, err := recordedKept(keepingBuildsForSettings(ctx, "fresh"), w.open, "fresh"); !errors.Is(err, errSettingsCannotKeep) {
t.Fatalf("a settings send to a machine never sent: %v", err)
}
w.heard = map[string]bool{"laptop": true}
settingsDataMoves = func(_ context.Context, _ *stores, node string) ([]dataMove, error) {
return nil, fmt.Errorf("%w: what %s was last sent is not known", errSettingsCannotKeep, node)
}
if _, err := openSettingsWalk(ctx, w.open, inventory.PlanSettings{Module: "app", Ask: "s8"}); err != nil {
t.Fatal(err)
}
advancePlans(ctx, w.open)
p := w.walk(t)
if p.State != inventory.PlanFailed || len(w.sent) != 0 ||
!strings.Contains(p.Note, "not sent to laptop: the settings cannot be sent with every build kept: what laptop was last sent is not known") ||
!strings.Contains(p.Note, "not heard from, left as they were: anchor") {
t.Fatalf("the walk is %s: %s", p.State, p.Note)
}
// It is not open, so a build waiting for a gate is walked.
now := time.Now().Add(-time.Minute)
manifest, _ := json.Marshal(catalogue.Manifest{Module: "app", Version: "1"})
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-app-c2", Module: "app", Commit: "c2",
Repository: "novox/mesh-catalog", Path: "modules/app", Manifest: manifest, Asked: now, At: now,
Made: []inventory.Artifact{{Name: "x", Kind: "bundle", Reference: "sha256:app-2"}}}); err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "app", Version: "1"}, inventory.Source{
Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/app", BuiltFrom: "c2", Head: "c2", Asked: now}); err != nil {
t.Fatal(err)
}
if r, err := releaseBacklog(ctx, w.open, ""); err != nil || r == nil {
t.Fatalf("the waiting build is held by the failed settings walk: %+v %v", r, err)
}
}
+15 -5
View File
@@ -263,8 +263,18 @@ func TestTheAgentsManagedSettingsAreRefusedThroughAVerb(t *testing.T) {
if err := atTheTerminal(t, "settings", "set", "claude-code", allow, "--node", "laptop"); err != nil {
t.Fatalf("the managed settings at the terminal: %v", err)
}
if err := atTheTerminal(t, "settings", "set", "claude-code", server); err != nil {
t.Fatalf("a tool server for the mesh at the terminal: %v", err)
// There is no module layer for the whole mesh (novox/hq issue 630), at the terminal either; one the mesh
// 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")
// 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 {
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)
}
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"}}}`})
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)
}
values, _, err := open.inventory.Layer(ctx, "", "mailer")
values, _, err := open.inventory.Layer(ctx, "anchor", "mailer")
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -3,7 +3,7 @@ module github.com/novox/mesh-controller
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c
github.com/jackc/pgx/v5 v5.10.0
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
-4
View File
@@ -2,10 +2,6 @@ git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f h1:8N5OW2mdTNIck
git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f/go.mod h1:tcTK4LMs1d6JpZUwy3hSHMFG/MIuDr/XFs7/nbeaW/c=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c h1:l5onJwoIeH8yE/PjVlEeoPyXBsHp2NQiWLllz2fwX7s=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d h1:HEJyqorKktrkJPeeJdwEo0kdrJjH3fm/YVS9MGftCqY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+25 -1
View File
@@ -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
// sees stays its source, as before.
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).
@@ -233,6 +238,25 @@ func build(ctx context.Context, run Runner, publish Publisher,
say("manifest", "INVALID: %v", 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))
// 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,
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/`.
+6
View File
@@ -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.
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
// 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
+270
View File
@@ -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
}
+117
View File
@@ -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)
}
}
+35 -21
View File
@@ -114,15 +114,23 @@ func operatorsWord(key string) string {
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 {
if len(m.Settings) == 0 {
declared := PreferencesOf(m)
if len(declared) == 0 {
return nil
}
used := settingKeysUsedBy(m)
var problems []string
for _, key := range sortedSettingKeys(m.Settings) {
d := m.Settings[key]
problems := preferenceProblems(m)
keys := make([]string, 0, len(declared))
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) {
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)
continue
}
if d.Kind != KindPreference {
say("kind is %q, and only a %q has a default (novox/hq ADR 0262)", 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)", m.Settings[key].Kind, KindPreference)
}
if word := operatorsWord(key); word != "" {
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)
}
switch v := d.Default.(type) {
case string:
if strings.TrimSpace(v) == "" {
say("an empty default is no default; give the value, or declare nothing")
if old {
switch v := d.Default.(type) {
case string:
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:
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)
} else if v, isString := d.Default.(string); isString && strings.TrimSpace(v) == "" {
say("an empty default is no default; give the value, or declare nothing")
}
if strings.TrimSpace(d.Why) == "" {
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.
func Defaults(m Manifest) (Layer, bool) {
if len(m.Settings) == 0 {
declared := PreferencesOf(m)
if len(declared) == 0 {
return Layer{}, false
}
values := map[string]any{}
for key, d := range m.Settings {
for key, d := range declared {
if d.Default != nil {
values[key] = d.Default
}
@@ -189,7 +202,7 @@ func WithDefaults(m Manifest, layers []Layer) []Layer {
type SettingSource struct {
Key string
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
// FromDefault is whether the value is the module's default, whatever From reads.
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
// 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 {
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,
Default: d.Default, HasDefault: true}
}
+1 -1
View File
@@ -188,7 +188,7 @@ func wholeID(v string) (uint64, bool) {
func OwnerSettingProblems(m Manifest) []string {
var problems []string
asked := settingKeysAskedOutsideOwners(m)
for k := range m.Settings {
for k := range PreferencesOf(m) {
asked[k] = true
}
for _, r := range m.Resources {
+2 -2
View File
@@ -43,7 +43,7 @@ func onlyTaken(layers []Layer, taken map[string]bool) []Layer {
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
}
@@ -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
// names every file that does not, and a file is not reported twice.
func WrittenSettingsProblems(m Manifest) []string {
if len(m.Settings) == 0 {
if len(PreferencesOf(m)) == 0 {
return nil
}
var silent []string
+6 -3
View File
@@ -37,6 +37,9 @@ type Layer struct {
// 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.
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.
@@ -136,7 +139,7 @@ func overridden(base map[string]any, layers []Layer, what string) (map[string]an
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)
if err != nil {
@@ -173,7 +176,7 @@ func settle(base map[string]any, layers []Layer, protected map[string]bool, what
map[string]any, error) {
merged := deepCopy(base)
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
// 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).
@@ -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
// 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
}
for _, values := range m.Contributes {
+27 -4
View File
@@ -299,10 +299,33 @@ var ControllerVerbs = []Verb{
"adopted": "\"true\" when the machine is in use and joins adopted",
}, nil, "adopted"),
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 " +
"mesh, or for one machine. Without values or clear, answers the layer as it stands — read it before setting it — " +
{Name: "config", Description: "Read or set config values (novox/hq issue 630). A module's preference resolves from " +
"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, " +
"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; " +
"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 " +
@@ -317,7 +340,7 @@ var ControllerVerbs = []Verb{
Input: schema(map[string]string{
"module": "the module's name; with list, only that module's",
"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",
"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",
+13
View File
@@ -59,6 +59,10 @@ type Facts struct {
Modules []Module `json:"modules"`
// Settings are the mesh-wide layer of every module's settings, scrubbed.
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
// rebuild width is computed from.
Edges []Edge `json:"edges,omitempty"`
@@ -143,6 +147,9 @@ type Machine struct {
Pins []Pin `json:"pins,omitempty"`
// Settings are its own layer of each module's settings, scrubbed.
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
// make them, so a check composing this machine gives each a stand-in instead of refusing it.
Accepted []Accepted `json:"accepted,omitempty"`
@@ -173,6 +180,12 @@ type Accepted struct {
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.
type Settings struct {
Module string `json:"module"`
+28 -2
View File
@@ -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)
if err != nil {
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 {
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 == "" {
// 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
@@ -1133,7 +1153,8 @@ func (i *Inventory) ClearSettingsBy(ctx context.Context, nodeName, module, clear
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
// 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()
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() {
var meshWide bool
var raw []byte
+339
View File
@@ -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
}
+274
View File
@@ -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)
}
}
+2 -2
View File
@@ -39,7 +39,7 @@ func TestRegisteringAModuleAgainKeepsWhatTheMeshHoldsForIt(t *testing.T) {
if err := inv.RegisterModule(ctx, m, Source{}); err != nil {
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)
}
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 {
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)
}
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;
-14
View File
@@ -433,20 +433,6 @@ type PlanRelease struct {
Skipped []string `json:"skipped,omitempty"`
// By is the person who released it, empty when the mesh did.
By string `json:"by,omitempty"`
// Settings is set on a settings walk (novox/hq ADR 0344): not the builds waiting for a gate, but a settings
// layer the operator approved, sent to the machines it reaches one at a time, its module judged on each.
Settings *PlanSettings `json:"settings,omitempty"`
}
// PlanSettings is what a settings walk sends (novox/hq ADR 0344): the module whose layer the operator approved, the
// layer's scope, the ask that approved it and what changed, in words.
type PlanSettings struct {
Module string `json:"module"`
// Node is the machine the layer is for, empty for the whole mesh.
Node string `json:"node,omitempty"`
// Ask is the proposal the operator approved, and Change what its layer changed ("~ prefs").
Ask string `json:"ask"`
Change string `json:"change,omitempty"`
}
// The states a plan passes through.
+7 -5
View File
@@ -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
// 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"`) {
t.Fatalf("a stray key was stored: %v", err)
}
if err := inv.SetSettings(ctx, "", "web", map[string]any{"colour": "blue"}); err != nil {
t.Fatal(err)
// There is no module layer for the whole mesh (novox/hq issue 630): a new one is refused.
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{catalogue.PortsSetting: map[string]any{"8080": 10000}}); err != nil {
if err := inv.SetSettings(ctx, "anchor", "web", map[string]any{"colour": "blue",
catalogue.PortsSetting: map[string]any{"8080": 10000}}); err != nil {
t.Fatal(err)
}
// A kept network is for an adopted machine only (rule 4).
+2 -2
View File
@@ -56,10 +56,10 @@ func TestTheLayerASetReplacesIsKeptAndReadable(t *testing.T) {
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.
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)
}
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)
}
mesh, err := inv.SettingsHistory(ctx, "", "web")
+5
View File
@@ -250,6 +250,11 @@ type BuildResult struct {
// the repository's default is the branch it follows.
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
// 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
+11 -56
View File
@@ -80,57 +80,6 @@ func Check(text string, machines ...string) (Refusal, bool) {
return Refusal{"address", "a hardware address"}, false
case reIPv6.MatchString(text):
return Refusal{"address", "an IPv6 address"}, false
}
if refusal, ok := secretShape(text); !ok {
return refusal, false
}
if reWinPath.MatchString(text) {
return Refusal{"path", "a drive path"}, false
}
if refusal, ok := randomRun(text); !ok {
return refusal, false
}
for _, word := range strings.FieldsFunc(text, isSeparator) {
w := strings.TrimRight(word, ".:!?")
if isPath(w) {
return Refusal{"path", "a file path"}, false
}
if isHostName(w) {
return Refusal{"address", "a host name"}, false
}
}
return Refusal{}, true
}
// Secret says whether a text carries a secret's shape, the one class that leaves the mesh nowhere — the
// messenger's CheckSecret, one for one. It is what an ask's whole words (asks.Ask.Whole, novox/hq issue 383)
// are held to: on a channel that proves who answers, a path or an address is what the operator approves and
// is shown; a secret never is. A path is read as one: a run with a slash in it is judged piece by piece
// between the slashes, so "/mnt/Photos_2024/Jochen" is a path and not a random string (the review of
// 2026-10-10); the named shapes hold whatever the run holds.
func Secret(text string, machines ...string) (Refusal, bool) {
text = withoutMachines(text, machines)
if refusal, ok := secretShape(text); !ok {
return refusal, false
}
return randomRunOutsidePaths(text)
}
// randomRunOutsidePaths is randomRun with each run that holds a slash judged by its pieces between the slashes.
func randomRunOutsidePaths(text string) (Refusal, bool) {
for _, run := range reRun.FindAllString(text, -1) {
for _, piece := range strings.Split(run, "/") {
if len(piece) >= 20 && looksRandom(piece) {
return Refusal{"secret", "a long random-looking string"}, false
}
}
}
return Refusal{}, true
}
// secretShape is the secret shapes a pattern names.
func secretShape(text string) (Refusal, bool) {
switch {
case rePEM.MatchString(text):
return Refusal{"secret", "a key block"}, false
case reJWT.MatchString(text):
@@ -143,17 +92,23 @@ func secretShape(text string) (Refusal, bool) {
return Refusal{"secret", "a value given to a secret's name"}, false
case reHex.MatchString(text):
return Refusal{"secret", "a long hexadecimal string"}, false
case reWinPath.MatchString(text):
return Refusal{"path", "a drive path"}, false
}
return Refusal{}, true
}
// randomRun is a long unbroken run of characters spread as a random string's are.
func randomRun(text string) (Refusal, bool) {
for _, run := range reRun.FindAllString(text, -1) {
if looksRandom(run) {
return Refusal{"secret", "a long random-looking string"}, false
}
}
for _, word := range strings.FieldsFunc(text, isSeparator) {
w := strings.TrimRight(word, ".:!?")
if isPath(w) {
return Refusal{"path", "a file path"}, false
}
if isHostName(w) {
return Refusal{"address", "a host name"}, false
}
}
return Refusal{}, true
}
-37
View File
@@ -86,40 +86,3 @@ func TestScrubAlwaysGivesWhatMayLeave(t *testing.T) {
t.Errorf("a text that passes was changed: %q", got)
}
}
// Secret is the one class that leaves the mesh nowhere (novox/hq issue 383): it refuses every secret's shape
// Check refuses, and nothing Check refuses as an address or a path — those an ask's whole words may carry.
func TestSecretRefusesOnlyASecretsShape(t *testing.T) {
for _, text := range []string{
"-----BEGIN RSA PRIVATE KEY-----",
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abc",
"123456789:ABCdefGHIjklMNOpqrSTUvwxYZ0123456789ab",
"ghp_abcdefghijklmnopqrstuvwxyz0123456789",
"password=hunter2",
"0123456789abcdef0123456789abcdef",
"a key xK9mQ2vL8pR4tW7yB3nF6hJ1dG5sA0zC",
} {
r, ok := Secret(text)
if ok || r.Class != "secret" {
t.Errorf("not refused as a secret: %q (%s)", text, r)
}
}
for _, text := range []string{
"recalbox=smb://nas.lan/recalbox@/mnt/recalbox:ro",
"library=/mnt/library",
"photos=/mnt/Photos_2024/Jochen",
"games=smb://nas.lan/Recalbox_Games2024",
"/srv/media/Series_Archive/Season01",
"10.77.0.9:53",
"jochen@example.com",
"C:\\Users\\jo",
"anchor and the laptop",
} {
if r, ok := Secret(text, "anchor"); !ok {
t.Errorf("refused as %s: %q", r, text)
}
if _, ok := Check(text, "anchor"); ok && text != "anchor and the laptop" {
t.Errorf("Check lets %q leave, so Secret is not the narrower rule", text)
}
}
}
+1
View File
@@ -69,6 +69,7 @@
"issue",
"token",
"settings",
"config",
"command",
"queue",
"cancel",
+1 -1
View File
@@ -9,7 +9,7 @@ a second place to clean besides the catalogue itself.
mesh-catalog e0bc919d4945bb5f13f971f74643a0b714cf45da modules/*/module.json, modules/nats/Dockerfile
mesh-host 7a41e120635b3f7b6cb94cca8b3fc4093e4e6110 examples/foundation-first-node.lock
mesh-sdk f4745447ba6d0c4fd7dc6b1071e26ce012806233 conformance/events/module-event.json
mesh-sdk 33917f91ac3cc213b879b32f3c70e2aa70395f98 conformance/events/module-event.json
To move them, from this repository's root, with the two repositories checked out beside it:
+4 -64
View File
@@ -174,12 +174,6 @@ func (a Ask) Digest() string {
canonical(&b, a.Whole)
canonical(&b, a.Details)
}
// WholeDetails (novox/hq issue 383, the review of mesh-controller #304) follows only when given, so every
// ask without it digests as it did before it existed.
if a.WholeDetails != "" {
canonical(&b, "whole-details")
canonical(&b, a.WholeDetails)
}
sum := sha256.Sum256([]byte(b.String()))
return "sha256:" + hex.EncodeToString(sum[:])
}
@@ -208,59 +202,9 @@ type Ask struct {
// The asker withholds a value shaped like a secret in it, and the router refuses the ask when one is left;
// Explanation stays under the whole content rule everywhere else. Empty, Explanation is shown everywhere.
Whole string `json:"whole,omitempty"`
// Details are the ask's secondary words, line by line under the content rule: who proposed it, a
// fingerprint, how to read it whole. The router shows them in the note of the ask's own message
// (Message.Note), after what the ask's options do and when it expires — never in its body (novox/hq
// issue 639).
// Details is what the Details answer on the ask's message shows, line by line under the content rule: a
// fingerprint, how to read the proposal whole at the terminal. Empty, an ask's own message offers no Details.
Details string `json:"details,omitempty"`
// WholeDetails are the Details with their exact values whole, shown in place of Details where Whole is shown
// in place of the explanation (a kind that proves who answers, given the ask's answers): every value an
// approval sets, whole, so the person reads exactly what they approve (novox/hq issue 383). Held, like
// Whole, to the secret rule alone; the router refuses the ask when a secret's shape is left in it. Empty,
// Details are shown everywhere.
WholeDetails string `json:"whole-details,omitempty"`
}
// What an ask's explanation is held to, so it reads at a glance on a phone (novox/hq issue 639): a summary
// of two or three short sentences on its first line — what changes, why, what it costs or risks — then at
// most three points, a line each, only where they add something. Whole is held to the same. Who asked, when,
// the fingerprint and how to read it whole are Details, never the explanation.
const (
SummaryLength = 300
MostPoints = 3
PointLength = 160
)
// Glance says why an explanation does not read at a glance, or nil: its first line, the summary, longer than
// SummaryLength; more than MostPoints lines after it; a point longer than PointLength. Blank lines are not
// counted. An asker holds its asks to it in its own tests; the router says, without refusing, an ask that
// does not hold to it.
func Glance(explanation string) error {
var lines []string
for _, l := range strings.Split(explanation, "\n") {
if strings.TrimSpace(l) != "" {
lines = append(lines, strings.TrimSpace(l))
}
}
var problems []string
switch {
case len(lines) == 0:
problems = append(problems, "it has no summary")
case len([]rune(lines[0])) > SummaryLength:
problems = append(problems, fmt.Sprintf("its summary is %d characters, more than %d", len([]rune(lines[0])), SummaryLength))
}
if len(lines) > 1+MostPoints {
problems = append(problems, fmt.Sprintf("it has %d points after its summary, more than %d", len(lines)-1, MostPoints))
}
for i, l := range lines {
if i > 0 && len([]rune(l)) > PointLength {
problems = append(problems, fmt.Sprintf("its point %d is %d characters, more than %d", i, len([]rune(l)), PointLength))
}
}
if len(problems) > 0 {
return errors.New("the explanation does not read at a glance: " + strings.Join(problems, "; "))
}
return nil
}
// The bounds of an ask (novox/hq ADR 0234 §8, ADR 0259 §4).
@@ -491,12 +435,8 @@ type Message struct {
Title string `json:"title"`
Body string `json:"body"`
Buttons []Button `json:"buttons,omitempty"`
// Note is the message's secondary words, after its body and its buttons: who asked and when, what each
// answer does, when it expires, and the ask's Details. A channel shows it smaller, collapsed or set apart
// where it can, and after the body where it cannot; it is never left out (novox/hq issue 639).
Note string `json:"note,omitempty"`
Urgent bool `json:"urgent,omitempty"`
Silent bool `json:"silent,omitempty"`
Urgent bool `json:"urgent,omitempty"`
Silent bool `json:"silent,omitempty"`
// Reply is the Choice or LinkAsked this answers, by its ID: the channel shows it where that was made.
Reply string `json:"reply,omitempty"`
// To is the account linked as the operator on this kind, for a channel that verifies its sender: where
+1 -1
View File
@@ -1,4 +1,4 @@
# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d
# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c
## explicit; go 1.22
git.novox.be/novox/mesh-sdk/go/asks
# github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op