From e41b78cd77907eb9c557e035fd43dc9b826fafed Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 16:23:03 +0200 Subject: [PATCH] Fill a preference's ${setting:} from its manifest default (hq ADR 0262) Without a default, a running module could never gain a setting: the file asking for it failed to compose until set, and the key was refused as stray until a file asked for it. Defaults sit under the mesh's and the node's settings, never merge into a JSON file, are refused for the operator's own values, and settings shows each value's source. --- cmd/mesh-controller/modules.go | 48 ++++- cmd/mesh-controller/plan.go | 2 +- .../settings_effective_test.go | 21 ++ internal/catalogue/consumer_into_serves.go | 2 +- internal/catalogue/declaration.go | 6 +- internal/catalogue/manifest.go | 9 + internal/catalogue/setting_defaults.go | 172 +++++++++++++++ internal/catalogue/setting_defaults_test.go | 195 ++++++++++++++++++ internal/catalogue/setting_into.go | 11 +- internal/catalogue/settings.go | 13 +- internal/catalogue/verbs.go | 4 +- 11 files changed, 470 insertions(+), 13 deletions(-) create mode 100644 cmd/mesh-controller/settings_effective_test.go create mode 100644 internal/catalogue/setting_defaults.go create mode 100644 internal/catalogue/setting_defaults_test.go diff --git a/cmd/mesh-controller/modules.go b/cmd/mesh-controller/modules.go index 5a302da1..ba777a02 100644 --- a/cmd/mesh-controller/modules.go +++ b/cmd/mesh-controller/modules.go @@ -502,13 +502,38 @@ func settingsCommand(ctx context.Context, args []string) error { } if !has { fmt.Printf("%s on %s: no layer — the module's definition says\n", positionals[0], where) - return nil + } else { + shown, err := json.MarshalIndent(values, "", " ") + if err != nil { + return err + } + fmt.Println(string(shown)) } - shown, err := json.MarshalIndent(values, "", " ") + // Every value the module gives a default or a layer sets, and where it came from (novox/hq + // ADR 0262): the default, the mesh's layer, or this node's. Said after the layer, which stays + // the first thing printed because a caller reads it before replacing it (ADR 0217). + known, err := inv.Catalogue(ctx) if err != nil { return err } - fmt.Println(string(shown)) + m, ok := known[positionals[0]] + if !ok || len(m.Settings) == 0 { + return nil + } + var layers []catalogue.Layer + if *node != "" { + if mesh, has, err := inv.Layer(ctx, "", 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}) + } + fmt.Print(describeEffective(positionals[0], where, catalogue.Effective(m, layers))) return nil case "clear": @@ -526,6 +551,23 @@ func settingsCommand(ctx context.Context, args []string) error { } } +// describeEffective says each setting's value on a machine or the whole mesh, where it came from, and +// the module's default when a layer overrides it. +func describeEffective(module, where string, values []catalogue.SettingSource) string { + var b strings.Builder + 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) + if v.HasDefault && v.From != catalogue.DefaultLayer { + d, _ := json.Marshal(v.Default) + fmt.Fprintf(&b, "; the default is %s", d) + } + b.WriteString(")\n") + } + return b.String() +} + // nodeFlag is ` --node ` for a machine's layer, nothing for the whole mesh's. func nodeFlag(node string) string { if node == "" { diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index 5b19c60c..26dddca4 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -1425,7 +1425,7 @@ func servedOnNode(ctx context.Context, inv *inventory.Inventory, node string, } serves := catalogue.ServedOn(m, provision, ports) if len(serves) > 0 { - serves, err = catalogue.Settle(serves, layers) + serves, err = catalogue.Settle(serves, catalogue.WithDefaults(m, layers)) if err != nil { return nil, err } diff --git a/cmd/mesh-controller/settings_effective_test.go b/cmd/mesh-controller/settings_effective_test.go new file mode 100644 index 00000000..02b979af --- /dev/null +++ b/cmd/mesh-controller/settings_effective_test.go @@ -0,0 +1,21 @@ +package main + +import ( + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// `settings` says each value and where it came from, and the default a layer overrides (novox/hq ADR 0262). +func TestSettingsSayWhereEachValueComesFrom(t *testing.T) { + got := describeEffective("dunst", "laptop", []catalogue.SettingSource{ + {Key: "font-size", Value: float64(13), From: "laptop", Default: float64(10), HasDefault: true}, + {Key: "width", Value: float64(250), From: catalogue.DefaultLayer, 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" + + " width = 250 (default)\n" + if got != want { + t.Fatalf("said\n%s\nwant\n%s", got, want) + } +} diff --git a/internal/catalogue/consumer_into_serves.go b/internal/catalogue/consumer_into_serves.go index 2333dd12..6757e614 100644 --- a/internal/catalogue/consumer_into_serves.go +++ b/internal/catalogue/consumer_into_serves.go @@ -238,7 +238,7 @@ func (r Resolution) derivedFor(provision, as, consumer, local string, settings S "different things and nothing would compare them (novox/hq ADR 0202)", consumer, local, provision, m.Module, orNothing(sortedAnyKeys(names))) } - settled, err := Settle(names, settings[m.Module]) + settled, err := Settle(names, WithDefaults(m, settings[m.Module])) if err != nil { return nil, fmt.Errorf("%s serving %s: %w", m.Module, provision, err) } diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index 27f42b4f..2cc13ba3 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -907,7 +907,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string, // **An operator's value, from the assignment** (novox/hq ADR 0112, ADR 0155): what a // definition may not carry because it is true of one installation only. Filled from // the same layers a mergeable file takes, and refused when no layer set it. - if err := settingInto(copied, with.Settings[m.Module], m.Module); err != nil { + if err := settingInto(copied, WithDefaults(m, with.Settings[m.Module]), m.Module); err != nil { return nil, err } // **Placed before anything reads a path.** A pathless directory receives the path @@ -1495,7 +1495,7 @@ func (r Resolution) composed(m Manifest, to string, raw map[string]any, layers [ // Overridden, not merged: a setting changes a key the contribution declares and adds none. // The provider reads the contribution as a contract, and a setting made for one of this // module's files is no part of it (novox/hq 04-ISSUES/173). - values, err := overridden(raw, layers, what) + values, err := overridden(raw, WithDefaults(m, layers), what) if err != nil { return nil, fmt.Errorf("%s: %w", what, err) } @@ -1926,7 +1926,7 @@ func (r Resolution) servedOnThisMachine(provision string, with Rendering) (map[s // one, so keep looking rather than concluding from the first. continue } - settled, err := Settle(serves, with.Settings[m.Module]) + settled, err := Settle(serves, WithDefaults(m, with.Settings[m.Module])) if err != nil { return nil, false, fmt.Errorf("%s serving %s: %w", m.Module, provision, err) } diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 2c64d59c..978c131e 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -458,6 +458,13 @@ type Manifest struct { // (novox/hq ADR 0201). Not history — that is an event — and never a secret, sealed or not. State []StateDeclaration `json:"state,omitempty"` + // Settings are the defaults this module gives its settings (novox/hq ADR 0262): each key a file, + // a contribution or a served fact asks for as `${setting:}`, its default, and why that + // default. Only a preference has one — a font size, a width, a number of workers — and a value + // that is inherently the operator's (a domain, an identity, a secret) is declared nowhere here and + // 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"` + // Data is every kind of data this module keeps — its own, by directory, and what it keeps for // its consumers, by provision — each with a class the mesh protects and watches it by (novox/hq // ADR 0233). One list: the backup holder's lines, the bindings that do not move, what an @@ -1541,6 +1548,8 @@ func ParseManifest(raw []byte) (Manifest, error) { problems = append(problems, EventProblems(m)...) // And what it may call its state, and whose it may read (state.go, novox/hq ADR 0201). problems = append(problems, StateProblems(m)...) + // And the defaults it gives its settings (setting_defaults.go, novox/hq ADR 0262). + problems = append(problems, SettingProblems(m)...) wellFormed := true for _, c := range m.Claims { if !name.MatchString(c.Name) { diff --git a/internal/catalogue/setting_defaults.go b/internal/catalogue/setting_defaults.go new file mode 100644 index 00000000..d16a2a9a --- /dev/null +++ b/internal/catalogue/setting_defaults.go @@ -0,0 +1,172 @@ +package catalogue + +import ( + "fmt" + "sort" + "strings" +) + +// A preference has a default; the operator's own value has none (novox/hq ADR 0262, extending ADR +// 0112 and taking the default half of ADR 0164). +// +// `${setting:}` was refused whenever no layer set the key, and `settings set` refused a key no +// file asked for yet. Together they meant a running module could never gain a setting: the file that +// asks for it fails to compose until somebody sets it, and nobody can set it until the file asks. +// A definition may now give a key a default, with why, when the value is a **preference** — a font +// size, a width, a number of workers: something true of the software that a machine may tune. A +// value that is inherently the operator's — a domain, a public name, an identity, a secret — gets no +// default and stays refused by name until a layer sets it, which is what ADR 0112 and ADR 0155 were +// written for. +// +// The default is the lowest layer. The mesh's layer, then the node's, override it. It fills only +// `${setting:}`: a mergeable file's content already is its defaults, and laying a default over +// it would reach every mergeable file of the module (novox/hq issue 168). + +// SettingDeclaration is one key's default, as the definition gives it. +type SettingDeclaration struct { + // Kind says what sort of value this is. `preference` is the only kind with a default; it is + // stated rather than assumed so a reviewer sees the claim being made. + Kind string `json:"kind"` + // Default is the value when no layer sets the key: a string, a number or a boolean. + Default any `json:"default"` + // Why this default: one sentence for whoever wonders whether to change it. + Why string `json:"why"` +} + +// KindPreference is the kind of a setting that may have a default. +const KindPreference = "preference" + +// DefaultLayer is what the layer of a module's own defaults is called where a value's source is said. +const DefaultLayer = "default" + +// meshWords are the settings keys the mesh reads itself; a module declares none of them. +var meshWords = map[string]bool{ + PortsSetting: true, ExposeSetting: true, ReachSetting: true, EndpointsSetting: true, + PlacesSetting: true, AccessesSetting: true, NetworksSetting: true, +} + +// operatorsOwn are words that, in a key's name, say its value is the operator's and never a +// preference: a default for one would be the literal ADR 0112 removed from definitions. +var operatorsOwn = []string{ + "domain", "host", "issuer", "url", "email", "mail", "address", "identity", "login", "user", + "account", "password", "secret", "token", "credential", +} + +// SettingProblems is every way a definition's setting defaults are wrong, in its own words. +func SettingProblems(m Manifest) []string { + if len(m.Settings) == 0 { + return nil + } + used := settingKeysUsedBy(m) + var problems []string + for _, key := range sortedSettingKeys(m.Settings) { + d := 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...)) + } + if !settingRef.MatchString("${setting:" + key + "}") { + say("not a usable key: lower-case letters, digits, dots, dashes and underscores") + continue + } + if meshWords[key] { + 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) + } + for _, word := range operatorsOwn { + if strings.Contains(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) + break + } + } + 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) + } + if strings.TrimSpace(d.Why) == "" { + say("no why: say in one sentence why this default") + } + if !used[key] { + say("nothing asks for ${setting:%s}, so the default reaches nothing", key) + } + } + return problems +} + +// 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 { + return Layer{}, false + } + values := map[string]any{} + for key, d := range m.Settings { + if d.Default != nil { + values[key] = d.Default + } + } + return Layer{From: DefaultLayer, Values: values}, len(values) > 0 +} + +// WithDefaults is a module's layers with its defaults under them, for filling `${setting:}`. +func WithDefaults(m Manifest, layers []Layer) []Layer { + d, has := Defaults(m) + if !has { + return layers + } + return append([]Layer{d}, layers...) +} + +// SettingSource is one key's effective value and the layer it came from. +type SettingSource struct { + Key string + Value any + // From is DefaultLayer, MeshWideLayer or the node's name. + From string + // Default is the module's default, when it gives one. + Default any + HasDefault bool +} + +// 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. +func Effective(m Manifest, layers []Layer) []SettingSource { + byKey := map[string]*SettingSource{} + for key, d := range m.Settings { + byKey[key] = &SettingSource{Key: key, Value: d.Default, From: DefaultLayer, Default: d.Default, HasDefault: true} + } + for _, layer := range layers { + for key, v := range layer.Values { + s, ok := byKey[key] + if !ok { + s = &SettingSource{Key: key} + byKey[key] = s + } + s.Value, s.From = v, layer.From + } + } + out := make([]SettingSource, 0, len(byKey)) + for _, s := range byKey { + out = append(out, *s) + } + sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key }) + return out +} + +func sortedSettingKeys(in map[string]SettingDeclaration) []string { + keys := make([]string, 0, len(in)) + for k := range in { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/internal/catalogue/setting_defaults_test.go b/internal/catalogue/setting_defaults_test.go new file mode 100644 index 00000000..4464c62c --- /dev/null +++ b/internal/catalogue/setting_defaults_test.go @@ -0,0 +1,195 @@ +package catalogue + +import ( + "encoding/json" + "strings" + "testing" +) + +// A preference has a default in the definition; the mesh's layer, then the node's, override it; a key +// with a default is not stray; and a value that is the operator's still has none (novox/hq ADR 0262). + +func notifier() Manifest { + return Manifest{Module: "notifier", + Settings: map[string]SettingDeclaration{ + "font-size": {Kind: KindPreference, Default: float64(10), Why: "readable at a scale of one"}, + "width": {Kind: KindPreference, Default: float64(250), Why: "fits a title of forty characters"}, + }, + Resources: []map[string]any{{"id": "configuration", "type": "file", "path": "/x/notifierrc", + "content": "font = Inter ${setting:font-size}\nwidth = ${setting:width}\n"}}, + } +} + +func parsed(t *testing.T, m Manifest) error { + t.Helper() + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + _, err = ParseManifest(raw) + return err +} + +func TestAnUnsetPreferenceTakesItsDefault(t *testing.T) { + m := notifier() + for _, layers := range [][]Layer{nil, {{From: MeshWideLayer, Values: map[string]any{}}}} { + file := map[string]any{} + for k, v := range m.Resources[0] { + file[k] = v + } + if err := settingInto(file, WithDefaults(m, layers), m.Module); err != nil { + t.Fatal(err) + } + if file["content"] != "font = Inter 10\nwidth = 250\n" { + t.Fatalf("filled as %q", file["content"]) + } + } + if err := JudgeSettings(m, nil, false); err != nil { + t.Fatalf("a module whose every key has a default does not compose with no layer: %v", err) + } +} + +func TestTheNodeOverTheMeshOverTheDefault(t *testing.T) { + m := notifier() + layers := []Layer{ + {From: MeshWideLayer, Values: map[string]any{"width": float64(300)}}, + {From: "laptop", Values: map[string]any{"font-size": float64(13), "width": float64(340)}}, + } + file := map[string]any{"type": "file", "content": m.Resources[0]["content"]} + if err := settingInto(file, WithDefaults(m, layers), m.Module); err != nil { + t.Fatal(err) + } + if file["content"] != "font = Inter 13\nwidth = 340\n" { + t.Fatalf("filled as %q", file["content"]) + } + file = map[string]any{"type": "file", "content": m.Resources[0]["content"]} + if err := settingInto(file, WithDefaults(m, layers[:1]), m.Module); err != nil { + t.Fatal(err) + } + if file["content"] != "font = Inter 10\nwidth = 300\n" { + t.Fatalf("the mesh's layer over the default filled as %q", file["content"]) + } +} + +// Composed for a machine, the way a push writes it: the default reaches the file, and the node's +// layer overrides it. +func TestAComposedMachineGetsTheDefaultAndTheNodesValue(t *testing.T) { + r := anAdoptedAnchor() + r.Modules = append(r.Modules, notifier()) + with := anchorRendering(false) + composed, err := r.Compose(with) + if err != nil { + t.Fatal(err) + } + if why, left := composed.LeftOut["notifier"]; left { + t.Fatalf("the notifier was left out: %s", why) + } + if c := byID(composed.Resources)["notifier.configuration"]["content"]; c != "font = Inter 10\nwidth = 250\n" { + t.Fatalf("composed with no layer as %q", c) + } + with.Settings["notifier"] = []Layer{{From: "anchor", Values: map[string]any{"font-size": float64(13)}}} + if composed, err = r.Compose(with); err != nil { + t.Fatal(err) + } + if c := byID(composed.Resources)["notifier.configuration"]["content"]; c != "font = Inter 13\nwidth = 250\n" { + t.Fatalf("composed with the node's font size as %q", c) + } +} + +// Setting a key the module gives a default is not refused as reaching nothing: it overrides the +// default, which is how a running module gains a setting with no gap between. +func TestAKeyWithADefaultIsNotStray(t *testing.T) { + m := notifier() + m.Resources[0]["content"] = "font = Inter ${setting:font-size}\nwidth = ${setting:width}\n" + stray := UnusedSettings(m, []Layer{{From: "laptop", Values: map[string]any{"font-size": float64(13), "colour": "red"}}}) + joined := strings.Join(stray, "; ") + if strings.Contains(joined, `"font-size"`) || !strings.Contains(joined, `"colour"`) { + t.Fatalf("stray: %s", joined) + } +} + +// A default fills ${setting:…} only: it never becomes a key of a mergeable file (issue 168). +func TestADefaultIsNotMergedIntoAJSONFile(t *testing.T) { + m := notifier() + m.Resources = append(m.Resources, map[string]any{"id": "other", "type": "file", "path": "/x/other.json", + "merge": MergeJSON, "content": `{"keep": 1}`}) + out, err := ApplySettings(m.Resources[1], WithDefaults(m, nil)) + if err != nil { + t.Fatal(err) + } + if strings.Contains(out["content"].(string), "font-size") { + t.Fatalf("a default reached a mergeable file: %s", out["content"]) + } +} + +// A value that is the operator's has no default: no layer setting it is refused by name, as before. +func TestAnOperatorsValueWithoutADefaultIsStillRefused(t *testing.T) { + m := notifier() + m.Resources[0]["content"] = "font = Inter ${setting:font-size}\nwidth = ${setting:width}\nfrom = ${setting:domain}\n" + err := JudgeSettings(m, nil, false) + if err == nil || !strings.Contains(err.Error(), "${setting:domain}") { + t.Fatalf("judged %v", err) + } + if strings.Contains(err.Error(), "font-size") { + t.Fatalf("a default was named as set today: %v", err) + } +} + +func TestTheParserTakesAPreferenceAndRefusesTheRest(t *testing.T) { + if err := parsed(t, notifier()); err != nil { + t.Fatalf("a preference with a default was refused: %v", err) + } + for _, c := range []struct { + name string + key string + d SettingDeclaration + refuse string + }{ + {"no kind", "font-size", SettingDeclaration{Default: float64(10), Why: "x"}, `only a "preference" has a default`}, + {"another kind", "font-size", SettingDeclaration{Kind: "operator", Default: float64(10), Why: "x"}, `only a "preference"`}, + {"no default", "font-size", SettingDeclaration{Kind: KindPreference, Why: "x"}, "no default"}, + {"an empty default", "font-size", SettingDeclaration{Kind: KindPreference, Default: " ", Why: "x"}, "an empty default"}, + {"an object", "font-size", SettingDeclaration{Kind: KindPreference, Default: map[string]any{"a": 1.0}, Why: "x"}, "a string, a number or a boolean"}, + {"no why", "font-size", SettingDeclaration{Kind: KindPreference, Default: float64(10)}, "no why"}, + {"the operator's", "mail-domain", SettingDeclaration{Kind: KindPreference, Default: "example.tld", Why: "x"}, "is the operator's value"}, + {"a secret", "api-token", SettingDeclaration{Kind: KindPreference, Default: "x", Why: "x"}, "is the operator's value"}, + {"the mesh's word", PortsSetting, SettingDeclaration{Kind: KindPreference, Default: float64(1), Why: "x"}, "the mesh reads"}, + {"read by nothing", "height", SettingDeclaration{Kind: KindPreference, Default: float64(300), Why: "x"}, "reaches nothing"}, + } { + m := notifier() + m.Resources[0]["content"] = m.Resources[0]["content"].(string) + "x = ${setting:" + c.key + "}\n" + if c.name == "read by nothing" { + m.Resources[0]["content"] = "font = Inter ${setting:font-size}\nwidth = ${setting:width}\n" + } + m.Settings[c.key] = c.d + err := parsed(t, m) + if err == nil || !strings.Contains(err.Error(), c.refuse) { + t.Errorf("%s: parsed %v, want %q", c.name, err, c.refuse) + } + } +} + +func TestEveryValueSaysWhereItCameFrom(t *testing.T) { + m := notifier() + m.Resources[0]["content"] = m.Resources[0]["content"].(string) + "x = ${setting:position}\n" + got := Effective(m, []Layer{ + {From: MeshWideLayer, Values: map[string]any{"width": float64(300), "position": "top-right"}}, + {From: "laptop", Values: map[string]any{"font-size": float64(13)}}, + }) + want := map[string]string{"font-size": "laptop", "position": MeshWideLayer, "width": MeshWideLayer} + if len(got) != 3 { + t.Fatalf("effective: %+v", got) + } + for _, s := range got { + if s.From != want[s.Key] { + t.Errorf("%s from %q, want %q", s.Key, s.From, want[s.Key]) + } + } + if got[0].Key != "font-size" || got[0].Value != float64(13) || got[0].Default != float64(10) { + t.Fatalf("font-size: %+v", got[0]) + } + only := Effective(m, nil) + if only[0].From != DefaultLayer || only[0].Value != float64(10) { + t.Fatalf("with no layer: %+v", only) + } +} diff --git a/internal/catalogue/setting_into.go b/internal/catalogue/setting_into.go index 751f3b17..8f631c63 100644 --- a/internal/catalogue/setting_into.go +++ b/internal/catalogue/setting_into.go @@ -40,8 +40,9 @@ func settingsUsed(content string) []string { // settingInto fills a file's ${setting:…} placeholders from the layers over a module. // -// The last layer setting a key wins, which is the node's over the mesh's — the same order settle -// applies to a mergeable file. A value that is not a string is written the way a program would read +// The last layer setting a key wins, which is the node's over the mesh's over the module's own +// default (novox/hq ADR 0262) — the same order settle applies to a mergeable file. The caller lays +// the defaults under the layers with WithDefaults. A value that is not a string is written the way a program would read // it (a number without a trailing .000000, a boolean as true/false). func settingInto(resource map[string]any, layers []Layer, module string) error { if fmt.Sprint(resource["type"]) != "file" { @@ -56,7 +57,8 @@ func settingInto(resource map[string]any, layers []Layer, module string) error { if !set { return fmt.Errorf( "%s has a file that says ${setting:%s}, and nothing sets %q for it — an operator's "+ - "value is the assignment's, never the definition's (novox/hq ADR 0112): "+ + "value is the assignment's, never the definition's (novox/hq ADR 0112), and only a "+ + "preference has a default in the definition (ADR 0262): "+ "`settings set %s ` with {%q: …}%s", module, key, key, module, key, orNoSettings(layers)) } @@ -80,6 +82,9 @@ func settingValue(layers []Layer, key string) (any, bool) { func orNoSettings(layers []Layer) string { var keys []string for _, l := range layers { + if l.From == DefaultLayer { + continue + } for k := range l.Values { keys = append(keys, k) } diff --git a/internal/catalogue/settings.go b/internal/catalogue/settings.go index 1c5c03e9..ea330369 100644 --- a/internal/catalogue/settings.go +++ b/internal/catalogue/settings.go @@ -151,6 +151,12 @@ 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.From == DefaultLayer { + // A module's own defaults fill ${setting:} 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). + continue + } for key, value := range layer.Values { if key == PortsSetting { // Where the machine puts a port is the mesh's to apply, not a value for a file or @@ -224,6 +230,11 @@ func UnusedSettings(m Manifest, layers []Layer) []string { } lands := settingKeysUsedBy(m) + // 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 { + lands[key] = true + } for _, values := range m.Contributes { for key := range values { lands[key] = true @@ -420,7 +431,7 @@ func JudgeSettings(m Manifest, layers []Layer, adopted bool) error { for k, v := range settled { copied[k] = v } - if err := settingInto(copied, layers, m.Module); err != nil { + if err := settingInto(copied, WithDefaults(m, layers), m.Module); err != nil { return err } } diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index edb2c92b..f2ddd8e6 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -249,7 +249,9 @@ var ControllerVerbs = []Verb{ }, 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; " + + "mesh, or for one machine. Without values or clear, answers the layer as it stands — read it before setting it — " + + "then every value the module gives a default or a layer sets, with where it comes from: the module's default, " + + "the mesh, or the machine (novox/hq ADR 0262); " + "with history, the layers it replaced. Setting replaces that layer whole and answers each key it adds (+), " + "changes (~) and removes (-); a set that would remove a key is refused unless replace says it is meant " + "(novox/hq ADR 0217). Takes effect at the next push. With clear, removes the layer and the module is back to " +