diff --git a/README.md b/README.md index 88e5409..05b143a 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ bt cloud-config.yaml > butane.yaml # or: cat cloud-config.yaml | bt -o butane.yaml ``` -`bt` currently converts the Cluster API `users`, `ntp`, and plain `write_files` subsets. A config +`bt` currently converts the Cluster API `users`, `ntp`, `bootcmd`, `runcmd`, and plain `write_files` subsets. A config is either converted completely or rejected; unsupported fields are never silently discarded. @@ -52,8 +52,8 @@ silently discarded. | ------------------- | ------ | ------------- | | `users` | Supported Cluster API subset | `passwd`, plus generated `storage.files` | | `write_files` | Supported plain-file subset | `storage.files`, including tmpfiles rules for `/run` | -| `runcmd` | Unsupported | Planned scripts and systemd units | -| `bootcmd` | Unsupported | Planned scripts and systemd units | +| `runcmd` | Supported Cluster API subset | A generated script and once-per-instance systemd unit | +| `bootcmd` | Supported Cluster API subset | A generated script and every-boot systemd unit | | `ntp` | Supported Cluster API subset | A generated timesyncd configuration file | | `disk_setup` | Unsupported | Planned storage configuration | | `fs_setup` | Unsupported | Planned `storage.filesystems` | @@ -140,8 +140,8 @@ recreated on every boot.** `!` restricts rules to tmpfiles invocations using `--boot`, not just the first boot. Such invocations also replace existing content. Consumers must run after `systemd-tmpfiles-setup.service`; conflicting tmpfiles rules installed outside this cloud-config remain the operator's responsibility. -Creating a kubeadm configuration does not execute `kubeadm join`; `runcmd` is -still unsupported. Boot and reboot behavior is verified on Flatcar 4757.2.1. +Creating a kubeadm configuration does not execute `kubeadm join`; use `runcmd` +for that. Boot and reboot behavior is verified on Flatcar 4757.2.1. ### NTP @@ -165,7 +165,48 @@ Fedora CoreOS [uses chrony](https://docs.fedoraproject.org/en-US/fedora-coreos/t so this timesyncd drop-in would not configure its default NTP client. Supporting it would require both the appropriate Butane variant and chrony configuration, not just a change to the variant. -All stanzas other than `users`, `ntp`, and `write_files` remain unsupported. Jinja templates are +### Commands + +`bootcmd` and `runcmd` accept only the shape Cluster API emits: a list of strings. +Argument-list items (`- [ls, -l]`), other non-string items, and a null stanza are +rejected. An omitted stanza or empty list generates nothing. + +Each stanza becomes a root-owned, mode 0700 `/bin/sh` script at +`/opt/butane-init/.sh`, with one command per line, preserved verbatim, as +cloud-init does. The scripts are not run with `set -e`: a failing command does not +stop later ones, and the unit's result is the last command's exit status. Commands +may contain bootstrap tokens, so the scripts are readable only by root. They live in +`/opt` because Flatcar mounts `/etc` `noexec`. + +| | `bootcmd` | `runcmd` | +| --- | --- | --- | +| Unit | `butane-init-bootcmd.service` | `butane-init-runcmd.service` | +| Frequency | Every boot | Once per instance, even if the commands fail | +| Ordering | After `network.target`, before `network-online.target` and `runcmd` | After `network-online.target` and `bootcmd` | + +Both are oneshot units without a start timeout. A failing `bootcmd` is logged but +does not prevent `runcmd`. An *instance* is identified by the Flatcar machine ID: +`runcmd` writes `/var/lib/butane-init/sem//runcmd` before running its +commands, so it reruns only after the machine ID changes, for example after +`flatcar-reset`. Output goes to the journal; `/var/log/cloud-init-output.log` is +not emulated. Unlike cloud-init's first boot, `write_files` content already exists +when `bootcmd` runs. + +The units, and only the units, run with `HOME=/root`, `INSTANCE_ID` set to the +machine ID, and +`PATH=/opt/butane-init/bin:/opt/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin`. +Flatcar does not ship cloud-init, so `/opt/butane-init/bin/cloud-init-per` +replaces its `cloud-init-per` tool: +`cloud-init-per once|instance|always [args...]`, with markers +under `/var/lib/butane-init/sem/` and `instance` markers stored per machine ID. +Commands are never rewritten; other cloud-init tools are not provided. + +`/opt/butane-init`, `/var/lib/butane-init`, the two units and their drop-in +directories and enablement symlinks, and `/etc/systemd/system-preset/20-ignition.preset` +are reserved: `write_files` entries that target them are rejected. + +All stanzas other than `users`, `ntp`, `write_files`, `bootcmd`, and `runcmd` +remain unsupported. Jinja templates are not evaluated and are rejected; render them before passing cloud-config to `bt`. ### A Flatcar Container Linux project diff --git a/commands.go b/commands.go new file mode 100644 index 0000000..1eb4b72 --- /dev/null +++ b/commands.go @@ -0,0 +1,113 @@ +package transpile + +import ( + _ "embed" + "fmt" + "strings" + + base "github.com/coreos/ignition/v2/butane/base/v0_5" + "github.com/goccy/go-yaml/ast" +) + +const ( + // Not /etc: Flatcar mounts /etc as a noexec confext overlay. + commandsDir = "/opt/butane-init" + commandsState = "/var/lib/butane-init" +) + +// commandReservedPaths are owned by the generated command units, even when a +// config does not use them, so write_files cannot interfere with them. +var commandReservedPaths = []string{ + commandsDir, + commandsState, + "/etc/systemd/system/butane-init-bootcmd.service", + "/etc/systemd/system/butane-init-bootcmd.service.d", + "/etc/systemd/system/butane-init-runcmd.service", + "/etc/systemd/system/butane-init-runcmd.service.d", + // Ignition appends enablement presets here; systemd then creates the + // wants symlinks. Either can be blocked or overridden by a written file. + "/etc/systemd/system-preset/20-ignition.preset", + "/etc/systemd/system/multi-user.target.wants/butane-init-bootcmd.service", + "/etc/systemd/system/multi-user.target.wants/butane-init-runcmd.service", +} + +// The embedded files are written exactly as they land on disk. Their paths +// must stay within commandReservedPaths; TestCommandFilesUseReservedPaths +// checks this. +// +// Both units add /opt/bin, where Flatcar installations commonly place +// kubeadm, to PATH, with the cloud-init-per replacement first. bootcmd runs +// on every boot, like cloud-init's init stage: after basic networking and +// before network-online.target and runcmd. Its failure is logged without +// blocking runcmd. runcmd runs once per instance (machine ID); like +// cloud-init's semaphore, its marker is written before the commands run, so +// failed or interrupted commands are not retried on reboot. + +//go:embed commands/butane-init-bootcmd.service +var bootcmdUnit string + +//go:embed commands/butane-init-runcmd.service +var runcmdUnit string + +// cloudInitPer replaces cloud-init's tools/cloud-init-per, which Flatcar does +// not ship. "instance" markers are stored per machine ID. +// +//go:embed commands/cloud-init-per +var cloudInitPer string + +// parseCommands converts bootcmd and runcmd into scripts and oneshot units. +func parseCommands(document map[string]any, file *ast.File) ([]base.File, []base.Unit, ValidationErrors) { + var files []base.File + var units []base.Unit + var problems ValidationErrors + for _, stanza := range []struct{ key, unit string }{ + {"bootcmd", bootcmdUnit}, + {"runcmd", runcmdUnit}, + } { + raw, exists := document[stanza.key] + if !exists { + continue + } + items, ok := raw.([]any) + if !ok { + problems = append(problems, problem(file, stanza.key, "must be a list")) + continue + } + // Like cloud-init: one line per command, without set -e. + var script strings.Builder + script.WriteString("#!/bin/sh\n") + for i, item := range items { + command, ok := item.(string) + if !ok { + message := "must be a string" + if _, isList := item.([]any); isList { + message = "must be a string; argument lists are unsupported" + } + problems = append(problems, problem(file, fmt.Sprintf("%s[%d]", stanza.key, i), message)) + continue + } + script.WriteString(command + "\n") + } + if len(items) == 0 { + continue + } + // Commands can embed bootstrap tokens, so only root may read them. + files = append(files, commandFile(commandsDir+"/"+stanza.key+".sh", script.String(), 0o700)) + enabled := true + contents := stanza.unit + units = append(units, base.Unit{Name: "butane-init-" + stanza.key + ".service", Enabled: &enabled, Contents: &contents}) + } + if len(problems) > 0 { + return nil, nil, problems + } + if len(units) > 0 { + files = append(files, commandFile(commandsDir+"/bin/cloud-init-per", cloudInitPer, 0o755)) + } + return files, units, nil +} + +func commandFile(path, contents string, mode int) base.File { + file := generatedFile(path, contents) + file.Mode = &mode + return file +} diff --git a/commands/butane-init-bootcmd.service b/commands/butane-init-bootcmd.service new file mode 100644 index 0000000..ee9fa32 --- /dev/null +++ b/commands/butane-init-bootcmd.service @@ -0,0 +1,15 @@ +[Unit] +Description=Butane Init cloud-config bootcmd +After=network.target +Before=network-online.target butane-init-runcmd.service + +[Service] +Type=oneshot +RemainAfterExit=yes +Environment=PATH=/opt/butane-init/bin:/opt/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin +Environment=HOME=/root +Environment=INSTANCE_ID=%m +ExecStart=/opt/butane-init/bootcmd.sh + +[Install] +WantedBy=multi-user.target diff --git a/commands/butane-init-runcmd.service b/commands/butane-init-runcmd.service new file mode 100644 index 0000000..9bddad8 --- /dev/null +++ b/commands/butane-init-runcmd.service @@ -0,0 +1,18 @@ +[Unit] +Description=Butane Init cloud-config runcmd +Wants=network-online.target +After=network-online.target butane-init-bootcmd.service +ConditionPathExists=!/var/lib/butane-init/sem/%m/runcmd + +[Service] +Type=oneshot +RemainAfterExit=yes +Environment=PATH=/opt/butane-init/bin:/opt/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin +Environment=HOME=/root +Environment=INSTANCE_ID=%m +ExecStartPre=/usr/bin/mkdir -p /var/lib/butane-init/sem/%m +ExecStartPre=/usr/bin/touch /var/lib/butane-init/sem/%m/runcmd +ExecStart=/opt/butane-init/runcmd.sh + +[Install] +WantedBy=multi-user.target diff --git a/commands/cloud-init-per b/commands/cloud-init-per new file mode 100644 index 0000000..e8f1e93 --- /dev/null +++ b/commands/cloud-init-per @@ -0,0 +1,33 @@ +#!/bin/sh +# Generated by Butane Init as a replacement for cloud-init's cloud-init-per. +usage() { + echo "Usage: ${0##*/} once|instance|always name cmd [arg ...]" +} +fail() { + echo "$@" 1>&2 + exit 1 +} +if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then + usage + exit 0 +fi +[ $# -ge 3 ] || { usage 1>&2; exit 1; } +freq=$1 +name=$(printf '%s' "$2" | sed 's/-/_/g') +shift 2 +[ "${name#*/}" = "$name" ] || fail "name cannot contain a /" +[ "$(id -u)" = "0" ] || fail "must be root" +case "$freq" in + once | always) sem="/var/lib/butane-init/sem/bootper.$name.$freq" ;; + instance) sem="/var/lib/butane-init/sem/$(cat /etc/machine-id)/bootper.$name.$freq" ;; + *) + usage 1>&2 + fail "invalid frequency: $freq" + ;; +esac +mkdir -p "${sem%/*}" || fail "failed to make directory for $sem" +[ "$freq" != "always" ] && [ -e "$sem" ] && exit 0 +"$@" +ret=$? +printf '%s\t%s\n' "$ret" "$(date +%s)" > "$sem" || fail "failed to write to $sem" +exit $ret diff --git a/commands_test.go b/commands_test.go new file mode 100644 index 0000000..9b6e80c --- /dev/null +++ b/commands_test.go @@ -0,0 +1,162 @@ +package transpile_test + +import ( + "errors" + "os" + "reflect" + "regexp" + "strings" + "testing" + + schema "github.com/coreos/ignition/v2/butane/config/flatcar/v1_1" + "github.com/flatcar/butane-init" + "github.com/goccy/go-yaml" +) + +func TestTranspileCommands(t *testing.T) { + got, err := transpile.Transpile([]byte(readFixture(t, "cluster-api-commands.yaml"))) + if err != nil { + t.Fatal(err) + } + var config schema.Config + if err := yaml.Unmarshal(got, &config); err != nil { + t.Fatal(err) + } + + files := map[string]string{} + modes := map[string]int{} + for _, file := range config.Storage.Files { + files[file.Path] = *file.Contents.Inline + modes[file.Path] = *file.Mode + } + // Commands are preserved verbatim, one per line, without set -e. + wantScripts := map[string]string{ + "/opt/butane-init/bootcmd.sh": "#!/bin/sh\ncloud-init-per once wipe-scratch echo \"first boot only\"\n", + "/opt/butane-init/runcmd.sh": "#!/bin/sh\n" + + "echo \"pre-kubeadm\" > /run/pre-kubeadm.log\n" + + "kubeadm join --config /run/kubeadm/kubeadm-join-config.yaml && echo success > /run/cluster-api/bootstrap-success.complete\n" + + "\n" + + "echo post-kubeadm\n", + } + for path, want := range wantScripts { + if files[path] != want || modes[path] != 0o700 { + t.Errorf("%s = %q (mode %o), want %q (mode 700)", path, files[path], modes[path], want) + } + } + if !strings.Contains(files["/opt/butane-init/bin/cloud-init-per"], `"$@"`) || modes["/opt/butane-init/bin/cloud-init-per"] != 0o755 { + t.Errorf("cloud-init-per replacement missing or not executable: %#v", config.Storage.Files) + } + + units := map[string]string{} + for _, unit := range config.Systemd.Units { + if unit.Enabled == nil || !*unit.Enabled { + t.Errorf("unit %s is not enabled", unit.Name) + } + units[unit.Name] = *unit.Contents + } + runcmd := units["butane-init-runcmd.service"] + for _, want := range []string{ + "ConditionPathExists=!/var/lib/butane-init/sem/%m/runcmd", + "After=network-online.target butane-init-bootcmd.service", + "Environment=PATH=/opt/butane-init/bin:/opt/bin:", + "Environment=INSTANCE_ID=%m", + "ExecStart=/opt/butane-init/runcmd.sh", + } { + if !strings.Contains(runcmd, want) { + t.Errorf("runcmd unit missing %q:\n%s", want, runcmd) + } + } + // The marker is written before the commands run, so a failed or + // interrupted runcmd is not retried on the next boot. + marker := strings.Index(runcmd, "ExecStartPre=/usr/bin/touch /var/lib/butane-init/sem/%m/runcmd") + if marker == -1 || marker > strings.Index(runcmd, "ExecStart=") { + t.Errorf("runcmd marker is not written before the commands run:\n%s", runcmd) + } + bootcmd := units["butane-init-bootcmd.service"] + if strings.Contains(bootcmd, "Condition") || !strings.Contains(bootcmd, "Before=network-online.target butane-init-runcmd.service") { + t.Errorf("bootcmd unit must run on every boot before runcmd:\n%s", bootcmd) + } +} + +func TestTranspileCommandsOnlyGeneratesUsedStanzas(t *testing.T) { + got, err := transpile.Transpile([]byte("#cloud-config\nbootcmd: []\nruncmd:\n - \"echo hi\"\n")) + if err != nil { + t.Fatal(err) + } + var config schema.Config + if err := yaml.Unmarshal(got, &config); err != nil { + t.Fatal(err) + } + if len(config.Systemd.Units) != 1 || config.Systemd.Units[0].Name != "butane-init-runcmd.service" { + t.Errorf("units = %#v, want only the runcmd unit", config.Systemd.Units) + } + + got, err = transpile.Transpile([]byte("#cloud-config\nbootcmd: []\nruncmd: []\n")) + if err != nil { + t.Fatal(err) + } + if want := "version: 1.1.0\nvariant: flatcar\n"; string(got) != want { + t.Errorf("empty command lists = %q, want %q", got, want) + } +} + +func TestTranspileCommandsRejectsInvalidInput(t *testing.T) { + input := `#cloud-config +bootcmd: +runcmd: + - [kubeadm, join] + - 42 + - "echo ok" +write_files: + - path: /opt/butane-init/runcmd.sh + - path: /etc/systemd/system/butane-init-bootcmd.service.d/override.conf + - path: /etc/systemd/system-preset/20-ignition.preset + - path: /etc/systemd/system/multi-user.target.wants +` + got, err := transpile.Transpile([]byte(input)) + if got != nil { + t.Fatalf("Transpile() output = %q, want nil on error", got) + } + var problems transpile.ValidationErrors + if !errors.As(err, &problems) { + t.Fatalf("Transpile() error = %v, want validation errors", err) + } + var paths []string + for _, problem := range problems { + paths = append(paths, problem.Path+": "+problem.Message) + } + want := []string{ + "bootcmd: must be a list", + "runcmd[0]: must be a string; argument lists are unsupported", + "runcmd[1]: must be a string", + `write_files[0].path: conflicts with file path "/opt/butane-init"`, + `write_files[1].path: conflicts with file path "/etc/systemd/system/butane-init-bootcmd.service.d"`, + `write_files[2].path: conflicts with file path "/etc/systemd/system-preset/20-ignition.preset"`, + `write_files[3].path: conflicts with file path "/etc/systemd/system/multi-user.target.wants/butane-init-bootcmd.service"`, + } + if !reflect.DeepEqual(paths, want) { + t.Errorf("problems = %q, want %q", paths, want) + } +} + +// The embedded unit and script files hard-code their paths. Every Butane Init +// path they use must stay reserved against write_files. +func TestCommandFilesUseReservedPaths(t *testing.T) { + pathPattern := regexp.MustCompile(`/[^\s:"$]*butane-init[^\s:"$]*`) + for _, name := range []string{"butane-init-bootcmd.service", "butane-init-runcmd.service", "cloud-init-per"} { + contents, err := os.ReadFile("commands/" + name) + if err != nil { + t.Fatal(err) + } + paths := pathPattern.FindAllString(string(contents), -1) + if len(paths) == 0 { + t.Fatalf("%s: found no Butane Init paths", name) + } + for _, path := range paths { + input := "#cloud-config\nwrite_files:\n - path: " + path + "\n" + if _, err := transpile.Transpile([]byte(input)); err == nil || !strings.Contains(err.Error(), "conflicts with file path") { + t.Errorf("%s uses %s, which write_files can target: %v", name, path, err) + } + } + } +} diff --git a/testcases/README.md b/testcases/README.md index 61321b4..961c371 100644 --- a/testcases/README.md +++ b/testcases/README.md @@ -16,6 +16,12 @@ These fixtures are adapted from Cluster API v1.11.1 at commit [cloud-init worker template](https://github.com/kubernetes-sigs/cluster-api/blob/v1.11.1/bootstrap/kubeadm/internal/cloudinit/node.go#L19-L36) supplies the `write_files` paths, ownership, modes, and placeholder content. The join configuration is a nonfunctional excerpt without credentials. +- The + [boot commands](https://github.com/kubernetes-sigs/cluster-api/blob/dc0fb87601071371044f60c59096ecad1b84397f/bootstrap/kubeadm/internal/cloudinit/boot_commands.go) + and [commands](https://github.com/kubernetes-sigs/cluster-api/blob/dc0fb87601071371044f60c59096ecad1b84397f/bootstrap/kubeadm/internal/cloudinit/commands.go) + templates emit `bootcmd` and `runcmd` items as `%q`-quoted strings; the join + and sentinel commands come from + [cloudinit.go](https://github.com/kubernetes-sigs/cluster-api/blob/dc0fb87601071371044f60c59096ecad1b84397f/bootstrap/kubeadm/internal/cloudinit/cloudinit.go#L31-L34). Secrets and SSH keys are nonfunctional test values. The fixtures are deliberately small excerpts of Cluster API output so each one isolates a transpilation @@ -27,3 +33,4 @@ boundary. | `cluster-api-groups.yaml` | Same group used as supplementary and primary | Rejected as contradictory | | `cluster-api-deferred-fields.yaml` | Cluster API account-policy value shapes | Rejected because `inactive: true` cannot be represented | | `cluster-api-write-files.yaml` | Worker bootstrap files under `/run` | Success via every-boot tmpfiles rules | +| `cluster-api-commands.yaml` | `bootcmd`, `cloud-init-per`, and the kubeadm join `runcmd` | Success via scripts and oneshot units | diff --git a/testcases/cluster-api-commands.yaml b/testcases/cluster-api-commands.yaml new file mode 100644 index 0000000..d262647 --- /dev/null +++ b/testcases/cluster-api-commands.yaml @@ -0,0 +1,8 @@ +#cloud-config +bootcmd: + - "cloud-init-per once wipe-scratch echo \"first boot only\"" +runcmd: + - "echo \"pre-kubeadm\" > /run/pre-kubeadm.log" + - kubeadm join --config /run/kubeadm/kubeadm-join-config.yaml && echo success > /run/cluster-api/bootstrap-success.complete + - "" + - "echo post-kubeadm" diff --git a/transpile.go b/transpile.go index 74c73af..99f3dac 100644 --- a/transpile.go +++ b/transpile.go @@ -70,7 +70,9 @@ func Transpile(input []byte) ([]byte, error) { var problems ValidationErrors for _, key := range sortedKeys(document) { - if key != "users" && key != "ntp" && key != "write_files" { + switch key { + case "users", "ntp", "write_files", "bootcmd", "runcmd": + default: problems = append(problems, problem(file, key, "unsupported field")) } } @@ -79,7 +81,9 @@ func Transpile(input []byte) ([]byte, error) { problems = append(problems, userProblems...) ntpFiles, ntpProblems := parseNTP(document, file) problems = append(problems, ntpProblems...) - generatedFiles := append(users.Files, ntpFiles...) + commandFiles, commandUnits, commandProblems := parseCommands(document, file) + problems = append(problems, commandProblems...) + generatedFiles := append(append(users.Files, ntpFiles...), commandFiles...) writtenFiles, fileProblems := parseWriteFiles(document, file, generatedFiles) problems = append(problems, fileProblems...) if len(problems) > 0 { @@ -90,6 +94,7 @@ func Transpile(input []byte) ([]byte, error) { config.Passwd.Groups = users.Groups config.Passwd.Users = users.Users config.Storage.Files = append(generatedFiles, writtenFiles...) + config.Systemd.Units = commandUnits out, err := yaml.MarshalWithOptions(config, yaml.OmitZero(), yaml.Indent(2), yaml.IndentSequence(true)) if err != nil { return nil, fmt.Errorf("encode Butane config: %w", err) diff --git a/transpile_test.go b/transpile_test.go index 16d2aaf..0b9ef3a 100644 --- a/transpile_test.go +++ b/transpile_test.go @@ -32,8 +32,8 @@ func TestTranspileRejectsInvalidDocument(t *testing.T) { wantErr: "Jinja templates are unsupported", }, "unsupported top-level field": { - input: "#cloud-config\nruncmd: []\n", - wantErr: "runcmd", + input: "#cloud-config\nmounts: []\n", + wantErr: "mounts", }, "yaml alias": { input: "#cloud-config\nusers:\n - &user\n name: alice\n - *user\n", @@ -62,7 +62,7 @@ func TestTranspileRejectsInvalidDocument(t *testing.T) { } func TestTranspileReturnsStructuredValidationErrors(t *testing.T) { - input := "#cloud-config\nruncmd: []\nusers:\n - shell: \"\"\n" + input := "#cloud-config\nmounts: []\nusers:\n - shell: \"\"\n" got, err := transpile.Transpile([]byte(input)) if err == nil { @@ -77,7 +77,7 @@ func TestTranspileReturnsStructuredValidationErrors(t *testing.T) { t.Fatalf("Transpile() error type = %T, want transpile.ValidationErrors", err) } want := transpile.ValidationErrors{ - {Path: "runcmd", Message: "unsupported field", Line: 2, Column: 9}, + {Path: "mounts", Message: "unsupported field", Line: 2, Column: 9}, {Path: "users[0].name", Message: "is required", Line: 4, Column: 10}, {Path: "users[0].shell", Message: "must not be empty", Line: 4, Column: 12}, } @@ -85,7 +85,7 @@ func TestTranspileReturnsStructuredValidationErrors(t *testing.T) { t.Fatalf("Transpile() validation errors = %#v, want %#v", problems, want) } - wantMessage := "[2:9] runcmd: unsupported field\n" + + wantMessage := "[2:9] mounts: unsupported field\n" + "[4:10] users[0].name: is required\n" + "[4:12] users[0].shell: must not be empty" if err.Error() != wantMessage { diff --git a/write_files.go b/write_files.go index 1bd69bc..cd49fa6 100644 --- a/write_files.go +++ b/write_files.go @@ -30,7 +30,7 @@ func parseWriteFiles(document map[string]any, file *ast.File, generated []base.F var files []base.File var runtimeRules strings.Builder var problems ValidationErrors - paths := []string{writeFilesTmpfilesPath} + paths := append([]string{writeFilesTmpfilesPath}, commandReservedPaths...) for _, generatedFile := range generated { paths = append(paths, generatedFile.Path) }