Mesh and node config may hold values only for keys on a fixed, reviewed list (hq ADR 0348 clause 7, design 57 package K2, issue 661). A build reads predefined.json at the root of the repository at the commit it builds and carries it with the commit's generation; module check and the merge gate read it from the tree. (Part 1 of 2: the changes to existing files.) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
455 lines
18 KiB
Go
455 lines
18 KiB
Go
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:<key>} fills exactly as ${setting:<key>} 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:<key>} 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 <file>`") {
|
|
t.Fatalf("the settings form's refusal changed: %v", err)
|
|
}
|
|
}
|