diff --git a/internal/compose/resolution/declaration.go b/internal/compose/resolution/declaration.go index b3f5672c..d72d33fe 100644 --- a/internal/compose/resolution/declaration.go +++ b/internal/compose/resolution/declaration.go @@ -659,6 +659,9 @@ func resolutionCompose(r mcat.Resolution, with mcat.Rendering, owner map[string] // parses strictly and knows no such field. absent := AbsentFor(r, m.Module) resources = withoutAbsent(resources, absent) + // **A resource declared only while a config value holds** (design 42 zfs-owns-zfs §2): left out while none of + // its `if-config` conditions holds, and the key dropped from the rest. + resources = withoutUnconfigured(resources, WithDefaults(m, with.Settings[m.Module])) // No container is given the mesh's names (novox/hq ADR 0148). It used to be: every // container got the whole roster as `--add-host` entries at creation, and a name that diff --git a/internal/compose/resolution/if_config.go b/internal/compose/resolution/if_config.go new file mode 100644 index 00000000..1c1c5db9 --- /dev/null +++ b/internal/compose/resolution/if_config.go @@ -0,0 +1,80 @@ +package resolution + +import ( + "fmt" + "strings" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// withoutUnconfigured drops each resource whose `if-config` holds none of its conditions against the module's config +// values (design 42 zfs-owns-zfs §2, ADR 0679 §16 (b)), and the key itself from every resource that keeps it: the +// node-engine parses strictly and knows no such key. What leaves the declaration this way is removed as any resource +// that leaves it (ADR 0269 §2), by the node-engine's own orphan removal. +func withoutUnconfigured(resources []map[string]any, layers []mcat.Layer) []map[string]any { + out := make([]map[string]any, 0, len(resources)) + for _, r := range resources { + conditions, has := r[mcat.IfConfigField] + if !has { + out = append(out, r) + continue + } + if !ifConfigHolds(conditions, layers) { + continue + } + kept := make(map[string]any, len(r)) + for k, v := range r { + if k != mcat.IfConfigField { + kept[k] = v + } + } + out = append(out, kept) + } + return out +} + +// ifConfigHolds is whether any condition of a list holds: `` when the config value is set and is neither false, +// off, none, empty nor an empty list; `=` when the value equals it, or a list value holds it as an entry. +func ifConfigHolds(conditions any, layers []mcat.Layer) bool { + list, _ := conditions.([]any) + for _, c := range list { + key, want, equals := strings.Cut(fmt.Sprint(c), "=") + value, set := settingValue(layers, key) + if !set { + continue + } + if !equals { + if !isOff(value) { + return true + } + continue + } + if entries, isList := value.([]any); isList { + for _, e := range entries { + if plainly(e) == want { + return true + } + } + } else if plainly(value) == want { + return true + } + } + return false +} + +// isOff is a config value that says "not here": false, off, none, an empty text or an empty list. +func isOff(value any) bool { + switch v := value.(type) { + case bool: + return !v + case []any: + return len(v) == 0 + case nil: + return true + } + switch plainly(value) { + case "", "false", "off", "none": + return true + } + return false +} diff --git a/internal/compose/resolution/setting_into_test.go b/internal/compose/resolution/setting_into_test.go index 21d76477..288923f1 100644 --- a/internal/compose/resolution/setting_into_test.go +++ b/internal/compose/resolution/setting_into_test.go @@ -1,42 +1,121 @@ package resolution import ( + "errors" "strings" "testing" mcat "git.novox.be/novox/mesh-sdk/go/catalogue" ) -// An operator's value reaches a file from the assignment's settings, the node's layer over the -// mesh's (novox/hq ADR 0112, ADR 0155), and a value nothing set is refused by name. -func TestASettingReachesAFileFromTheLayers(t *testing.T) { - file := map[string]any{"id": "env", "type": "file", "path": "/x/mail.env", - "content": "DOMAIN=${setting:domain}\nSITENAME=${setting:sitename}\nWORKERS=${setting:workers}\n"} - layers := []mcat.Layer{ - {From: "the mesh", Values: map[string]any{"domain": "example.tld", "sitename": "Mesh", "workers": float64(4)}}, - {From: "this node", Values: map[string]any{"sitename": "This one"}}, +// A config value reaches a resource from the assignment's layers, the node's over the mesh's (novox/hq ADR 0112, +// ADR 0155), and a value nothing set is refused by name; and a resource under `if-config` is declared only while a +// config value holds (design 42 zfs-owns-zfs §2, ADR 0679 §16 (b)). One table of the two, each row its layers, what it +// does to a resource and what comes of it. +func TestAConfigValueReachesAResourceAndDecidesWhetherItIsDeclared(t *testing.T) { + filled := func(content string) func([]mcat.Layer) (string, error) { + return func(layers []mcat.Layer) (string, error) { + file := map[string]any{"id": "env", "type": "file", "path": "/x/mail.env", "content": content} + err := settingInto(file, layers, "mail") + return file["content"].(string), err + } } - if err := settingInto(file, layers, "mail"); err != nil { - t.Fatal(err) + // declared is the ids of what is left of a resource under each condition, and whether the key is gone from it. + declared := func(conditions ...any) func([]mcat.Layer) (string, error) { + return func(layers []mcat.Layer) (string, error) { + kept := withoutUnconfigured([]map[string]any{ + {"id": "plain", "type": "file"}, {"id": "cond", "type": "file", mcat.IfConfigField: conditions}}, layers) + var ids []string + for _, r := range kept { + if _, has := r[mcat.IfConfigField]; has { + return "", errKeyKept + } + ids = append(ids, r["id"].(string)) + } + return strings.Join(ids, ","), nil + } } - if file["content"] != "DOMAIN=example.tld\nSITENAME=This one\nWORKERS=4\n" { - t.Fatalf("filled as %q", file["content"]) + values := func(kv map[string]any) []mcat.Layer { return []mcat.Layer{{From: "the mesh", Values: kv}} } + for _, row := range []struct { + name string + layers []mcat.Layer + do func([]mcat.Layer) (string, error) + want string // what comes of it + errs []string // or what the refusal says + }{ + {"the node's layer over the mesh's", []mcat.Layer{ + {From: "the mesh", Values: map[string]any{"domain": "example.tld", "sitename": "Mesh", "workers": float64(4)}}, + {From: "this node", Values: map[string]any{"sitename": "This one"}}}, + filled("DOMAIN=${setting:domain}\nSITENAME=${setting:sitename}\nWORKERS=${setting:workers}\n"), + "DOMAIN=example.tld\nSITENAME=This one\nWORKERS=4\n", nil}, + {"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"}}, + {"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}), + declared("k"), "plain", nil}, + {"if-config: off", values(map[string]any{"k": "off"}), declared("k"), "plain", nil}, + {"if-config: none", values(map[string]any{"k": "none"}), declared("k"), "plain", nil}, + {"if-config: empty text", values(map[string]any{"k": ""}), declared("k"), "plain", nil}, + {"if-config: an empty list", values(map[string]any{"k": []any{}}), declared("k"), "plain", nil}, + {"if-config: a list of entries", values(map[string]any{"k": []any{"a"}}), declared("k"), "plain,cond", nil}, + {"if-config: a value equal to the condition", values(map[string]any{"k": "on"}), declared("k=on"), "plain,cond", nil}, + {"if-config: a value other than the condition", values(map[string]any{"k": "off"}), declared("k=on"), "plain", nil}, + {"if-config: a list holding the entry", values(map[string]any{"k": []any{"a", "b"}}), declared("k=b"), "plain,cond", nil}, + {"if-config: a list without the entry", values(map[string]any{"k": []any{"a"}}), declared("k=b"), "plain", nil}, + {"if-config: any of the conditions", values(map[string]any{"j": "x"}), declared("k", "j"), "plain,cond", nil}, + {"if-config: a value from a default layer", []mcat.Layer{{Default: true, Values: map[string]any{"k": "on"}}}, + declared("k=on"), "plain,cond", nil}, + {"if-config: a number equal to the condition", values(map[string]any{"k": float64(64)}), declared("k=64"), "plain,cond", nil}, + } { + t.Run(row.name, func(t *testing.T) { + got, err := row.do(row.layers) + if len(row.errs) == 0 && err != nil { + t.Fatal(err) + } + if len(row.errs) > 0 && err == nil { + t.Fatalf("a refusal was expected, got %q", got) + } + for _, want := range row.errs { + if !strings.Contains(err.Error(), want) { + t.Fatalf("the refusal lacks %q: %v", want, err) + } + } + if got != row.want { + t.Fatalf("got %q, want %q", got, row.want) + } + }) } } -func TestASettingNothingSetIsRefusedByName(t *testing.T) { - file := map[string]any{"id": "env", "type": "file", "content": "DOMAIN=${setting:domain}\n"} - err := settingInto(file, []mcat.Layer{{From: "the mesh", Values: map[string]any{"other": "x"}}}, "mail") - if err == nil { - t.Fatal("a setting nothing set was written as something") - } - for _, want := range []string{"${setting:domain}", "settings set mail", "set today: other"} { - if !strings.Contains(err.Error(), want) { - t.Fatalf("the refusal lacks %q: %v", want, err) +// errKeyKept is what a row of the table above says when `if-config` reached the node-engine, which parses strictly. +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":[ + {"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}} { + out, err := Declaration(mcat.Resolution{Node: "workstation", Modules: []mcat.Manifest{m}}, + mcat.Rendering{Settings: mcat.SettingsBy{"zfs": row.layers}}) + if err != nil { + t.Fatal(err) + } + if fileNamed(out, "zfs.conf") == nil { + t.Error("the unconditioned file was left out") + } + 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) + } + if _, has := zed[mcat.IfConfigField]; has { + t.Errorf("%s reached the node-engine, which parses strictly", mcat.IfConfigField) } } - // Left as it was: the literal placeholder must never reach a machine. - if file["content"] != "DOMAIN=${setting:domain}\n" { - t.Fatalf("content was changed on refusal: %q", file["content"]) - } }