diff --git a/modules/mesh-delivery-indexer/judge/rules.go b/modules/mesh-delivery-indexer/judge/rules.go index 12cf6863..b79f12c5 100644 --- a/modules/mesh-delivery-indexer/judge/rules.go +++ b/modules/mesh-delivery-indexer/judge/rules.go @@ -872,6 +872,9 @@ func configPlaced(m catalogue.Manifest) map[string]bool { placed[k] = true } } + for _, u := range configLinesUses(m) { + placed[u.key] = true + } return placed } @@ -2835,6 +2838,7 @@ func parseManifest(raw []byte, predefined catalogue.PredefinedList) (catalogue.M } // And the config values its resources are declared under (design 42 zfs-owns-zfs §2). problems = append(problems, IfConfigProblems(m)...) + problems = append(problems, ConfigLinesProblems(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). @@ -7306,6 +7310,10 @@ var fillers = []filler{ {namespace: "port", patterns: []*regexp.Regexp{ofPort}, fields: map[string][]string{"file": {"content"}, "container": {"env"}, "process": {"env"}}}, {namespace: "seat", patterns: []*regexp.Regexp{ofSeat, ofSeatReach}, fields: map[string][]string{"file": {"content"}, "container": {"env"}, "process": {"env"}}}, {namespace: "machine", patterns: []*regexp.Regexp{ofMachine}, fields: map[string][]string{"*": {"path", "owner", "content", "name", "user", "root", "home"}}}, + // The module's own config values written into a file (design 42 zfs-owns-zfs §3, §9), filled by the node-engine + // and judged by ConfigLinesProblems. + {namespace: "config-lines", patterns: []*regexp.Regexp{ofConfigLines}, fields: map[string][]string{"file": {"content"}}}, + {namespace: "config-json", patterns: []*regexp.Regexp{ofConfigJSON}, fields: map[string][]string{"file": {"content"}}}, // Placed in a file's content by their holders, and judged there by their own rules // (placeholderProblems, seatPlaceholderProblems). {namespace: "environment", patterns: []*regexp.Regexp{ofEnvironment}, fields: map[string][]string{"*": {"content"}}}, @@ -7348,6 +7356,74 @@ func whereFilled(f filler) string { var ofKnownNamespace = regexp.MustCompile(`(?im)\$\{(?:` + strings.Join(namespacesOf(fillers), "|") + `):(?:[^-=+?}\n][^}\n]*\}?|\}|$)`) +// ofConfigLines is a `${config-lines::}` or `${config-lines:::mib-bytes}` whose prefix is +// literal text with no `}`, `$`, newline or `:` (design 42 zfs-owns-zfs §3, answers A-4, A-5). +var ofConfigLines = regexp.MustCompile(`\$\{config-lines:[a-z0-9][a-z0-9_.-]*:[^}$\n:]*(?::mib-bytes)?\}`) + +// ofConfigJSON is the module's config values as one JSON object (design 42 zfs-owns-zfs §9, answer A-15). +var ofConfigJSON = regexp.MustCompile(`\$\{config-json\}`) + +var configLinesToken = regexp.MustCompile(`\$\{config-lines:([^}]*)\}`) + +// configLinesUse is one `${config-lines:…}` of a file's content, split into its parts. +type configLinesUse struct { + resource any + key string + prefix string + extra []string // the parts after the prefix + parts int +} + +// configLinesUses is every `${config-lines:…}` in the content of the module's files, to the first `}`. +func configLinesUses(m catalogue.Manifest) []configLinesUse { + var out []configLinesUse + for _, r := range m.Resources { + content, _ := r["content"].(string) + if fmt.Sprint(r["type"]) != "file" { + continue + } + for _, t := range configLinesToken.FindAllStringSubmatch(content, -1) { + parts := strings.Split(t[1], ":") + u := configLinesUse{resource: r["id"], key: parts[0], parts: len(parts)} + if len(parts) > 1 { + u.prefix = parts[1] + u.extra = parts[2:] + } + out = append(out, u) + } + } + return out +} + +// ConfigLinesProblems are the `${config-lines::[:mib-bytes]}` a file says wrongly: a prefix holding `$`, a +// newline or `:`, a fourth part other than `mib-bytes`, a key the module does not declare, and `mib-bytes` on a value +// that is not a number (design 42 zfs-owns-zfs §3; answers A-4, A-5). A `}` ends the token where it stands. +func ConfigLinesProblems(m catalogue.Manifest) []string { + var out []string + for _, u := range configLinesUses(m) { + say := func(format string, args ...any) { + out = append(out, fmt.Sprintf("%s's resource %v says ${config-lines:%s…}: ", m.Module, u.resource, u.key)+ + fmt.Sprintf(format, args...)+" (design 42 zfs-owns-zfs §3)") + } + d, declared := m.Settings[u.key] + if !declared { + say("the module declares no value %q under %s", u.key, catalogue.ConfigField) + } + switch { + case u.parts < 2: + say("it has no prefix: ${config-lines::}") + continue + case strings.ContainsAny(u.prefix, "$\n"): + say("the prefix is literal text with no }, $ or newline") + case len(u.extra) > 1 || len(u.extra) == 1 && u.extra[0] != "mib-bytes": + say("the prefix holds no ':', and the only fourth part is mib-bytes") + case len(u.extra) == 1 && declared && d.Type != catalogue.ConfigNumber: + say("mib-bytes is for a number, and %q is a %s", u.key, d.Type) + } + } + return out +} + var namespaceShaped = regexp.MustCompile(`\$\{[a-z][a-z0-9_-]*:[A-Za-z0-9][^\s{}$]*\}`) func namespacesOf(fs []filler) []string { diff --git a/modules/mesh-delivery-indexer/judge/rules_test.go b/modules/mesh-delivery-indexer/judge/rules_test.go index da7661c9..e920e46e 100644 --- a/modules/mesh-delivery-indexer/judge/rules_test.go +++ b/modules/mesh-delivery-indexer/judge/rules_test.go @@ -3563,6 +3563,37 @@ func caseIfConfigNamesADeclaredValueAndPlacesIt(t *testing.T) { } } +// Design 42 zfs-owns-zfs §3, V2/AC1 to AC3 (A-4, A-5, A-24): `${config-lines::[:mib-bytes]}`. +func caseConfigLinesNamesADeclaredValueAndAPlainPrefix(t *testing.T) { + withLines := func(content string) map[string]any { + m := inConfigForm() + m["config"].(map[string]any)["opts"] = map[string]any{"type": "list", "trusted": true, "description": "options, one per line", "default": []any{}} + m["resources"] = append(m["resources"].([]any), map[string]any{"id": "options", "type": "file", "path": "/x/o.conf", "content": content}) + return m + } + // A prefix holding $, a newline or ':' is refused, naming the key; so is a fourth part but mib-bytes. + for _, c := range []string{"${config-lines:opts:a$b}", "${config-lines:opts:a\nb}", "${config-lines:opts:a:b}", + "${config-lines:opts:a:mib-bytes:x}", "${config-lines:opts}"} { + refused(t, withLines(c), "${config-lines:opts", "design 42 zfs-owns-zfs §3") + } + // Accepted, and the key is placed (opts is placed by nothing else). + for _, c := range []string{"${config-lines:opts:options zfs x=}", "${config-lines:opts:}"} { + if _, err := parseRaw(t, withLines(c)); err != nil { + t.Fatalf("%s: %v", c, err) + } + } + if _, err := parseRaw(t, withLines("# none\n")); err == nil || !strings.Contains(err.Error(), "placed nowhere") { + t.Fatalf("a list value placed nowhere was accepted: %v", err) + } + // mib-bytes is for a number; a key not declared is refused. + m := withLines("${config-lines:opts:o=}\n${config-lines:width:w=:mib-bytes}") + if _, err := parseRaw(t, m); err != nil { + t.Fatal(err) + } + refused(t, withLines("${config-lines:opts:o=:mib-bytes}"), "mib-bytes is for a number", `"opts" is a list`) + refused(t, withLines("${config-lines:nowhere:o=}"), `no value "nowhere"`) +} + // Consumer data said to live in a provision the module requires is protected only as well as that // provision's provider protects its consumers' data. func caseConsumerDataInARequiredProvisionIsJudgedAgainstItsProvider(t *testing.T) { @@ -6158,6 +6189,7 @@ func TestRulesConfig(t *testing.T) { t.Run("ConfigRefusesAValueWithoutTypeOrDescription", caseConfigRefusesAValueWithoutTypeOrDescription) t.Run("ConfigStillRefusesAValueNoItemPlaces", caseConfigStillRefusesAValueNoItemPlaces) t.Run("IfConfigNamesADeclaredValueAndPlacesIt", caseIfConfigNamesADeclaredValueAndPlacesIt) + t.Run("ConfigLinesNamesADeclaredValueAndAPlainPrefix", caseConfigLinesNamesADeclaredValueAndAPlainPrefix) } // TestRulesClaims runs the tests of one kind as subtests, each by its former name.