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>
116 lines
3.7 KiB
Go
116 lines
3.7 KiB
Go
package imagepull
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"banger/internal/system"
|
|
)
|
|
|
|
func TestInjectGuestAgentsWritesExpectedFiles(t *testing.T) {
|
|
if _, err := exec.LookPath("mkfs.ext4"); err != nil {
|
|
t.Skip("mkfs.ext4 not available; skipping")
|
|
}
|
|
if _, err := exec.LookPath("debugfs"); err != nil {
|
|
t.Skip("debugfs not available; skipping")
|
|
}
|
|
|
|
// Build a bare ext4 from an empty (but non-empty-dir) source so
|
|
// debugfs has a valid filesystem to inject into. mkfs.ext4 -d
|
|
// wants the source dir itself to contain at least something.
|
|
src := t.TempDir()
|
|
if err := os.MkdirAll(filepath.Join(src, "usr"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(src, "etc"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ext4 := filepath.Join(t.TempDir(), "rootfs.ext4")
|
|
if err := BuildExt4(context.Background(), system.NewRunner(), src, ext4, MinExt4Size); err != nil {
|
|
t.Fatalf("BuildExt4: %v", err)
|
|
}
|
|
|
|
// Fake vsock-agent binary content — InjectGuestAgents copies bytes
|
|
// verbatim so any file passes as a stand-in.
|
|
fakeAgent := filepath.Join(t.TempDir(), "banger-vsock-agent")
|
|
if err := os.WriteFile(fakeAgent, []byte("#!/bin/true\n"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := InjectGuestAgents(context.Background(), system.NewRunner(), ext4, GuestAgentAssets{
|
|
VsockAgentBin: fakeAgent,
|
|
}); err != nil {
|
|
t.Fatalf("InjectGuestAgents: %v", err)
|
|
}
|
|
|
|
// Verify each expected path is present via debugfs stat.
|
|
expectPaths := []string{
|
|
"/usr/local/bin/banger-vsock-agent",
|
|
"/usr/local/libexec/banger-network-bootstrap",
|
|
"/etc/systemd/system/banger-network.service",
|
|
"/etc/systemd/system/banger-vsock-agent.service",
|
|
"/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()
|
|
if err != nil {
|
|
t.Errorf("debugfs stat %s: %v: %s", p, err, out)
|
|
continue
|
|
}
|
|
if strings.Contains(string(out), "couldn't find file") || strings.Contains(string(out), "File not found") {
|
|
t.Errorf("path missing: %s\noutput:\n%s", p, out)
|
|
}
|
|
}
|
|
|
|
// Verify ownership on one file (uid=0).
|
|
statOut, err := exec.Command("debugfs", "-R", "stat /usr/local/bin/banger-vsock-agent", ext4).CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("debugfs stat agent: %v: %s", err, statOut)
|
|
}
|
|
s := string(statOut)
|
|
if !strings.Contains(s, "User: 0") && !strings.Contains(s, "User: 0") {
|
|
t.Errorf("vsock-agent binary not uid=0:\n%s", s)
|
|
}
|
|
if !strings.Contains(s, "Mode: 0755") && !strings.Contains(s, "Mode: 100755") {
|
|
t.Errorf("vsock-agent binary mode not 0755:\n%s", s)
|
|
}
|
|
}
|
|
|
|
func TestInjectGuestAgentsRequiresVsockAgentBinary(t *testing.T) {
|
|
err := InjectGuestAgents(context.Background(), system.NewRunner(), "/tmp/nonexistent.ext4", GuestAgentAssets{
|
|
VsockAgentBin: "",
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "required") {
|
|
t.Fatalf("expected missing-binary error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCollectAncestorsIsShallowFirst(t *testing.T) {
|
|
files := []injectFile{
|
|
{guestPath: "/a/b/c/file"},
|
|
}
|
|
symlinks := []injectSymlink{
|
|
{link: "/x/y/z/link"},
|
|
}
|
|
got := collectAncestors(files, symlinks)
|
|
want := []string{"/a", "/x", "/a/b", "/x/y", "/a/b/c", "/x/y/z"}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("len got=%d want=%d: %v", len(got), len(want), got)
|
|
}
|
|
for i, g := range got {
|
|
if g != want[i] {
|
|
t.Errorf("index %d: got %q want %q", i, g, want[i])
|
|
}
|
|
}
|
|
}
|