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 <noreply@anthropic.com>
136 lines
3.9 KiB
Go
136 lines
3.9 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|