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 the mesh published, for the length of a test. func withPredefined(t *testing.T, keys PredefinedList) { t.Helper() was := ReplacePublished(keys) t.Cleanup(func() { ReplacePublished(was) }) } // Rule 5: a predefined key not on the list, or of another type. Where no list is present, a value naming one is // refused saying so. func TestConfigRefusesAPredefinedKeyNotOnTheListOrOfAnotherType(t *testing.T) { withPredefined(t, nil) m := inConfigForm() declaration(m, "font-size")["predefined"] = "font-size" refused(t, m, `"font-size"`, "the list of predefined keys", "is not 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) { withPredefined(t, nil) m := inConfigForm() declaration(m, "theme")["predefined"] = "theme" refused(t, m, `"theme"`, "is not 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) } }