Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 47 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,16 @@ 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.

| cloud-config stanza | Status | Butane output |
| ------------------- | ------ | ------------- |
| `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` |
Expand Down Expand Up @@ -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

Expand All @@ -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/<stanza>.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/<machine-id>/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 <name> <command> [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
Expand Down
113 changes: 113 additions & 0 deletions commands.go
Original file line number Diff line number Diff line change
@@ -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
}
15 changes: 15 additions & 0 deletions commands/butane-init-bootcmd.service
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions commands/butane-init-runcmd.service
Original file line number Diff line number Diff line change
@@ -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
33 changes: 33 additions & 0 deletions commands/cloud-init-per
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading