diff --git a/internal/catalogue/config.go b/internal/catalogue/config.go new file mode 100644 index 00000000..90e9ea71 --- /dev/null +++ b/internal/catalogue/config.go @@ -0,0 +1,469 @@ +package catalogue + +import ( + "encoding/json" + "fmt" + "regexp" + "slices" + "strings" +) + +// A module declares its config (novox/hq ADR 0348 §1, §4, §5, §6, §8, §11; design 57 package K1, issue 660). +// +// **One concept, two spellings for a while.** "Config" replaces "settings": the manifest says `config` for +// `settings`, `takes-config` for `takes-settings` and `${config:}` for `${setting:}`. The old names stay +// accepted as aliases until every module has moved (ADR 0348 §1), and a module uses one form or the other, never +// both. The config form is read into the very fields the settings form fills — Settings, a mergeable file's +// takes-settings and `${setting:}` — so composition has one path and `${config:}` fills exactly as +// `${setting:}` does: a module rewritten from settings to config composes byte-identically. It is written +// back in the form it was read in, so what the mesh holds is what its author wrote. +// +// **A moved module is held to the whole declaration.** Under `config` every value has a type (number, string or +// boolean; an object or a list only when trusted), a description including its unit, its validation (enum, +// minimum and maximum, pattern), whether it is trusted, optionally the predefined key it falls back to, and either +// a default or `required`; it lands in a file or is read at runtime (`runtime: true`); every mergeable file names +// the keys it takes; a `reload-on` or `restart-on` names a file the module declares. A module not yet moved keeps +// today's form and today's rules (SettingProblems, TakenSettingsProblems) until the catalogue's list of such +// modules is empty (ADR 0348 §11). +// +// **A required value nothing sets** is the operator's own: it composes as an operator's setting nobody gave does +// today, refused by name at composition (UnsetSettingError). Who reports it once mesh-config answers values is the +// provisioner's (ADR 0349), not this file's. + +// ConfigField is the manifest key a module declares its config values under. +const ConfigField = "config" + +// TakesConfigField is the key a mergeable file names the config values it takes by. +const TakesConfigField = "takes-config" + +// SettingsField is the old name of ConfigField, read as its alias. +const SettingsField = "settings" + +// configRef is how a file asks for a config value: ${config:}, the same keys as settingRef. +var configRef = regexp.MustCompile(`\$\{config:([a-z0-9][a-z0-9_.-]*)\}`) + +// takesConfigKey and takesSettingsKey are the two spellings as a key of a JSON object. A string value is never +// followed by a colon, and a quote inside a string is escaped, so these match only keys. +var ( + takesConfigKey = regexp.MustCompile(`"` + TakesConfigField + `"(\s*):`) + takesSettingsKey = regexp.MustCompile(`"` + TakesSettingsField + `"(\s*):`) +) + +// The types a config value may have (ADR 0348 §4). +const ( + ConfigNumber = "number" + ConfigString = "string" + ConfigBoolean = "boolean" + ConfigObject = "object" + ConfigList = "list" +) + +// accountFacts are what the mesh places for a module's account, never config values (ADR 0348 §11). +var accountFacts = map[string]bool{"puid": true, "pgid": true} + +// PredefinedKey is one key of the catalogue's list of predefined keys, as far as a module's value falling back +// to it is judged here: its type, and whether it is trusted (ADR 0348 §7, §8). +type PredefinedKey struct { + Type string + Trusted bool +} + +// predefinedKeys is the list of predefined keys, nil until it is present. The list (`predefined.json` at the +// catalogue's root) arrives with design 57's package K2 (issue 661); until then a value naming one is refused +// saying the list is not yet present. +var predefinedKeys map[string]PredefinedKey + +// configForms is which of the two forms a manifest was written in, by the words of each it uses. +type configForms struct { + config, settings []string +} + +// DeclaresConfig is whether the manifest is written in the config form: `config`, `takes-config` or +// `${config:}` (ADR 0348 §1, §11). +func (m Manifest) DeclaresConfig() bool { return len(m.forms.config) > 0 } + +// toSettingsForm is raw with the config form's placeholders and takes-config read as the settings form's, and +// which words of each form it used. `config` itself is a top-level key, renamed where the keys are read +// (UnmarshalJSON). +func toSettingsForm(raw []byte) ([]byte, configForms) { + var f configForms + if configRef.Match(raw) { + f.config = append(f.config, "${config:…}") + } + if takesConfigKey.Match(raw) { + f.config = append(f.config, TakesConfigField) + } + if settingRef.Match(raw) { + f.settings = append(f.settings, "${setting:…}") + } + if takesSettingsKey.Match(raw) { + f.settings = append(f.settings, TakesSettingsField) + } + if len(f.config) == 0 { + return raw, f + } + raw = configRef.ReplaceAllFunc(raw, func(ref []byte) []byte { + return []byte("${setting:" + string(configRef.FindSubmatch(ref)[1]) + "}") + }) + raw = takesConfigKey.ReplaceAllFunc(raw, func(key []byte) []byte { + return []byte(`"` + TakesSettingsField + `"` + string(takesConfigKey.FindSubmatch(key)[1]) + ":") + }) + return raw, f +} + +// toConfigForm is a manifest written in the settings form's words, as JSON, in the config form's: what +// toSettingsForm and UnmarshalJSON read, written back. +func toConfigForm(raw []byte) ([]byte, error) { + var keys map[string]json.RawMessage + if err := json.Unmarshal(raw, &keys); err != nil { + return nil, err + } + if settings, has := keys[SettingsField]; has { + keys[ConfigField] = settings + delete(keys, SettingsField) + var err error + if raw, err = json.Marshal(keys); err != nil { + return nil, err + } + } + raw = settingRef.ReplaceAllFunc(raw, func(ref []byte) []byte { + return []byte("${config:" + string(settingRef.FindSubmatch(ref)[1]) + "}") + }) + raw = takesSettingsKey.ReplaceAllFunc(raw, func(key []byte) []byte { + return []byte(`"` + TakesConfigField + `"` + string(takesSettingsKey.FindSubmatch(key)[1]) + ":") + }) + return raw, nil +} + +// inConfigWords is a refusal said of a module in the config form in its words: `${config:}` and +// takes-config, where the rules shared with the settings form say `${setting:}` and takes-settings. +func inConfigWords(problem string) string { + problem = settingRef.ReplaceAllStringFunc(problem, func(ref string) string { + return "${config:" + settingRef.FindStringSubmatch(ref)[1] + "}" + }) + return strings.ReplaceAll(problem, TakesSettingsField, TakesConfigField) +} + +// MixedFormProblems is a manifest using both forms, said with the words of each (ADR 0348 §1). +func MixedFormProblems(m Manifest) []string { + if len(m.forms.config) == 0 || len(m.forms.settings) == 0 { + return nil + } + return []string{fmt.Sprintf("%s mixes the config form (%s) with the settings form (%s): a module uses one form "+ + "or the other, never both; write %s, %s and ${config:} throughout (novox/hq ADR 0348 §1)", + m.Module, strings.Join(m.forms.config, ", "), strings.Join(m.forms.settings, ", "), + ConfigField, TakesConfigField)} +} + +// configDeclaration is the config form's declaration as written: nothing of the settings form's, and nothing +// unsaid. +type configDeclaration struct { + Type string `json:"type,omitempty"` + Description string `json:"description,omitempty"` + Enum []any `json:"enum,omitempty"` + Minimum *float64 `json:"minimum,omitempty"` + Maximum *float64 `json:"maximum,omitempty"` + Pattern string `json:"pattern,omitempty"` + Trusted bool `json:"trusted,omitempty"` + Predefined string `json:"predefined,omitempty"` + Default any `json:"default,omitempty"` + Required bool `json:"required,omitempty"` + Runtime bool `json:"runtime,omitempty"` +} + +// MarshalJSON writes a settings declaration exactly as before, and a config declaration without the settings +// form's kind and why, and without a default it does not have. +func (d SettingDeclaration) MarshalJSON() ([]byte, error) { + type plain SettingDeclaration + if d.Kind != "" || d.Why != "" { + return json.Marshal(plain(d)) + } + return json.Marshal(configDeclaration{Type: d.Type, Description: d.Description, Enum: d.Enum, + Minimum: d.Minimum, Maximum: d.Maximum, Pattern: d.Pattern, Trusted: d.Trusted, Predefined: d.Predefined, + Default: d.Default, Required: d.Required, Runtime: d.Runtime}) +} + +// configShaped is whether a declaration says anything of the config form's. +func configShaped(d SettingDeclaration) bool { + return d.Type != "" || d.Description != "" || len(d.Enum) > 0 || d.Minimum != nil || d.Maximum != nil || + d.Pattern != "" || d.Trusted || d.Predefined != "" || d.Required || d.Runtime +} + +// TypedSettingProblems is a declaration under `settings` that says what only `config` says: a value with a type, +// a description and its validation is declared in the config form (ADR 0348 §1, §4). +func TypedSettingProblems(m Manifest) []string { + var problems []string + for _, key := range sortedSettingKeys(m.Settings) { + if configShaped(m.Settings[key]) { + problems = append(problems, fmt.Sprintf("%s's setting %q says its type, description or validation "+ + "under %s: a value declared with those is declared under config, with ${config:} and "+ + "takes-config, never under %s (novox/hq ADR 0348 §1, §4)", m.Module, key, SettingsField, SettingsField)) + } + } + return problems +} + +// ConfigProblems is every way a module in the config form declares its config wrongly, each said by name with +// what to do (ADR 0348 How it is checked, design 57 package K1). +func ConfigProblems(m Manifest) []string { + if !m.DeclaresConfig() { + return nil + } + var problems []string + placed := configPlaced(m) + for _, key := range sortedSettingKeys(m.Settings) { + d := m.Settings[key] + say := func(format string, args ...any) { + problems = append(problems, fmt.Sprintf("%s's config value %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] || accountFacts[key] { + say("%q is placement, which the mesh decides, never a config value (novox/hq ADR 0348 §11)", key) + continue + } + if d.Kind != "" || d.Why != "" { + say("kind and why are the settings form's; under config a value says its type and its description " + + "(novox/hq ADR 0348 §1, §4)") + } + switch d.Type { + case ConfigNumber, ConfigString, ConfigBoolean: + case ConfigObject, ConfigList: + if !d.Trusted { + say("is %s and not trusted: an object or a list decides more than one value can say, so it is "+ + "trusted, \"trusted\": true, or split into numbers, strings and booleans (novox/hq ADR 0348 §4)", d.Type) + } + case "": + say("no type: say \"type\": number, string or boolean, or object or list when trusted (novox/hq ADR 0348 §4)") + default: + say("type %q is none of number, string, boolean, object and list (novox/hq ADR 0348 §4)", d.Type) + } + if strings.TrimSpace(d.Description) == "" { + say("no description: say in a sentence what the value is, including its unit (novox/hq ADR 0348 §4)") + } + switch { + case d.Required && d.Default != nil: + say("both a default and required: a required value is the operator's own and has no default; give " + + "one or the other (novox/hq ADR 0348 §4)") + case !d.Required && d.Default == nil: + say("neither a default nor required: give a default, or say \"required\": true for a value that is " + + "the operator's own (novox/hq ADR 0348 §4)") + } + for _, wrong := range validationProblems(d) { + say("%s", wrong) + } + if word := operatorsWord(key); word != "" && d.Default != nil { + say("a key naming %q is the operator's value: declare it \"required\": true, with no default "+ + "(novox/hq ADR 0348 §4, ADR 0112)", word) + } + if d.Predefined != "" { + p, listed := predefinedKeys[d.Predefined] + switch { + case predefinedKeys == nil: + say("falls back to the predefined key %q, and the list of predefined keys (the catalogue's "+ + "predefined.json, design 57 package K2) is not yet present: no value can fall back to one "+ + "until it is; leave \"predefined\" out for now (novox/hq ADR 0348 §7)", d.Predefined) + case !listed: + say("falls back to %q, which is not on the list of predefined keys: name a key on it, or leave "+ + "\"predefined\" out (novox/hq ADR 0348 §7)", d.Predefined) + default: + if p.Type != d.Type { + say("falls back to the predefined key %q, which is a %s, and this value is a %s: a value falls "+ + "back only to a key of its own type (novox/hq ADR 0348 §4)", d.Predefined, p.Type, d.Type) + } + if p.Trusted && !d.Trusted { + say("falls back to the trusted predefined key %q and is not trusted itself: a value that falls "+ + "back to a trusted key is trusted, \"trusted\": true (novox/hq ADR 0348 §8)", d.Predefined) + } + } + } + // A template's text is not here yet. Resolve fills it from the build, WithTemplatesFrom from the tree; neither + // judges again itself, but WithTemplatesFrom parses its result again, and a resolved build is parsed again + // where it is registered, and this is judged then. + if !placed[key] && !d.Runtime && !m.Templated() { + say("placed nowhere: no file says ${config:%s} or takes it under %s, and it is not marked "+ + "\"runtime\": true for a value the module's code reads itself (novox/hq ADR 0348 §5)", key, TakesConfigField) + } + } + used := settingKeysUsedBy(m) + for _, key := range sortedKeys(used) { + if _, declared := m.Settings[key]; !declared { + problems = append(problems, fmt.Sprintf("%s says ${config:%s} and declares no such value under %s: "+ + "declare %q with its type, description and a default or required (novox/hq ADR 0348 §4, §5)", + m.Module, key, ConfigField, key)) + } + } + problems = append(problems, configTakesProblems(m)...) + problems = append(problems, reflectsProblems(m)...) + return problems +} + +// configPlaced is every value a file, a unit, a directory's owner, a contribution or a served fact asks for, and +// every value a mergeable file takes. +func configPlaced(m Manifest) map[string]bool { + placed := settingKeysUsedBy(m) + for _, r := range m.Resources { + for _, k := range stringsOf(r[TakesSettingsField]) { + placed[k] = true + } + } + return placed +} + +// validationProblems is what is wrong with a declaration's validation, and its default against it. +func validationProblems(d SettingDeclaration) []string { + var problems []string + isNumber := d.Type == ConfigNumber + if len(d.Enum) > 0 && d.Type != ConfigNumber && d.Type != ConfigString { + problems = append(problems, fmt.Sprintf("an enum is for a number or a string, and this is a %s", d.Type)) + } + if (d.Minimum != nil || d.Maximum != nil) && !isNumber { + problems = append(problems, fmt.Sprintf("a minimum or maximum is for a number, and this is a %s", d.Type)) + } + if d.Minimum != nil && d.Maximum != nil && *d.Minimum > *d.Maximum { + problems = append(problems, fmt.Sprintf("its minimum %v is above its maximum %v", *d.Minimum, *d.Maximum)) + } + var pattern *regexp.Regexp + if d.Pattern != "" { + if d.Type != ConfigString { + problems = append(problems, fmt.Sprintf("a pattern is for a string, and this is a %s", d.Type)) + } + var err error + if pattern, err = regexp.Compile(`^(?:` + d.Pattern + `)$`); err != nil { + problems = append(problems, fmt.Sprintf("its pattern %q is not a regular expression: %v", d.Pattern, err)) + } + } + for _, v := range d.Enum { + if !ofType(v, d.Type) { + problems = append(problems, fmt.Sprintf("its enum holds %v, which is not a %s", v, d.Type)) + } + } + if d.Default == nil { + return problems + } + if !ofType(d.Default, d.Type) { + if d.Type != "" { + problems = append(problems, fmt.Sprintf("its default %v is not a %s", d.Default, d.Type)) + } + return problems + } + if len(d.Enum) > 0 && !slices.ContainsFunc(d.Enum, func(v any) bool { return v == d.Default }) { + problems = append(problems, fmt.Sprintf("its default %v is not one of %v", d.Default, d.Enum)) + } + if n, ok := d.Default.(float64); ok { + if d.Minimum != nil && n < *d.Minimum { + problems = append(problems, fmt.Sprintf("its default %v is below its minimum %v", n, *d.Minimum)) + } + if d.Maximum != nil && n > *d.Maximum { + problems = append(problems, fmt.Sprintf("its default %v is above its maximum %v", n, *d.Maximum)) + } + } + if s, ok := d.Default.(string); ok && pattern != nil && !pattern.MatchString(s) { + problems = append(problems, fmt.Sprintf("its default %q does not match its pattern %q", s, d.Pattern)) + } + return problems +} + +// ofType is whether a value read from JSON is of a config type. +func ofType(v any, kind string) bool { + switch v.(type) { + case float64: + return kind == ConfigNumber + case string: + return kind == ConfigString + case bool: + return kind == ConfigBoolean + case map[string]any: + return kind == ConfigObject + case []any: + return kind == ConfigList + } + return false +} + +// configTakesProblems is what ADR 0348 §11 adds for a module in the config form: an account fact in +// takes-config (the mesh's own words are refused for both forms by TakenSettingsProblems), and a mergeable file +// naming no keys where none of the module's does (where one does, TakenSettingsProblems names every one that +// does not). +func configTakesProblems(m Manifest) []string { + var problems, silent []string + naming := false + for _, r := range m.Resources { + if how, _ := r["merge"].(string); how == "" { + continue + } + if _, said := r[TakesSettingsField]; !said { + silent = append(silent, fmt.Sprint(r["id"])) + continue + } + naming = true + for _, k := range stringsOf(r[TakesSettingsField]) { + _, declared := m.Settings[k] + switch { + case accountFacts[k]: + problems = append(problems, fmt.Sprintf("%s's %v names %q in %s, and %q is an account fact the "+ + "mesh places, never a config value (novox/hq ADR 0348 §11)", m.Module, r["id"], k, + TakesConfigField, k)) + case meshWords[k]: + // Refused for both forms by TakenSettingsProblems, in its words. + case !declared: + problems = append(problems, fmt.Sprintf("%s's %v names %q in %s and declares no such value under "+ + "%s: a value a file takes is declared like every other, with its type, description and a "+ + "default or required, or the config verb could never set it (novox/hq ADR 0348 §4, §11)", + m.Module, r["id"], k, TakesConfigField, ConfigField)) + } + } + } + if !naming { + for _, id := range silent { + problems = append(problems, fmt.Sprintf("%s's mergeable %s names no %s: a module that declares config "+ + "names the keys each mergeable file takes, an empty list for none (novox/hq ADR 0348 §11)", + m.Module, id, TakesConfigField)) + } + } + return problems +} + +// meshMadeIDs are the resources the mesh composes into a module's own set, which its `restart-on` may name +// without declaring them; meshMadePrefixes are those named after what they are for. +var ( + meshMadeIDs = []string{ComposedJailsID(), FilteringID(), CertificateID(), AuthorityID(), BusUsersID()} + meshMadePrefixes = []string{NeedID(""), SecretID(""), strings.TrimSuffix(GrantID("", ""), "-"), BoundID(""), ReceivedID(""), AccessID("/")} +) + +// reflectsProblems is a `reload-on` or `restart-on` naming a file the module does not declare (ADR 0348 §6): not +// one of its own resources, not one the mesh composes for it, and not another module's, which is named +// `.`. +func reflectsProblems(m Manifest) []string { + own := map[string]bool{} + for _, r := range m.Resources { + own[fmt.Sprint(r["id"])] = true + } + meshMade := func(id string) bool { + if slices.Contains(meshMadeIDs, id) { + return true + } + for _, p := range meshMadePrefixes { + if strings.HasPrefix(id, p) && len(id) > len(p) { + return true + } + } + return false + } + var problems []string + for _, r := range m.Resources { + for _, field := range []string{"reload-on", "restart-on"} { + for _, id := range stringsOf(r[field]) { + if strings.Contains(id, ".") || own[id] || meshMade(id) { + continue + } + problems = append(problems, fmt.Sprintf("%s's %v names %q in its %s, and %s declares no such "+ + "file: name a file of its own, or another module's as . (novox/hq ADR 0348 §6)", + m.Module, r["id"], id, field, m.Module)) + } + } + } + return problems +} diff --git a/internal/catalogue/config_test.go b/internal/catalogue/config_test.go new file mode 100644 index 00000000..1a2d8499 --- /dev/null +++ b/internal/catalogue/config_test.go @@ -0,0 +1,453 @@ +package catalogue + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + "testing" +) + +// A module declares its config (novox/hq ADR 0348 §1, §4, §5, §6, §8, §11; design 57 package K1, issue 660): +// one registration refusal per rule of design 57's K1, the old names read as aliases, and a module rewritten +// from settings to config composing byte-identically. + +// inConfigForm is a module in the config form that registers: every value typed, described, defaulted or +// required, placed in a file or read at runtime, and its mergeable file naming its keys. +func inConfigForm() map[string]any { + return map[string]any{ + "module": "notifier", + "version": "1", + "config": map[string]any{ + "font-size": map[string]any{"type": "number", "description": "the notification's font size, in points", + "minimum": 6, "maximum": 40, "default": 10}, + "width": map[string]any{"type": "number", "description": "the notification's width, in pixels", + "default": 250}, + "position": map[string]any{"type": "string", "description": "the corner it shows in, a word", + "enum": []any{"top-right", "top-left"}, "default": "top-right"}, + "theme": map[string]any{"type": "string", "description": "the colour theme, a lower-case name", + "pattern": "[a-z]+", "default": "dark"}, + "history": map[string]any{"type": "number", "description": "how many notifications it keeps, a count", + "default": 20, "runtime": true}, + "sender": map[string]any{"type": "string", "description": "the address it mails a digest from", + "required": true}, + }, + "resources": []any{ + map[string]any{"id": "configuration", "type": "file", "path": "/x/notifierrc", + "content": "font = Inter ${config:font-size}\nwidth = ${config:width}\nfrom = ${config:sender}\n"}, + map[string]any{"id": "rules", "type": "file", "path": "/x/rules.json", "merge": "json", + "takes-config": []any{"position", "theme"}, "content": "{\"position\": \"top-right\"}\n"}, + map[string]any{"id": "notifier", "type": "service", "unit": "notifier.service", + "reload-on": []any{"configuration"}, "restart-on": []any{"rules"}}, + }, + } +} + +// declaration is one value of inConfigForm's config, to change in place. +func declaration(m map[string]any, key string) map[string]any { + return m["config"].(map[string]any)[key].(map[string]any) +} + +func resourceOf(m map[string]any, id string) map[string]any { + for _, r := range m["resources"].([]any) { + if r.(map[string]any)["id"] == id { + return r.(map[string]any) + } + } + return nil +} + +func parseRaw(t *testing.T, m map[string]any) (Manifest, error) { + t.Helper() + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + return ParseManifest(raw) +} + +// refused is that the manifest is refused with every one of these words. +func refused(t *testing.T, m map[string]any, words ...string) { + t.Helper() + _, err := parseRaw(t, m) + if err == nil { + t.Fatalf("registered; wanted a refusal saying %q", words) + } + for _, w := range words { + if !strings.Contains(err.Error(), w) { + t.Fatalf("refused without %q:\n%v", w, err) + } + } +} + +func TestAModuleInTheConfigFormRegisters(t *testing.T) { + m, err := parseRaw(t, inConfigForm()) + if err != nil { + t.Fatal(err) + } + if !m.DeclaresConfig() { + t.Fatal("a module declaring config is not read as in the config form") + } + d := m.Settings["font-size"] + if d.Type != "number" || d.Description == "" || d.Minimum == nil || *d.Minimum != 6 || d.Default != float64(10) { + t.Fatalf("font-size read as %+v", d) + } + if s := m.Settings["sender"]; !s.Required || s.Default != nil { + t.Fatalf("sender read as %+v", s) + } +} + +// Rule 1: a value without type or description. +func TestConfigRefusesAValueWithoutTypeOrDescription(t *testing.T) { + m := inConfigForm() + delete(declaration(m, "width"), "type") + refused(t, m, `notifier's config value "width"`, "no type") + m = inConfigForm() + declaration(m, "width")["type"] = "colour" + refused(t, m, `"width"`, `type "colour"`) + m = inConfigForm() + delete(declaration(m, "width"), "description") + refused(t, m, `"width"`, "no description") +} + +// Rule 2: a value with neither a default nor required. +func TestConfigRefusesAValueWithNeitherDefaultNorRequired(t *testing.T) { + m := inConfigForm() + delete(declaration(m, "width"), "default") + refused(t, m, `"width"`, "neither a default nor required") + m = inConfigForm() + declaration(m, "sender")["default"] = "noreply@example.org" + refused(t, m, `"sender"`, "both a default and required") +} + +// Rule 3: a default failing its validation. +func TestConfigRefusesADefaultFailingItsValidation(t *testing.T) { + for _, c := range []struct { + key string + value any + said string + }{ + {"font-size", 4, "below its minimum"}, + {"font-size", 41, "above its maximum"}, + {"font-size", "10", "not a number"}, + {"position", "bottom", "not one of"}, + {"theme", "Dark", "does not match"}, + } { + m := inConfigForm() + declaration(m, c.key)["default"] = c.value + refused(t, m, `"`+c.key+`"`, c.said) + } +} + +// Rule 4: an object or a list not trusted. +func TestConfigRefusesAnObjectOrListNotTrusted(t *testing.T) { + for _, kind := range []string{"object", "list"} { + m := inConfigForm() + def := any(map[string]any{"a": 1}) + if kind == "list" { + def = []any{"a"} + } + m["config"].(map[string]any)["rules"] = map[string]any{"type": kind, "description": "the rules, as " + kind, + "default": def, "runtime": true} + refused(t, m, `"rules"`, "is "+kind+" and not trusted") + m["config"].(map[string]any)["rules"].(map[string]any)["trusted"] = true + if _, err := parseRaw(t, m); err != nil { + t.Fatalf("a trusted %s is refused: %v", kind, err) + } + } +} + +// withPredefined is the list of predefined keys as package K2 will give it, for the length of a test. +func withPredefined(t *testing.T, keys map[string]PredefinedKey) { + t.Helper() + was := predefinedKeys + predefinedKeys = keys + t.Cleanup(func() { predefinedKeys = was }) +} + +// Rule 5: a predefined key not on the list, or of another type. Until the list is present (package K2), a value +// naming one is refused saying so. +func TestConfigRefusesAPredefinedKeyNotOnTheListOrOfAnotherType(t *testing.T) { + m := inConfigForm() + declaration(m, "font-size")["predefined"] = "font-size" + refused(t, m, `"font-size"`, "the list of predefined keys", "not yet present") + + withPredefined(t, map[string]PredefinedKey{"font-size": {Type: "number"}, "accent": {Type: "string"}}) + if _, err := parseRaw(t, m); err != nil { + t.Fatalf("a value falling back to a listed key of its type is refused: %v", err) + } + m = inConfigForm() + declaration(m, "font-size")["predefined"] = "font-scale" + refused(t, m, `"font-size"`, `"font-scale"`, "not on the list") + m = inConfigForm() + declaration(m, "font-size")["predefined"] = "accent" + refused(t, m, `"font-size"`, `"accent"`, "is a string") +} + +// Rule 6: an untrusted value falling back to a trusted predefined key. +func TestConfigRefusesAnUntrustedValueFallingBackToATrustedKey(t *testing.T) { + m := inConfigForm() + declaration(m, "theme")["predefined"] = "theme" + refused(t, m, `"theme"`, "not yet present") + + withPredefined(t, map[string]PredefinedKey{"theme": {Type: "string", Trusted: true}}) + refused(t, m, `"theme"`, "trusted", "is not") + declaration(m, "theme")["trusted"] = true + if _, err := parseRaw(t, m); err != nil { + t.Fatalf("a trusted value falling back to a trusted key is refused: %v", err) + } +} + +// Rule 7: a value neither placed in a template nor read at runtime. +func TestConfigRefusesAValueNeitherPlacedNorReadAtRuntime(t *testing.T) { + m := inConfigForm() + delete(declaration(m, "history"), "runtime") + refused(t, m, `"history"`, "${config:history}", `"runtime": true`) + // Taken by a mergeable file is placed. + m = inConfigForm() + if _, err := parseRaw(t, m); err != nil { + t.Fatal(err) + } +} + +// Rule 8: a placeholder naming an undeclared value. +func TestConfigRefusesAPlaceholderNamingAnUndeclaredValue(t *testing.T) { + m := inConfigForm() + resourceOf(m, "configuration")["content"] = "font = ${config:font-size}\nwidth = ${config:width}\n" + + "from = ${config:sender}\ncolour = ${config:colour}\n" + refused(t, m, "${config:colour}", "declares no such value") +} + +// Rule 9: a reload-on or restart-on naming a file the module does not declare. +func TestConfigRefusesAReloadOrRestartOnNamingAnUndeclaredFile(t *testing.T) { + m := inConfigForm() + resourceOf(m, "notifier")["reload-on"] = []any{"configuration", "dunstrc"} + refused(t, m, "reload-on", `"dunstrc"`, "declares no such") + m = inConfigForm() + resourceOf(m, "notifier")["restart-on"] = []any{"rule"} + refused(t, m, "restart-on", `"rule"`) + // Another module's file, and one the mesh makes for the module, are not the module's to declare. + m = inConfigForm() + resourceOf(m, "notifier")["restart-on"] = []any{"rules", "resolver.fact-zones", "secret-mail", "composed-jails"} + if _, err := parseRaw(t, m); err != nil { + t.Fatalf("refused: %v", err) + } +} + +// Rule 10: a moved module's mergeable file not naming its keys under takes-config. +func TestConfigRefusesAMergeableFileNotNamingItsKeys(t *testing.T) { + m := inConfigForm() + delete(resourceOf(m, "rules"), "takes-config") + // position and theme are then placed nowhere; say they are read at runtime so only the file's rule speaks. + declaration(m, "position")["runtime"] = true + declaration(m, "theme")["runtime"] = true + refused(t, m, "rules", "names no takes-config") +} + +// Rule 11: a mesh word, puid or pgid in takes-config. +func TestConfigRefusesAMeshWordOrAnAccountFactInTakesConfig(t *testing.T) { + for _, word := range []string{"ports", "expose", "reach", "endpoints", "places", "accesses", "networks", "puid", "pgid"} { + m := inConfigForm() + resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", word} + refused(t, m, `"`+word+`"`, "takes-config") + } +} + +// A module uses one form or the other, never both. +func TestAManifestMixingTheConfigAndSettingsFormsIsRefused(t *testing.T) { + m := inConfigForm() + resourceOf(m, "configuration")["content"] = "font = ${config:font-size}\nwidth = ${setting:width}\nfrom = ${config:sender}\n" + refused(t, m, "mixes", "${setting:", "config") + + m = inConfigForm() + delete(resourceOf(m, "rules"), "takes-config") + resourceOf(m, "rules")["takes-settings"] = []any{"position", "theme"} + refused(t, m, "mixes", "takes-settings") + + m = inConfigForm() + m["settings"] = map[string]any{"colour": map[string]any{"kind": "preference", "default": "red", "why": "red"}} + refused(t, m, "mixes", "settings") +} + +// The settings form keeps its own declaration; the config form's has no kind or why. +func TestEachFormKeepsItsOwnDeclaration(t *testing.T) { + m := inConfigForm() + declaration(m, "width")["why"] = "fits a title" + refused(t, m, `"width"`, "why") + + err := parsed(t, Manifest{Module: "notifier", + Settings: map[string]SettingDeclaration{"width": {Kind: KindPreference, Default: float64(250), + Why: "fits a title", Type: "number"}}, + Resources: []map[string]any{{"id": "c", "type": "file", "path": "/x", "content": "${setting:width}"}}}) + if err == nil || !strings.Contains(err.Error(), "under config") { + t.Fatalf("a typed value under settings: %v", err) + } +} + +// What the mesh holds is what was written: a module in the config form is written back in it, and read again +// the same. +func TestAConfigManifestIsWrittenBackInItsOwnForm(t *testing.T) { + m, err := parseRaw(t, inConfigForm()) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + for _, said := range []string{`"config":`, `${config:font-size}`, `"takes-config":`, `"required":true`} { + if !strings.Contains(string(raw), said) { + t.Fatalf("written back without %s: %s", said, raw) + } + } + for _, unsaid := range []string{`"settings":`, `${setting:`, `"takes-settings"`, `"kind"`, `"why"`, `"default":null`} { + if strings.Contains(string(raw), unsaid) { + t.Fatalf("written back with %s: %s", unsaid, raw) + } + } + again, err := ParseManifest(raw) + if err != nil { + t.Fatal(err) + } + twice, _ := json.Marshal(again) + if !bytes.Equal(raw, twice) { + t.Fatalf("read again it is written\n%s\nwhere it was\n%s", twice, raw) + } +} + +// A module in the settings form is written back byte for byte as before. +func TestASettingsManifestIsWrittenBackAsBefore(t *testing.T) { + d := SettingDeclaration{Kind: KindPreference, Default: float64(10), Why: "readable"} + raw, err := json.Marshal(d) + if err != nil { + t.Fatal(err) + } + if string(raw) != `{"kind":"preference","default":10,"why":"readable"}` { + t.Fatalf("written as %s", raw) + } +} + +// settingsDunst is the dunst module as it is written today; configDunst the same module rewritten from settings to +// config. +const settingsDunst = `{"module": "dunst", "version": "1", + "settings": { + "font-size": {"kind": "preference", "default": 10, "why": "10 points of Inter reads well on a screen of about 100 DPI"}, + "width": {"kind": "preference", "default": 250, "why": "250 pixels fits a title of about forty characters at 10 points"} + }, + "resources": [ + {"id": "package", "type": "package", "package": "dunst"}, + {"id": "configuration", "type": "file", "path": "/home/me/.config/dunst/dunstrc", "mode": "0644", + "content": "[global]\n font = Inter ${setting:font-size}\n width = ${setting:width}\n"}, + {"id": "rules", "type": "file", "path": "/home/me/.config/dunst/rules.json", "merge": "json", + "takes-settings": [], "content": "{\"urgency\": \"low\"}\n"}, + {"id": "notifier", "type": "service", "unit": "dunst.service", "reload-on": ["configuration"]} + ]}` + +const configDunst = `{"module": "dunst", "version": "1", + "config": { + "font-size": {"type": "number", "description": "the font size of a notification, in points", "minimum": 6, + "maximum": 40, "default": 10}, + "width": {"type": "number", "description": "the width of a notification, in pixels", "minimum": 100, + "default": 250} + }, + "resources": [ + {"id": "package", "type": "package", "package": "dunst"}, + {"id": "configuration", "type": "file", "path": "/home/me/.config/dunst/dunstrc", "mode": "0644", + "content": "[global]\n font = Inter ${config:font-size}\n width = ${config:width}\n"}, + {"id": "rules", "type": "file", "path": "/home/me/.config/dunst/rules.json", "merge": "json", + "takes-config": [], "content": "{\"urgency\": \"low\"}\n"}, + {"id": "notifier", "type": "service", "unit": "dunst.service", "reload-on": ["configuration"]} + ]}` + +// ${config:} fills exactly as ${setting:} does: the module rewritten from settings to config composes +// to the same bytes, with no layer, with the node's, and with the mesh's under the node's. +func TestAModuleRewrittenFromSettingsToConfigComposesByteIdentically(t *testing.T) { + composed := func(manifest string, layers []Layer) []byte { + t.Helper() + m, err := ParseManifest([]byte(manifest)) + if err != nil { + t.Fatal(err) + } + r := anAdoptedAnchor() + r.Modules = append(r.Modules, m) + with := anchorRendering(false) + if layers != nil { + with.Settings["dunst"] = layers + } + c, err := r.Compose(with) + if err != nil { + t.Fatal(err) + } + if why, left := c.LeftOut["dunst"]; left { + t.Fatalf("dunst was left out: %s", why) + } + raw, err := json.Marshal(c) + if err != nil { + t.Fatal(err) + } + return raw + } + for _, layers := range [][]Layer{ + nil, + {{From: "anchor", Values: map[string]any{"font-size": float64(13)}}}, + {{From: MeshWideLayer, Values: map[string]any{"width": float64(300)}}, + {From: "anchor", Values: map[string]any{"font-size": float64(12), "width": float64(320)}}}, + } { + was, is := composed(settingsDunst, layers), composed(configDunst, layers) + if !bytes.Equal(was, is) { + t.Fatalf("composed differently with %v:\nsettings: %s\nconfig: %s", layers, was, is) + } + if !strings.Contains(string(is), "font = Inter 1") || strings.Contains(string(is), "${config:") { + t.Fatalf("not filled: %s", is) + } + } +} + +// A key a mergeable file takes is a config value like every other: one not declared under config is refused by +// name (ADR 0348 §4, review of mesh-controller#320). +func TestConfigRefusesATakesConfigKeyNotDeclared(t *testing.T) { + m := inConfigForm() + resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", "urgency"} + refused(t, m, `"urgency"`, "takes-config", "declares no such value under config") +} + +// A required value nothing sets is refused at composition in the config form's words: ${config:} and the +// config verb, never `settings set` (review of mesh-controller#320). The settings form's words are unchanged. +func TestAnUnsetRequiredValueIsRefusedInTheConfigFormsWords(t *testing.T) { + m, err := parseRaw(t, inConfigForm()) + if err != nil { + t.Fatal(err) + } + r := anAdoptedAnchor() + r.Modules = append(r.Modules, m) + composed, err := r.Compose(anchorRendering(false)) + if err != nil { + t.Fatal(err) + } + why, left := composed.LeftOut["notifier"] + if !left { + t.Fatal("a module whose required value nothing sets was not left out") + } + for _, said := range []string{"${config:sender}", `required config value "sender"`, "config verb"} { + if !strings.Contains(why, said) { + t.Fatalf("left out without %q: %s", said, why) + } + } + for _, unsaid := range []string{"${setting:", "settings set"} { + if strings.Contains(why, unsaid) { + t.Fatalf("left out in the settings form's words (%q): %s", unsaid, why) + } + } + var unset *UnsetSettingError + if err := unsetInConfigWords(settingInto(map[string]any{"type": "file", "content": "${setting:sender}"}, nil, + "notifier"), m); !errors.As(err, &unset) || unset.Setting != "sender" { + t.Fatalf("not typed as a value nobody gave: %v", err) + } + + settingsForm := notifier() + settingsForm.Resources[0]["content"] = "from = ${setting:domain}\n" + err = unsetInConfigWords(settingInto(settingsForm.Resources[0], nil, "notifier"), settingsForm) + if err == nil || !strings.Contains(err.Error(), "`settings set notifier `") { + t.Fatalf("the settings form's refusal changed: %v", err) + } +} diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index 761a5515..25e39e27 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -1003,7 +1003,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, WithDefaults(m, with.Settings[m.Module]), m.Module); err != nil { + if err := unsetInConfigWords(settingInto(copied, WithDefaults(m, with.Settings[m.Module]), m.Module), m); err != nil { return nil, err } // **Placed before anything reads a path.** A pathless directory receives the path diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 39dc1ba2..49866bdd 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -530,6 +530,11 @@ 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"` + // forms is which of the two forms its config is written in, `config` or its alias `settings` (config.go, + // novox/hq ADR 0348 §1): the config form is read into Settings, a mergeable file's takes-settings and + // `${setting:}`, and written back as it was read. + forms configForms + // 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 @@ -1321,10 +1326,25 @@ type manifestFields Manifest // strict read there failed the whole catalogue, and with it every plan and every send, the moment a // controller was rolled back. UnknownField says what was set aside. func (m *Manifest) UnmarshalJSON(raw []byte) error { + // The config form read into the settings form's fields, so both compose by one path (config.go). + raw, forms := toSettingsForm(raw) var keys map[string]json.RawMessage if err := json.Unmarshal(raw, &keys); err != nil { return err } + if settings, has := keys[SettingsField]; has && string(settings) != "null" { + forms.settings = append(forms.settings, SettingsField) + } + if config, has := keys[ConfigField]; has { + if string(config) != "null" { + forms.config = append(forms.config, ConfigField) + } + if _, both := keys[SettingsField]; !both { + keys[SettingsField] = config + } + // Beside `settings` it is refused by name (MixedFormProblems), never merged into it. + delete(keys, ConfigField) + } plain := map[string]string{} many := map[string]map[string]string{} if secrets, ok := keys["secrets"]; ok && string(secrets) != "null" { @@ -1453,6 +1473,7 @@ func (m *Manifest) UnmarshalJSON(raw []byte) error { } *m = Manifest(fields) m.unknown = unknown + m.forms = forms m.OptionalRequires = optionalRequires if len(plain) > 0 { m.Secrets = plain @@ -1470,8 +1491,16 @@ func (m *Manifest) UnmarshalJSON(raw []byte) error { } // MarshalJSON writes `secrets` and `contributes` back in the shape they were read: single values, -// and objects of local names. +// and objects of local names; and its config in the form it was read in (config.go). func (m Manifest) MarshalJSON() ([]byte, error) { + raw, err := m.marshalFields() + if err != nil || !m.DeclaresConfig() { + return raw, err + } + return toConfigForm(raw) +} + +func (m Manifest) marshalFields() ([]byte, error) { raw, err := json.Marshal(manifestFields(m)) if err != nil { return nil, err @@ -1783,8 +1812,14 @@ func ParseManifest(raw []byte) (Manifest, error) { problems = append(problems, StateProblems(m)...) // And what it may call its logs, and how large it may ask them to grow (logs.go, novox/hq ADR 0297). problems = append(problems, LogProblems(m)...) - // And the defaults it gives its settings (setting_defaults.go, novox/hq ADR 0262). - problems = append(problems, SettingProblems(m)...) + // And the defaults it gives its settings (setting_defaults.go, novox/hq ADR 0262), or, written in the + // config form, the whole declaration of each value (config.go, novox/hq ADR 0348). + if m.DeclaresConfig() { + problems = append(problems, ConfigProblems(m)...) + } else { + problems = append(problems, SettingProblems(m)...) + problems = append(problems, TypedSettingProblems(m)...) + } // And what it requires optionally, and the files it delivers only while bound (novox/hq ADR 0330). problems = append(problems, OptionalProblems(m)...) // And which setting keys each mergeable file takes (setting_takes.go, novox/hq ADR 0326). @@ -2341,6 +2376,14 @@ func ParseManifest(raw []byte) (Manifest, error) { problems = append(problems, healthProblems(m)...) // And whether a container is busy (novox/hq ADR 0335), likewise. problems = append(problems, busyProblems(m)...) + if m.DeclaresConfig() { + // Said in the words it was written in: the rules shared with the settings form say ${setting:} + // and takes-settings. + for i, p := range problems { + problems[i] = inConfigWords(p) + } + problems = append(problems, MixedFormProblems(m)...) + } if len(problems) > 0 { sort.Strings(problems) return Manifest{}, fmt.Errorf("this manifest cannot be used:\n - %s", diff --git a/internal/catalogue/setting_defaults.go b/internal/catalogue/setting_defaults.go index 3f158e02..353b4063 100644 --- a/internal/catalogue/setting_defaults.go +++ b/internal/catalogue/setting_defaults.go @@ -31,6 +31,28 @@ type SettingDeclaration struct { Default any `json:"default"` // Why this default: one sentence for whoever wonders whether to change it. Why string `json:"why"` + + // The config form's declaration (novox/hq ADR 0348 §4, config.go): none of these is said under + // `settings`, and neither Kind nor Why under `config`. + + // Type is number, string or boolean; object or list only when Trusted. + Type string `json:"type,omitempty"` + // Description says what the value is, including its unit. + Description string `json:"description,omitempty"` + // Enum, Minimum, Maximum and Pattern are its validation: the values it may take, the bounds of a + // number, and the whole of a string matching the pattern. + Enum []any `json:"enum,omitempty"` + Minimum *float64 `json:"minimum,omitempty"` + Maximum *float64 `json:"maximum,omitempty"` + Pattern string `json:"pattern,omitempty"` + // Trusted is a value that decides what runs or who can reach what (ADR 0348 §8). + Trusted bool `json:"trusted,omitempty"` + // Predefined is the predefined key the value falls back to, of the same type (ADR 0348 §2, §7). + Predefined string `json:"predefined,omitempty"` + // Required is the operator's own value: it has no default (ADR 0348 §4). + Required bool `json:"required,omitempty"` + // Runtime is a value the module's code reads at runtime rather than one placed in a file (ADR 0348 §5). + Runtime bool `json:"runtime,omitempty"` } // KindPreference is the kind of a setting that may have a default. @@ -203,6 +225,10 @@ type SettingSource struct { func Effective(m Manifest, layers []Layer) []SettingSource { byKey := map[string]*SettingSource{} for key, d := range m.Settings { + if d.Default == nil { + // A required config value has no default (ADR 0348 §4); a settings declaration always has one. + continue + } byKey[key] = &SettingSource{Key: key, Value: d.Default, From: DefaultLayer, FromDefault: true, Default: d.Default, HasDefault: true} } diff --git a/internal/catalogue/setting_into.go b/internal/catalogue/setting_into.go index ee04ee18..1ea94437 100644 --- a/internal/catalogue/setting_into.go +++ b/internal/catalogue/setting_into.go @@ -1,6 +1,7 @@ package catalogue import ( + "errors" "fmt" "regexp" "sort" @@ -47,10 +48,30 @@ func settingsUsed(content string) []string { type UnsetSettingError struct { Module, Setting string said string + // where says what asks for it — "a file", "a service whose unit", "a directory whose owner" — and + // setToday which of the module's keys a layer sets, for saying it again in the config form's words. + where, setToday string } func (e *UnsetSettingError) Error() string { return e.said } +// unsetInConfigWords is the refusal of a required config value nothing sets, in the words of a module written in the +// config form (novox/hq ADR 0348 §1, §4): `${config:}` and the config verb, never `settings set`. Any other +// error, and any error of a module in the settings form, is returned as it was. +func unsetInConfigWords(err error, m Manifest) error { + var unset *UnsetSettingError + if err == nil || !m.DeclaresConfig() || !errors.As(err, &unset) { + return err + } + return &UnsetSettingError{Module: unset.Module, Setting: unset.Setting, where: unset.where, + setToday: unset.setToday, said: fmt.Sprintf( + "%s has %s that says ${config:%s}, and nothing sets the required config value %q for it — a "+ + "required value is the operator's own and has no default (novox/hq ADR 0348 §4): set it with "+ + "the config verb, for %s on this node, key %q%s", + unset.Module, unset.where, unset.Setting, unset.Setting, unset.Module, unset.Setting, + strings.ReplaceAll(unset.setToday, "no setting is set", "no config value is set"))} +} + // 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 over the module's own @@ -74,12 +95,13 @@ func settingInto(resource map[string]any, layers []Layer, module string) error { for _, key := range settingsUsed(content) { value, set := settingValue(layers, key) if !set { - return &UnsetSettingError{Module: module, Setting: key, said: fmt.Sprintf( - "%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), 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))} + return &UnsetSettingError{Module: module, Setting: key, where: "a file", + setToday: orNoSettings(layers), said: fmt.Sprintf( + "%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), 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))} } content = strings.ReplaceAll(content, "${setting:"+key+"}", plainly(value)) } @@ -130,11 +152,12 @@ func settingIntoUnit(resource map[string]any, layers []Layer, module string) err for _, key := range settingsUsed(unit) { value, set := settingValue(layers, key) if !set { - return &UnsetSettingError{Module: module, Setting: key, said: fmt.Sprintf( - "%s has a service whose unit 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): "+ - "`settings set %s ` with {%q: …}%s", - module, key, key, module, key, orNoSettings(layers))} + return &UnsetSettingError{Module: module, Setting: key, where: "a service whose unit", + setToday: orNoSettings(layers), said: fmt.Sprintf( + "%s has a service whose unit 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): "+ + "`settings set %s ` with {%q: …}%s", + module, key, key, module, key, orNoSettings(layers))} } v := plainly(value) if !unitPart.MatchString(v) { @@ -229,11 +252,13 @@ func settingIntoOwner(resource map[string]any, layers []Layer, module string) er for _, key := range settingsUsed(owner) { value, set := settingValue(layers, key) if !set { - return &UnsetSettingError{Module: module, Setting: key, said: fmt.Sprintf( - "%s has a directory %v whose owner 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): "+ - "`settings set %s ` with {%q: …}%s", - module, resource["id"], key, key, module, key, orNoSettings(layers))} + return &UnsetSettingError{Module: module, Setting: key, + where: fmt.Sprintf("a directory %v whose owner", resource["id"]), setToday: orNoSettings(layers), + said: fmt.Sprintf( + "%s has a directory %v whose owner 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): "+ + "`settings set %s ` with {%q: …}%s", + module, resource["id"], key, key, module, key, orNoSettings(layers))} } v := plainly(value) id, whole := wholeID(v) diff --git a/internal/catalogue/setting_takes.go b/internal/catalogue/setting_takes.go index 0cb7fca4..d723de87 100644 --- a/internal/catalogue/setting_takes.go +++ b/internal/catalogue/setting_takes.go @@ -83,7 +83,8 @@ 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(m.Settings) == 0 || m.DeclaresConfig() { + // A module in the config form is held to this where it is read, in its own words (ConfigProblems). return nil } var silent []string diff --git a/internal/catalogue/settings.go b/internal/catalogue/settings.go index 28177413..17572a38 100644 --- a/internal/catalogue/settings.go +++ b/internal/catalogue/settings.go @@ -597,7 +597,7 @@ func JudgeSettings(m Manifest, layers []Layer, adopted bool) error { for k, v := range settled { copied[k] = v } - if err := settingInto(copied, WithDefaults(m, layers), m.Module); err != nil { + if err := unsetInConfigWords(settingInto(copied, WithDefaults(m, layers), m.Module), m); err != nil { return err } }