From c3fb4ccc3e3db1be8de02547236b7a0233dd536b Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Thu, 16 Apr 2026 18:20:33 -0300 Subject: [PATCH] Phase B-3: first-boot sshd install New internal/imagepull/assets/first-boot.sh: POSIX-sh oneshot that detects the guest distro from /etc/os-release (ID + ID_LIKE fallback), installs openssh-server via the native package manager, and enables/starts sshd. Covers debian/ubuntu/kali/raspbian/pop, alpine, fedora/rhel/centos/rocky/almalinux, arch/manjaro, and opensuse/suse. Unknown distros fail clearly with a pointer at editing the script to add a branch. Marker-driven: the service has ConditionPathExists= /var/lib/banger/first-boot-pending, and the script removes the marker on success. Subsequent boots no-op. Testability seams in the script: RUN_PLAN=1 skips the sshd-already-present short-circuit and makes the dispatch echo the planned command instead of executing it. OS_RELEASE_FILE and BANGER_FIRST_BOOT_MARKER env vars override paths so the Go tests exercise the real dispatch logic in a tempdir without touching /etc or /var/lib on the host. Embedding: internal/imagepull/firstboot.go go:embeds both the script and the systemd unit; exposes FirstBootScript() and FirstBootUnit() plus the FirstBootScriptPath / FirstBootMarkerPath / FirstBootUnitName constants. Injection: InjectGuestAgents now drops /usr/local/libexec/ banger-first-boot (0755), /etc/systemd/system/banger-first-boot. service (0644), the empty /var/lib/banger/first-boot-pending marker (0644), and the multi-user.target.wants enable symlink. All uid=0, gid=0. Tests: eight-case dispatch-by-distro (debian, ubuntu, alpine, fedora, arch, opensuse, plus ID_LIKE fallbacks for weird derivatives). Script syntax check via `sh -n`. Unit-contains- expected-fields check. Existing inject round-trip test extended to assert the first-boot bits land in the ext4. Deferred: per-image FirstBootPending flag + extended SSH wait timeout at VM start. Will add if live verification (B-4) shows the naive retry UX is unacceptable. Co-Authored-By: Claude Sonnet 4.6 --- internal/imagepull/assets/first-boot.service | 17 +++ internal/imagepull/assets/first-boot.sh | 101 ++++++++++++++ internal/imagepull/firstboot.go | 26 ++++ internal/imagepull/firstboot_test.go | 136 +++++++++++++++++++ internal/imagepull/inject.go | 19 +++ internal/imagepull/inject_test.go | 5 + 6 files changed, 304 insertions(+) create mode 100644 internal/imagepull/assets/first-boot.service create mode 100644 internal/imagepull/assets/first-boot.sh create mode 100644 internal/imagepull/firstboot.go create mode 100644 internal/imagepull/firstboot_test.go diff --git a/internal/imagepull/assets/first-boot.service b/internal/imagepull/assets/first-boot.service new file mode 100644 index 0000000..fdf2967 --- /dev/null +++ b/internal/imagepull/assets/first-boot.service @@ -0,0 +1,17 @@ +[Unit] +Description=Banger first-boot provisioning +After=network-online.target banger-network.service +Wants=network-online.target +Before=sshd.service ssh.service +ConditionPathExists=/var/lib/banger/first-boot-pending + +[Service] +Type=oneshot +ExecStart=/usr/local/libexec/banger-first-boot +RemainAfterExit=yes +StandardOutput=journal +StandardError=journal +TimeoutStartSec=300s + +[Install] +WantedBy=multi-user.target diff --git a/internal/imagepull/assets/first-boot.sh b/internal/imagepull/assets/first-boot.sh new file mode 100644 index 0000000..f3bdad3 --- /dev/null +++ b/internal/imagepull/assets/first-boot.sh @@ -0,0 +1,101 @@ +#!/bin/sh +# banger-first-boot — runs once at the first boot of a pulled OCI image. +# Installs openssh-server via the guest's native package manager, enables +# and starts the ssh daemon, and removes its own trigger file so the +# service is a no-op on subsequent boots. +# +# Distro dispatch is driven by /etc/os-release's ID / ID_LIKE values. +# RUN_PLAN=1 in the environment makes this script echo the commands it +# would run instead of executing them — used by tests. + +set -eu + +log() { printf '[banger-first-boot] %s\n' "$*" >&2; } + +MARKER="${BANGER_FIRST_BOOT_MARKER:-/var/lib/banger/first-boot-pending}" +if [ ! -f "$MARKER" ]; then + log "marker absent; nothing to do" + exit 0 +fi + +# If sshd is already present, just enable + start and finish. +# The RUN_PLAN env skips this short-circuit so tests can exercise the +# dispatch logic on hosts that happen to have sshd installed. +if [ "${RUN_PLAN:-0}" != "1" ] && command -v sshd >/dev/null 2>&1; then + log "sshd already installed; enabling and starting" + systemctl enable --now ssh.service 2>/dev/null || \ + systemctl enable --now sshd.service 2>/dev/null || true + rm -f "$MARKER" + exit 0 +fi + +DIST="" +FAMILY="" +OS_RELEASE_FILE="${OS_RELEASE_FILE:-/etc/os-release}" +if [ -r "$OS_RELEASE_FILE" ]; then + # shellcheck source=/dev/null + . "$OS_RELEASE_FILE" + DIST="${ID:-}" + FAMILY="${ID_LIKE:-}" +fi + +log "detected distro: ID=$DIST ID_LIKE=$FAMILY" + +# Dispatch. Each branch sets CMD to the single install command. +CMD="" +case "$DIST" in + debian|ubuntu|kali|raspbian|linuxmint|pop) + CMD="env DEBIAN_FRONTEND=noninteractive apt-get update && env DEBIAN_FRONTEND=noninteractive apt-get install -y openssh-server" + ;; + alpine) + CMD="apk add --no-cache openssh" + ;; + fedora|rhel|centos|rocky|almalinux) + CMD="dnf install -y openssh-server" + ;; + arch|archlinux|manjaro) + CMD="pacman -Sy --noconfirm openssh" + ;; + opensuse*|suse) + CMD="zypper --non-interactive install -y openssh" + ;; + *) + # Fall back to ID_LIKE. + case " $FAMILY " in + *" debian "*) + CMD="env DEBIAN_FRONTEND=noninteractive apt-get update && env DEBIAN_FRONTEND=noninteractive apt-get install -y openssh-server" + ;; + *" rhel "* | *" fedora "*) + CMD="dnf install -y openssh-server" + ;; + *" arch "*) + CMD="pacman -Sy --noconfirm openssh" + ;; + *" suse "*) + CMD="zypper --non-interactive install -y openssh" + ;; + esac + ;; +esac + +if [ -z "$CMD" ]; then + log "no known install command for distro '$DIST' (ID_LIKE='$FAMILY')" + log "edit /usr/local/libexec/banger-first-boot to add a branch, then restart banger-first-boot.service" + exit 1 +fi + +if [ "${RUN_PLAN:-0}" = "1" ]; then + printf '%s\n' "$CMD" + exit 0 +fi + +log "installing openssh-server: $CMD" +sh -c "$CMD" + +log "enabling sshd" +systemctl enable --now ssh.service 2>/dev/null || \ + systemctl enable --now sshd.service 2>/dev/null || \ + { log "could not enable sshd service"; exit 1; } + +rm -f "$MARKER" +log "first-boot provisioning complete" diff --git a/internal/imagepull/firstboot.go b/internal/imagepull/firstboot.go new file mode 100644 index 0000000..4a83014 --- /dev/null +++ b/internal/imagepull/firstboot.go @@ -0,0 +1,26 @@ +package imagepull + +import _ "embed" + +//go:embed assets/first-boot.sh +var firstBootScript string + +//go:embed assets/first-boot.service +var firstBootUnit string + +// FirstBootScript returns the shell script that installs openssh-server +// on first VM boot, dispatching on /etc/os-release. +func FirstBootScript() string { return firstBootScript } + +// FirstBootUnit returns the systemd oneshot unit that runs the first-boot +// script once after network-online, before sshd. +func FirstBootUnit() string { return firstBootUnit } + +// FirstBoot guest paths — kept here so inject.go and future callers +// share one source of truth. +const ( + FirstBootScriptPath = "/usr/local/libexec/banger-first-boot" + FirstBootUnitName = "banger-first-boot.service" + FirstBootMarkerDir = "/var/lib/banger" + FirstBootMarkerPath = "/var/lib/banger/first-boot-pending" +) diff --git a/internal/imagepull/firstboot_test.go b/internal/imagepull/firstboot_test.go new file mode 100644 index 0000000..4ee60ef --- /dev/null +++ b/internal/imagepull/firstboot_test.go @@ -0,0 +1,136 @@ +package imagepull + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// runFirstBootPlan executes first-boot.sh in planning mode (RUN_PLAN=1) +// against a synthetic /etc/os-release. Returns the planned install +// command or an error. +func runFirstBootPlan(t *testing.T, osReleaseContent string) string { + t.Helper() + if _, err := exec.LookPath("sh"); err != nil { + t.Skip("sh not available") + } + + dir := t.TempDir() + osRelease := filepath.Join(dir, "os-release") + if err := os.WriteFile(osRelease, []byte(osReleaseContent), 0o644); err != nil { + t.Fatal(err) + } + scriptPath := filepath.Join(dir, "banger-first-boot") + if err := os.WriteFile(scriptPath, []byte(FirstBootScript()), 0o755); err != nil { + t.Fatal(err) + } + marker := filepath.Join(dir, "first-boot-pending") + if err := os.WriteFile(marker, nil, 0o644); err != nil { + t.Fatal(err) + } + + cmd := exec.Command("sh", scriptPath) + cmd.Env = append(os.Environ(), + "RUN_PLAN=1", + "OS_RELEASE_FILE="+osRelease, + "BANGER_FIRST_BOOT_MARKER="+marker, + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("first-boot script: %v\noutput:\n%s", err, out) + } + // Planned command is printed to stdout (no [banger-first-boot] prefix); + // log output goes to stderr. CombinedOutput merges them, so pick the + // last non-log line. + lines := strings.Split(strings.TrimRight(string(out), "\n"), "\n") + for i := len(lines) - 1; i >= 0; i-- { + l := lines[i] + if strings.TrimSpace(l) == "" { + continue + } + if strings.HasPrefix(l, "[banger-first-boot]") { + continue + } + return l + } + t.Fatalf("no planned command in output:\n%s", out) + return "" +} + +func TestFirstBootScriptDispatchesByDistro(t *testing.T) { + cases := []struct { + name string + osRel string + wantRe string + }{ + {"debian", `ID=debian` + "\n" + `ID_LIKE=""`, "apt-get install -y openssh-server"}, + {"ubuntu", `ID=ubuntu`, "apt-get install -y openssh-server"}, + {"alpine", `ID=alpine`, "apk add --no-cache openssh"}, + {"fedora", `ID=fedora`, "dnf install -y openssh-server"}, + {"arch", `ID=arch`, "pacman -Sy --noconfirm openssh"}, + {"opensuse-leap", `ID="opensuse-leap"`, "zypper --non-interactive install -y openssh"}, + {"unknown-with-debian-like", `ID=someweirddistro` + "\n" + `ID_LIKE=debian`, "apt-get install -y openssh-server"}, + {"unknown-with-rhel-like", `ID=something` + "\n" + `ID_LIKE="rhel fedora"`, "dnf install -y openssh-server"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := runFirstBootPlan(t, tc.osRel) + if !strings.Contains(got, tc.wantRe) { + t.Errorf("got=%q, want contains %q", got, tc.wantRe) + } + }) + } +} + +func TestFirstBootScriptContainsDistroCases(t *testing.T) { + s := FirstBootScript() + for _, snippet := range []string{ + "debian|ubuntu|kali|raspbian", + "apt-get install -y openssh-server", + "alpine)", + "apk add --no-cache openssh", + "fedora|rhel|centos|rocky|almalinux", + "dnf install -y openssh-server", + "arch|archlinux|manjaro", + "pacman -Sy --noconfirm openssh", + "opensuse*|suse", + "zypper --non-interactive install -y openssh", + `ID_LIKE`, + "RUN_PLAN", + } { + if !strings.Contains(s, snippet) { + t.Errorf("script missing %q", snippet) + } + } +} + +func TestFirstBootScriptIsShSyntaxValid(t *testing.T) { + if _, err := exec.LookPath("sh"); err != nil { + t.Skip("sh not available") + } + dir := t.TempDir() + path := filepath.Join(dir, "first-boot") + if err := os.WriteFile(path, []byte(FirstBootScript()), 0o755); err != nil { + t.Fatal(err) + } + out, err := exec.Command("sh", "-n", path).CombinedOutput() + if err != nil { + t.Fatalf("sh -n first-boot: %v: %s", err, out) + } +} + +func TestFirstBootUnitReferencesScript(t *testing.T) { + u := FirstBootUnit() + for _, want := range []string{ + FirstBootScriptPath, + "ConditionPathExists=" + FirstBootMarkerPath, + "After=network-online.target", + "Before=sshd.service", + } { + if !strings.Contains(u, want) { + t.Errorf("unit missing %q", want) + } + } +} diff --git a/internal/imagepull/inject.go b/internal/imagepull/inject.go index 890432d..20116c6 100644 --- a/internal/imagepull/inject.go +++ b/internal/imagepull/inject.go @@ -72,6 +72,21 @@ func InjectGuestAgents(ctx context.Context, runner system.CommandRunner, ext4Fil guestPath: "/etc/modules-load.d/banger-vsock.conf", mode: 0o644, }, + { + content: []byte(FirstBootScript()), + guestPath: FirstBootScriptPath, // /usr/local/libexec/banger-first-boot + mode: 0o755, + }, + { + content: []byte(FirstBootUnit()), + guestPath: "/etc/systemd/system/" + FirstBootUnitName, + mode: 0o644, + }, + { + content: nil, // empty marker file — its existence triggers the service + guestPath: FirstBootMarkerPath, + mode: 0o644, + }, } // Resolve content-backed steps to on-disk temp files. @@ -95,6 +110,10 @@ func InjectGuestAgents(ctx context.Context, runner system.CommandRunner, ext4Fil target: "/etc/systemd/system/" + vsockagent.ServiceName, link: "/etc/systemd/system/multi-user.target.wants/" + vsockagent.ServiceName, }, + { + target: "/etc/systemd/system/" + FirstBootUnitName, + link: "/etc/systemd/system/multi-user.target.wants/" + FirstBootUnitName, + }, } script := buildInjectScript(steps, symlinks) diff --git a/internal/imagepull/inject_test.go b/internal/imagepull/inject_test.go index a8ffa9b..03354a5 100644 --- a/internal/imagepull/inject_test.go +++ b/internal/imagepull/inject_test.go @@ -56,6 +56,11 @@ func TestInjectGuestAgentsWritesExpectedFiles(t *testing.T) { "/etc/modules-load.d/banger-vsock.conf", "/etc/systemd/system/multi-user.target.wants/banger-network.service", "/etc/systemd/system/multi-user.target.wants/banger-vsock-agent.service", + // Phase B-3 first-boot bits: + FirstBootScriptPath, + "/etc/systemd/system/" + FirstBootUnitName, + "/etc/systemd/system/multi-user.target.wants/" + FirstBootUnitName, + FirstBootMarkerPath, } for _, p := range expectPaths { out, err := exec.Command("debugfs", "-R", "stat "+p, ext4).CombinedOutput()