H3 engine-config-lines, H3b engine-config-json: a template writes lines from a config value and the config as JSON

`${config-lines:<key>:<prefix>[:mib-bytes]}` in a file's content writes one line
per entry (nothing for an unset value or empty list; a newline in an entry
refuses the composition); `${config-json}` writes the module's set config values
as one JSON object. Both join the sweep's table of fillers. Their rows join the
config table test (A-22).

Builds: design 42 zfs-owns-zfs §3, §9; answers A-15, A-24, A-22
This commit is contained in:
2026-11-13 00:22:22 +01:00
parent 86048f0ed9
commit 61c42f9fbc
4 changed files with 123 additions and 2 deletions
@@ -0,0 +1,90 @@
package resolution
import (
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// configLinesRef is `${config-lines:<key>:<prefix>}` and `${config-lines:<key>:<prefix>:mib-bytes}` in a file's
// content (design 42 zfs-owns-zfs §3, ADR 0679 §16 (c)): one line per entry of a list value, `<prefix><entry>`. The
// prefix is literal text with no `}`, `$` or newline. A placeholder that holds a whole line takes its newline with it.
var configLinesRef = regexp.MustCompile(`\$\{config-lines:([a-z0-9][a-z0-9_.-]*):([^}$\n]*?)(:mib-bytes)?\}(\n?)`)
// configJSONRef is `${config-json}` in a file's content (design 42 zfs-owns-zfs §9, answer A-15): the module's set
// config values as one JSON object.
var configJSONRef = regexp.MustCompile(`\$\{config-json\}`)
// configLinesInto writes each `${config-lines:…}` of a file's content from the layers: a line `<prefix><entry>` for each
// entry of a list, one for a set scalar, nothing for an unset value or an empty list. With `mib-bytes` an entry given in
// MiB is written in bytes. An entry holding a newline is refused, and the content is left as it was.
func configLinesInto(content string, layers []mcat.Layer, module string) (string, error) {
var refused error
filled := configLinesRef.ReplaceAllStringFunc(content, func(token string) string {
m := configLinesRef.FindStringSubmatch(token)
key, prefix, mib, newline := m[1], m[2], m[3] != "", m[4]
value, set := settingValue(layers, key)
if !set {
return ""
}
entries, isList := value.([]any)
if !isList {
entries = []any{value}
}
var lines []string
for _, e := range entries {
text := plainly(e)
if strings.ContainsAny(text, "\r\n") {
refused = fmt.Errorf("%s: an entry of the config value %q holds a newline, which would begin a line "+
"of its own in the file it is written into (design 42 §3)", module, key)
return token
}
if mib {
n, err := strconv.ParseUint(text, 10, 64)
if err != nil || n > 1<<44 {
refused = fmt.Errorf("%s: the config value %q holds %q, and mib-bytes writes a whole number of MiB "+
"in bytes (design 42 §3)", module, key, text)
return token
}
text = strconv.FormatUint(n<<20, 10)
}
lines = append(lines, prefix+text)
}
if len(lines) == 0 {
return ""
}
return strings.Join(lines, "\n") + newline
})
if refused != nil {
return content, refused
}
return filled, nil
}
// configJSONInto writes `${config-json}` in a file's content as the module's set config values in one JSON object
// (design 42 zfs-owns-zfs §9, answer A-15): what no layer sets is absent, and no unset value refuses the module; a
// list and an object are written as JSON, a number, a boolean and a text as JSON values of their own type.
func configJSONInto(content string, layers []mcat.Layer) (string, error) {
if !configJSONRef.MatchString(content) {
return content, nil
}
set := map[string]any{}
for _, layer := range layers {
for k, v := range layer.Values {
set[k] = v
}
}
var out strings.Builder
enc := json.NewEncoder(&out)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if err := enc.Encode(set); err != nil {
return content, fmt.Errorf("the config values cannot be written as JSON: %w", err)
}
object := strings.TrimSuffix(out.String(), "\n")
return configJSONRef.ReplaceAllLiteralString(content, object), nil
}
@@ -83,6 +83,13 @@ func settingInto(resource map[string]any, layers []mcat.Layer, module string) er
if !ok {
return nil
}
content, err := configLinesInto(content, layers, module)
if err != nil {
return err
}
if content, err = configJSONInto(content, layers); err != nil {
return err
}
for _, key := range settingsUsed(content) {
value, set := settingValue(layers, key)
if !set {
@@ -2,6 +2,7 @@ package resolution
import (
"errors"
"fmt"
"strings"
"testing"
@@ -51,6 +52,21 @@ func TestAConfigValueReachesAResourceAndDecidesWhetherItIsDeclared(t *testing.T)
{"a value nothing set is refused, the file left as it was", values(map[string]any{"other": "x"}),
filled("DOMAIN=${setting:domain}\n"), "DOMAIN=${setting:domain}\n",
[]string{"${setting:domain}", "settings set mail", "set today: other"}},
{"config-lines: a list of two writes two lines in order", values(map[string]any{"k": []any{"a", "b"}}),
filled("h\n${config-lines:k:options zfs x=}\nt\n"), "h\noptions zfs x=a\noptions zfs x=b\nt\n", nil},
{"config-lines: an empty list writes nothing", values(map[string]any{"k": []any{}}),
filled("h\n${config-lines:k:p=}\nt\n"), "h\nt\n", nil},
{"config-lines: an unset value writes nothing", nil, filled("h\n${config-lines:k:p=}\nt\n"), "h\nt\n", nil},
{"config-lines: a set scalar writes one line", values(map[string]any{"k": "v"}),
filled("${config-lines:k:p=}\n"), "p=v\n", nil},
{"config-lines: mib-bytes writes MiB in bytes", values(map[string]any{"k": float64(64)}),
filled("${config-lines:k:zfs_arc_max=:mib-bytes}\n"), "zfs_arc_max=67108864\n", nil},
{"config-lines: an entry with a newline is refused, the file left as it was", values(map[string]any{"k": []any{"a\nb"}}),
filled("${config-lines:k:p=}\n"), "${config-lines:k:p=}\n", []string{"newline"}},
{"config-json: the set values only, each as its own type", values(map[string]any{"n": float64(64), "b": true, "s": "x",
"l": []any{"a"}, "o": map[string]any{"f": float64(1)}}), filled("${config-json}\n"),
"{\n \"b\": true,\n \"l\": [\n \"a\"\n ],\n \"n\": 64,\n \"o\": {\n \"f\": 1\n },\n \"s\": \"x\"\n}\n", nil},
{"config-json: nothing set is an empty object, not a refusal", nil, filled("${config-json}\n"), "{}\n", nil},
{"if-config: a key never set leaves it out", nil, declared("k"), "plain", nil},
{"if-config: a key set to text declares it", values(map[string]any{"k": "x"}), declared("k"), "plain,cond", nil},
{"if-config: a key set to false, off, none, empty or an empty list leaves it out", values(map[string]any{"k": false}),
@@ -95,13 +111,14 @@ var errKeyKept = errors.New("the if-config key was kept in a declared resource")
// The composition takes a resource out while its condition does not hold, and a value going from set to unset takes it
// out of the next composition, which the node-engine removes as it removes any resource that left (design 42 §2).
func TestAConfigConditionedResourceIsComposedWhileItsValueHolds(t *testing.T) {
m := mustParse(t, `{"module":"zfs","version":"1","config":{"zed":{"type":"string","default":"off"}},"resources":[
m := mustParse(t, `{"module":"zfs","version":"1","config":{"zed":{"type":"string","default":"off"},"mods":{"type":"list"}},"resources":[
{"id":"opts","type":"file","path":"/etc/modprobe.d/zfs.conf","content":"${config-lines:mods:options zfs }\n${config-json}\n"},
{"id":"conf","type":"file","path":"/etc/zfs/mesh.conf","content":"x\n"},
{"id":"zed","type":"file","path":"/etc/zfs/zed.rc","content":"y\n","if-config":["zed=on"]}]}`)
for _, row := range []struct {
layers []mcat.Layer
zed bool
}{{nil, false}, {[]mcat.Layer{{From: "this node", Values: map[string]any{"zed": "on"}}}, true}} {
}{{nil, false}, {[]mcat.Layer{{From: "this node", Values: map[string]any{"zed": "on", "mods": []any{"a=1"}}}}, true}} {
out, err := Declaration(mcat.Resolution{Node: "workstation", Modules: []mcat.Manifest{m}},
mcat.Rendering{Settings: mcat.SettingsBy{"zfs": row.layers}})
if err != nil {
@@ -110,6 +127,9 @@ func TestAConfigConditionedResourceIsComposedWhileItsValueHolds(t *testing.T) {
if fileNamed(out, "zfs.conf") == nil {
t.Error("the unconditioned file was left out")
}
if opts := fileNamed(out, "zfs.opts"); opts == nil || (row.zed != strings.HasPrefix(fmt.Sprint(opts["content"]), "options zfs a=1\n")) {
t.Errorf("the template's lines do not follow the list: %v", opts)
}
zed := fileNamed(out, "zfs.zed")
if (zed != nil) != row.zed {
t.Errorf("with %v the conditioned file is declared: %v, want %v", row.layers, zed != nil, row.zed)
@@ -25,6 +25,10 @@ type filler struct {
// or the sweep refuses the placeholder it would have filled.
var fillers = []filler{
{namespace: "setting", patterns: []*regexp.Regexp{settingRef}, fields: map[string][]string{"file": {"content"}, "service": {"unit"}, "directory": {"owner"}, "container": {"groups"}}},
// A file's content only: lines from a config value's entries, and the module's config as one JSON object
// (design 42 zfs-owns-zfs §3, §9; config_lines.go).
{namespace: "config-lines", patterns: []*regexp.Regexp{configLinesRef}, fields: map[string][]string{"file": {"content"}}},
{namespace: "config-json", patterns: []*regexp.Regexp{configJSONRef}, fields: map[string][]string{"file": {"content"}}},
{namespace: "dir", patterns: []*regexp.Regexp{dirRef}, fields: map[string][]string{"*": {"path", "content", "volumes", "env", "env-file"}}},
{namespace: "access", patterns: []*regexp.Regexp{accessRef}, fields: map[string][]string{"*": {"path", "content", "volumes", "env", "env-file"}}},
{namespace: "secret", patterns: []*regexp.Regexp{placeholder}, fields: map[string][]string{"file": {"content"}},