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} // 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 := m.predefined[d.Predefined] switch { case m.predefined == nil: say("falls back to the predefined key %q, and the list of predefined keys (%s at the root of "+ "the catalogue's repository) is not present where it is judged: a value falls back only to a "+ "key on that list; judge it in the catalogue, or leave \"predefined\" out (novox/hq ADR 0348 §7)", d.Predefined, PredefinedFile) 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 }