From 61c42f9fbc9eac80349ea6b3e307b7cf1efd781e Mon Sep 17 00:00:00 2001 From: jochens Date: Fri, 13 Nov 2026 00:22:22 +0100 Subject: [PATCH] H3 engine-config-lines, H3b engine-config-json: a template writes lines from a config value and the config as JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `${config-lines::[: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 --- internal/compose/resolution/config_lines.go | 90 +++++++++++++++++++ internal/compose/resolution/setting_into.go | 7 ++ .../compose/resolution/setting_into_test.go | 24 ++++- .../resolution/unconsumed_placeholder.go | 4 + 4 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 internal/compose/resolution/config_lines.go diff --git a/internal/compose/resolution/config_lines.go b/internal/compose/resolution/config_lines.go new file mode 100644 index 00000000..4758f3fe --- /dev/null +++ b/internal/compose/resolution/config_lines.go @@ -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::}` and `${config-lines:::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, ``. 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 `` 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 +} diff --git a/internal/compose/resolution/setting_into.go b/internal/compose/resolution/setting_into.go index df0abe2e..61b69a48 100644 --- a/internal/compose/resolution/setting_into.go +++ b/internal/compose/resolution/setting_into.go @@ -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 { diff --git a/internal/compose/resolution/setting_into_test.go b/internal/compose/resolution/setting_into_test.go index 288923f1..a2c0a98f 100644 --- a/internal/compose/resolution/setting_into_test.go +++ b/internal/compose/resolution/setting_into_test.go @@ -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) diff --git a/internal/compose/resolution/unconsumed_placeholder.go b/internal/compose/resolution/unconsumed_placeholder.go index b83fe4cc..b46d65b5 100644 --- a/internal/compose/resolution/unconsumed_placeholder.go +++ b/internal/compose/resolution/unconsumed_placeholder.go @@ -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"}},