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 <noreply@anthropic.com>
This commit is contained in:
parent
491c8e1ebb
commit
c3fb4ccc3e
6 changed files with 304 additions and 0 deletions
17
internal/imagepull/assets/first-boot.service
Normal file
17
internal/imagepull/assets/first-boot.service
Normal file
|
|
@ -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
|
||||
101
internal/imagepull/assets/first-boot.sh
Normal file
101
internal/imagepull/assets/first-boot.sh
Normal file
|
|
@ -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"
|
||||
26
internal/imagepull/firstboot.go
Normal file
26
internal/imagepull/firstboot.go
Normal file
|
|
@ -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"
|
||||
)
|
||||
136
internal/imagepull/firstboot_test.go
Normal file
136
internal/imagepull/firstboot_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue