banger/internal/imagepull/inject_test.go
Thales Maciel 491c8e1ebb
Phase B-2: pre-inject banger guest agents into pulled rootfs
New imagepull.InjectGuestAgents writes banger's guest-side assets
straight into the pulled ext4 so systemd will start them at first boot:

  /usr/local/bin/banger-vsock-agent             (binary, 0755)
  /usr/local/libexec/banger-network-bootstrap   (script, 0755)
  /etc/systemd/system/banger-network.service    (unit, 0644)
  /etc/systemd/system/banger-vsock-agent.service (unit, 0644)
  /etc/modules-load.d/banger-vsock.conf         (modules, 0644)

  plus enable-at-boot symlinks under
  /etc/systemd/system/multi-user.target.wants/

All writes + ownership + symlinks go through one `debugfs -w -f -`
invocation. No sudo required because the caller owns the ext4 file.
Script is deterministic: shallow-first mkdir, then write, then sif,
then symlink. "File exists" errors from mkdir on already-present
dirs are tolerated (debugfs keeps going past them with -f, and we
filter them out of the output scan).

Asset content reuses the existing guestnet.BootstrapScript /
SystemdServiceUnit / ConfigPath and vsockagent.ServiceUnit /
ModulesLoadConfig / GuestInstallPath — one source of truth, no
duplicated systemd unit strings.

Daemon wiring: new d.finalizePulledRootfs seam runs both
ApplyOwnership (B-1) and InjectGuestAgents as one phase between
BuildExt4 and StageBootArtifacts. The companion vsock-agent binary
is resolved via paths.CompanionBinaryPath. Existing daemon tests
stub the seam with a no-op to avoid needing a real companion
binary + debugfs in the test harness.

Tests: real-ext4 round-trip that builds a minimal ext4, runs
InjectGuestAgents, then verifies every expected path is present
via `debugfs stat`, plus uid=0 and mode 0755 on the vsock-agent
binary. Also: missing-binary rejection, ancestor-collection order
test. debugfs/mkfs.ext4 tests skip on hosts without the binaries.

After B-1+B-2, any OCI image that already ships sshd boots with
banger-network and banger-vsock-agent running; image pull is
one step from "useful rootfs primitive". B-3 (first-boot sshd
install) unlocks images that don't ship sshd.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 18:08:56 -03:00

111 lines
3.5 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",
}
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])
}
}
}