Files
mesh-controller/internal/catalogue/setting_takes.go
T
mesh-tools 97a2d841c7 Land feature 57's controller work on its release branch
The controller plans config, templates and moved providers' provisions,
hands bus accounts to the vault and judges the new manifest words, on the
feature's SDK and node-engine validator (feature 57, config and provisioning).
2026-10-15 21:27:33 +00:00

338 lines
13 KiB
Go

package catalogue
import (
"fmt"
"strings"
"unicode"
)
// A mergeable file names the setting keys it takes (novox/hq ADR 0326, issue 168).
//
// Before this, every key of a module's layers merged into every one of its mergeable files: the
// mesh's own words, a key meant for a `${setting:<key>}` in another file, a key for a second
// configuration file. So a module could not own two configurable files, and every receiver had to
// ignore what it did not know. ADR 0164 §11 decided that a setting names the files that read it; this
// is that relation, stated from the file's side, so the operator's own values stay declared nowhere
// (ADR 0262 §2).
// TakesSettingsField is the key a mergeable file carries to name the top-level setting keys it takes.
const TakesSettingsField = "takes-settings"
// **A taken value may be laid under the file's own name** (issue 751). An entry of takes-config is a key, laid
// under that key, or {"key": <the config key>, "as": <the file's name for it>}: the key stays a config key,
// lower-case, and the file is written in its software's spelling, `atlassian-url` as ATLASSIAN_URL. Like every
// taken value it is laid only when a layer sets it, never from a default, so an unset one leaves the file's bytes
// and the code's own fallback (its environment) as they were; for that reason it is never `required`
// (ConfigProblems). The settings form has no such entry: it is the config form's alone.
// Fields of a renamed takes-config entry.
const (
takenKeyField = "key"
takenAsField = "as"
)
// takenEntry is one value a mergeable file takes: its key and the name the file has for it.
type takenEntry struct {
key, as string
renamed bool
}
// takenEntries is the entries of a takes-settings list in their order, a usable entry only: a string is a key
// under its own name, an object with exactly key and as a key under another (TakenSettingsProblems says the rest).
func takenEntries(raw any) []takenEntry {
list, _ := raw.([]any)
var out []takenEntry
for _, item := range list {
if e, ok := asTakenEntry(item); ok {
out = append(out, e)
}
}
return out
}
// asTakenEntry reads one item of a takes-settings list, and whether it is shaped as an entry at all.
func asTakenEntry(item any) (takenEntry, bool) {
switch v := item.(type) {
case string:
return takenEntry{key: v, as: v}, true
case map[string]any:
key, keyOK := v[takenKeyField].(string)
as, asOK := v[takenAsField].(string)
if !keyOK || !asOK || len(v) != 2 {
return takenEntry{}, false
}
return takenEntry{key: key, as: as, renamed: true}, true
}
return takenEntry{}, false
}
// takenKeyList is the keys a resource takes, in order: the config keys, never the file's names for them.
func takenKeyList(resource map[string]any) []string {
var keys []string
for _, e := range takenEntries(resource[TakesSettingsField]) {
keys = append(keys, e.key)
}
return keys
}
// takenKeys is the keys a resource names in takes-settings, each with the name the file has for it, and whether
// it names any list at all. An empty list is a list: that file takes no setting.
func takenKeys(resource map[string]any) (map[string]string, bool) {
raw, said := resource[TakesSettingsField]
if !said {
return nil, false
}
keys := map[string]string{}
for _, e := range takenEntries(raw) {
keys[e.key] = e.as
}
return keys, true
}
// onlyTaken is the layers with every key the file does not name left out, and each key it takes under the
// file's name for it. The file's own defaults and what the mesh reads itself are not touched here: settle
// already keeps both out of a file.
//
// **A layer said before its module moved still reaches the file** (issue 751): such a layer names a renamed value
// by the file's own spelling (nodered's timeZone on one machine), and until K5 moves the layer and renames the key
// that spelling is taken as the value's, so moving the module changes no machine's file. Where one layer says
// both, the config key wins.
func onlyTaken(layers []Layer, taken map[string]string) []Layer {
oldSpelling := map[string]string{}
for key, as := range taken {
if as != key {
oldSpelling[as] = key
}
}
kept := make([]Layer, 0, len(layers))
for _, layer := range layers {
values := map[string]any{}
for key, value := range layer.Values {
if as, takes := taken[key]; takes {
values[as] = value
continue
}
if newKey, old := oldSpelling[key]; old {
if _, said := layer.Values[newKey]; !said {
values[key] = value
}
}
}
kept = append(kept, Layer{From: layer.From, Values: values, Default: layer.Default})
}
return kept
}
// takenNames is every key a layer may say for the values a resource takes: each config key and, for a renamed
// one, the file's own spelling a layer said before its module moved (onlyTaken).
func takenNames(resource map[string]any) []string {
var names []string
for _, e := range takenEntries(resource[TakesSettingsField]) {
names = append(names, e.key)
if e.renamed {
names = append(names, e.as)
}
}
return names
}
// namesItsKeys says whether a module's mergeable files name the keys they take (ADR 0326 §2): true
// only when it has one and every one carries takes-settings. A module with a silent mergeable file
// keeps today's form (§3), where every key reaches every mergeable file.
func namesItsKeys(m Manifest) bool {
merging := 0
for _, r := range m.Resources {
if how, _ := r["merge"].(string); how == "" {
continue
}
merging++
if _, said := r[TakesSettingsField]; !said {
return false
}
}
return merging > 0
}
// TakenSettingsProblems is every way a definition's takes-settings is wrong (ADR 0326 §2 and §4), in
// its own words: on a file that does not merge, not a list of keys, a key named twice, one the mesh
// reads itself, one the file keeps for itself, or one mergeable file naming its keys beside another
// that names none.
func TakenSettingsProblems(m Manifest) []string {
return takenSettingsProblems(m)
}
// WrittenSettingsProblems is ADR 0326 §3, judged only where a definition is written — the module
// check, `module add`, a build taken in, the builder and the merge gate's registration — and never
// where a stored one is read again (ParseManifest, a build's manifest at a commit, a module put back):
// a module that declares a setting names the keys of every mergeable file it owns (novox/hq issue
// 574, ADR 0164 §11 and §15). A build stored before the rule, such as betterbird's before its accounts
// file named its keys, must still compose and still be put back; only its next change is held to it.
//
// 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 || 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
for _, r := range m.Resources {
how, _ := r["merge"].(string)
if how == "" {
continue
}
if _, said := r[TakesSettingsField]; said {
return nil
}
silent = append(silent, fmt.Sprint(r["id"]))
}
var problems []string
for _, id := range silent {
problems = append(problems, fmt.Sprintf("%s declares settings, and its mergeable %s names no %s: "+
"a module that declares a setting names the keys each mergeable file takes, an empty list "+
"for none (novox/hq ADR 0326 §3, ADR 0164 §11)", m.Module, id, TakesSettingsField))
}
return problems
}
// takenSettingsProblems is every rule of ADR 0326 §2 and §4, judged where a definition is checked and
// where a stored one is composed.
func takenSettingsProblems(m Manifest) []string {
var problems []string
var naming, silent []string
for _, r := range m.Resources {
id := fmt.Sprint(r["id"])
how, _ := r["merge"].(string)
raw, said := r[TakesSettingsField]
if how == "" {
if said {
problems = append(problems, fmt.Sprintf("%s's %s says %s and does not merge: only a mergeable "+
"file takes settings by key (novox/hq ADR 0326)", m.Module, id, TakesSettingsField))
}
continue
}
if !said {
silent = append(silent, id)
continue
}
naming = append(naming, id)
list, isList := raw.([]any)
if !isList {
problems = append(problems, fmt.Sprintf("%s's %s: %s is a list of setting keys, and this is %T",
m.Module, id, TakesSettingsField, raw))
continue
}
protected := map[string]bool{}
for _, k := range stringsOf(r["protected"]) {
protected[k] = true
}
seen, landed := map[string]bool{}, map[string]bool{}
for _, item := range list {
if _, isObject := item.(map[string]any); isObject {
e, shaped := asTakenEntry(item)
switch {
case !m.DeclaresConfig():
problems = append(problems, fmt.Sprintf("%s's %s: %v in %s lays a value under another name, "+
"which only the config form says: write the key alone (novox/hq issue 751)",
m.Module, id, item, TakesSettingsField))
continue
case !shaped:
problems = append(problems, fmt.Sprintf("%s's %s: %v in %s is a key alone, or an object "+
"saying exactly %q and %q: the config key and the file's name for it (novox/hq issue 751)",
m.Module, id, item, TakesSettingsField, takenKeyField, takenAsField))
continue
case !settingRef.MatchString("${setting:" + e.key + "}"):
problems = append(problems, fmt.Sprintf("%s's %s: %q in %s is not a usable key: a config "+
"key is lower-case letters, digits, dots, dashes and underscores; the file's own spelling "+
"is its %q", m.Module, id, e.key, TakesSettingsField, takenAsField))
continue
case !usableKey(e.as):
problems = append(problems, fmt.Sprintf("%s's %s: %q, the name %s takes %q as, is not a "+
"usable name in a file", m.Module, id, e.as, TakesSettingsField, e.key))
continue
}
problems = append(problems, takenEntryProblems(m.Module, id, e, seen, landed, protected)...)
continue
}
key, isString := item.(string)
if !isString || !usableKey(key) {
problems = append(problems, fmt.Sprintf("%s's %s: %v in %s is not a usable setting key",
m.Module, id, item, TakesSettingsField))
continue
}
problems = append(problems, takenEntryProblems(m.Module, id, takenEntry{key: key, as: key}, seen,
landed, protected)...)
}
}
if len(naming) > 0 {
for _, id := range silent {
problems = append(problems, fmt.Sprintf("%s's %s merges and names no %s while %s does: once one "+
"mergeable file of a module names the keys it takes, every one does, an empty list for none "+
"(novox/hq ADR 0326)", m.Module, id, TakesSettingsField, strings.Join(naming, ", ")))
}
}
return problems
}
// takenEntryProblems is what is wrong with one usable entry of a file's takes-settings, against those before it:
// a key taken twice, or two values laid under one name in the file; a word the mesh reads itself, as the key or as
// the file's name; a name the file keeps as protected. seen and landed are the keys and the names so far, added to.
func takenEntryProblems(module, id string, e takenEntry, seen, landed, protected map[string]bool) []string {
var problems []string
switch {
case seen[e.key]:
problems = append(problems, fmt.Sprintf("%s's %s names %q twice in %s", module, id, e.key, TakesSettingsField))
case landed[e.as]:
problems = append(problems, fmt.Sprintf("%s's %s names %q twice in %s, as a key or as the name a key is "+
"laid under: one name in a file holds one value", module, id, e.as, TakesSettingsField))
case meshWords[e.key] || meshWords[e.as]:
word := e.key
if !meshWords[word] {
word = e.as
}
problems = append(problems, fmt.Sprintf("%s's %s names %q in %s, and the mesh reads %q itself "+
"(novox/hq ADR 0164 §14)", module, id, word, TakesSettingsField, word))
case protected[e.as]:
problems = append(problems, fmt.Sprintf("%s's %s names %q in %s and keeps it as protected: a "+
"key is the file's or the setting's, never both", module, id, e.as, TakesSettingsField))
}
seen[e.key], landed[e.as] = true, true
return problems
}
// usableKey is a key a file can be said to take: not empty, and no space, line end or control
// character. Not the lower-case rule of a `${setting:<key>}`: a file's own keys are its software's
// spelling, such as `timeZone`.
func usableKey(key string) bool {
if key == "" {
return false
}
for _, r := range key {
if unicode.IsSpace(r) || unicode.IsControl(r) || !unicode.IsPrint(r) {
return false
}
}
return true
}
// takenSaid is what a stray key's message adds for a module whose files name their keys: which keys
// each file takes, so the person sees where the key would have had to be named.
func takenSaid(m Manifest) string {
if !namesItsKeys(m) {
return ""
}
var files []string
for _, r := range m.Resources {
if how, _ := r["merge"].(string); how == "" {
continue
}
keys := takenKeyList(r)
if len(keys) == 0 {
files = append(files, fmt.Sprintf("%v takes none", r["id"]))
continue
}
files = append(files, fmt.Sprintf("%v takes %s", r["id"], strings.Join(keys, ", ")))
}
return " (" + strings.Join(files, "; ") + ")"
}