V2 judge-config-lines: the form ${config-lines:<key>:<prefix>[:mib-bytes]} is judged and places its key

Builds: design 42 zfs-owns-zfs §3; answers A-4, A-5, A-24
This commit is contained in:
2026-11-13 01:27:58 +01:00
parent 27c3b9a860
commit ff76ee95ad
2 changed files with 108 additions and 0 deletions
@@ -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:<key>:<prefix>}` or `${config-lines:<key>:<prefix>: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:<key>:<prefix>[: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:<key>:<prefix>}")
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 {
@@ -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:<key>:<prefix>[: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.