From 491c8e1ebb2ba84ece34e4c9d8a7c9a58b616475 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Thu, 16 Apr 2026 18:08:56 -0300 Subject: [PATCH] Phase B-2: pre-inject banger guest agents into pulled rootfs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/daemon/daemon.go | 1 + internal/daemon/images_pull.go | 28 +++- internal/daemon/images_pull_test.go | 42 +++-- internal/imagepull/inject.go | 229 ++++++++++++++++++++++++++++ internal/imagepull/inject_test.go | 111 ++++++++++++++ 5 files changed, 393 insertions(+), 18 deletions(-) create mode 100644 internal/imagepull/inject.go create mode 100644 internal/imagepull/inject_test.go diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 59b9c4a..a7e35b4 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -52,6 +52,7 @@ type Daemon struct { vmCaps []vmCapability imageBuild func(context.Context, imageBuildSpec) error pullAndFlatten func(ctx context.Context, ref, cacheDir, destDir string) (imagepull.Metadata, error) + finalizePulledRootfs func(ctx context.Context, ext4File string, meta imagepull.Metadata) error requestHandler func(context.Context, rpc.Request) rpc.Response guestWaitForSSH func(context.Context, string, string, time.Duration) error guestDial func(context.Context, string, string) (guestSSHClient, error) diff --git a/internal/daemon/images_pull.go b/internal/daemon/images_pull.go index 0e1a3de..c19cbcd 100644 --- a/internal/daemon/images_pull.go +++ b/internal/daemon/images_pull.go @@ -14,6 +14,7 @@ import ( "banger/internal/daemon/imagemgr" "banger/internal/imagepull" "banger/internal/model" + "banger/internal/paths" "github.com/google/go-containerregistry/pkg/name" ) @@ -107,8 +108,8 @@ func (d *Daemon) PullImage(ctx context.Context, params api.ImagePullParams) (ima if err := imagepull.BuildExt4(ctx, d.runner, rootfsTree, rootfsExt4, sizeBytes); err != nil { return model.Image{}, fmt.Errorf("build rootfs ext4: %w", err) } - if err := imagepull.ApplyOwnership(ctx, d.runner, rootfsExt4, meta); err != nil { - return model.Image{}, fmt.Errorf("apply ownership: %w", err) + if err := d.runFinalizePulledRootfs(ctx, rootfsExt4, meta); err != nil { + return model.Image{}, err } stagedKernel, stagedInitrd, stagedModules, err := imagemgr.StageBootArtifacts(ctx, d.runner, stagingDir, kernelPath, initrdPath, modulesDir) @@ -153,6 +154,29 @@ func (d *Daemon) runPullAndFlatten(ctx context.Context, ref, cacheDir, destDir s return imagepull.Flatten(ctx, pulled, destDir) } +// runFinalizePulledRootfs applies ownership fixup and injects banger's +// guest agents. Tests substitute via d.finalizePulledRootfs; nil → +// real implementation using debugfs + the companion vsock-agent +// binary resolved via paths.CompanionBinaryPath. +func (d *Daemon) runFinalizePulledRootfs(ctx context.Context, ext4File string, meta imagepull.Metadata) error { + if d.finalizePulledRootfs != nil { + return d.finalizePulledRootfs(ctx, ext4File, meta) + } + if err := imagepull.ApplyOwnership(ctx, d.runner, ext4File, meta); err != nil { + return fmt.Errorf("apply ownership: %w", err) + } + vsockBin, err := paths.CompanionBinaryPath("banger-vsock-agent") + if err != nil { + return fmt.Errorf("locate vsock agent binary: %w", err) + } + if err := imagepull.InjectGuestAgents(ctx, d.runner, ext4File, imagepull.GuestAgentAssets{ + VsockAgentBin: vsockBin, + }); err != nil { + return fmt.Errorf("inject guest agents: %w", err) + } + return nil +} + // nameSanitize keeps lowercase alphanumerics + hyphens, collapses runs. var nameSanitizeRE = regexp.MustCompile(`[^a-z0-9]+`) diff --git a/internal/daemon/images_pull_test.go b/internal/daemon/images_pull_test.go index 4c2455b..6d89631 100644 --- a/internal/daemon/images_pull_test.go +++ b/internal/daemon/images_pull_test.go @@ -39,6 +39,12 @@ func writeFakeKernelTriple(t *testing.T) (kernelPath, initrdPath, modulesDir str return } +// stubFinalizePulledRootfs is a no-op seam substitute that skips the real +// debugfs + vsock-agent-binary injection machinery during daemon tests. +func stubFinalizePulledRootfs(_ context.Context, _ string, _ imagepull.Metadata) error { + return nil +} + // stubPullAndFlatten writes a fixed file tree into destDir, simulating a // successful OCI pull without the network or tarball machinery. func stubPullAndFlatten(_ context.Context, _ string, _ string, destDir string) (imagepull.Metadata, error) { @@ -65,10 +71,11 @@ func TestPullImageHappyPath(t *testing.T) { kernel, initrd, modules := writeFakeKernelTriple(t) d := &Daemon{ - layout: paths.Layout{ImagesDir: imagesDir, OCICacheDir: cacheDir}, - store: openDaemonStore(t), - runner: system.NewRunner(), - pullAndFlatten: stubPullAndFlatten, + layout: paths.Layout{ImagesDir: imagesDir, OCICacheDir: cacheDir}, + store: openDaemonStore(t), + runner: system.NewRunner(), + pullAndFlatten: stubPullAndFlatten, + finalizePulledRootfs: stubFinalizePulledRootfs, } image, err := d.PullImage(context.Background(), api.ImagePullParams{ @@ -109,10 +116,11 @@ func TestPullImageRejectsExistingName(t *testing.T) { kernel, _, _ := writeFakeKernelTriple(t) d := &Daemon{ - layout: paths.Layout{ImagesDir: imagesDir, OCICacheDir: t.TempDir()}, - store: openDaemonStore(t), - runner: system.NewRunner(), - pullAndFlatten: stubPullAndFlatten, + layout: paths.Layout{ImagesDir: imagesDir, OCICacheDir: t.TempDir()}, + store: openDaemonStore(t), + runner: system.NewRunner(), + pullAndFlatten: stubPullAndFlatten, + finalizePulledRootfs: stubFinalizePulledRootfs, } // Seed a preexisting image with the would-be derived name. id, _ := model.NewID() @@ -136,10 +144,11 @@ func TestPullImageRejectsExistingName(t *testing.T) { func TestPullImageRequiresKernel(t *testing.T) { d := &Daemon{ - layout: paths.Layout{ImagesDir: t.TempDir(), OCICacheDir: t.TempDir()}, - store: openDaemonStore(t), - runner: system.NewRunner(), - pullAndFlatten: stubPullAndFlatten, + layout: paths.Layout{ImagesDir: t.TempDir(), OCICacheDir: t.TempDir()}, + store: openDaemonStore(t), + runner: system.NewRunner(), + pullAndFlatten: stubPullAndFlatten, + finalizePulledRootfs: stubFinalizePulledRootfs, } _, err := d.PullImage(context.Background(), api.ImagePullParams{ Ref: "docker.io/library/debian:bookworm", @@ -157,10 +166,11 @@ func TestPullImageCleansStagingOnFailure(t *testing.T) { } d := &Daemon{ - layout: paths.Layout{ImagesDir: imagesDir, OCICacheDir: t.TempDir()}, - store: openDaemonStore(t), - runner: system.NewRunner(), - pullAndFlatten: failureSeam, + layout: paths.Layout{ImagesDir: imagesDir, OCICacheDir: t.TempDir()}, + store: openDaemonStore(t), + runner: system.NewRunner(), + pullAndFlatten: failureSeam, + finalizePulledRootfs: stubFinalizePulledRootfs, } _, err := d.PullImage(context.Background(), api.ImagePullParams{ Ref: "docker.io/library/debian:bookworm", diff --git a/internal/imagepull/inject.go b/internal/imagepull/inject.go new file mode 100644 index 0000000..890432d --- /dev/null +++ b/internal/imagepull/inject.go @@ -0,0 +1,229 @@ +package imagepull + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "banger/internal/guestnet" + "banger/internal/system" + "banger/internal/vsockagent" +) + +// GuestAgentAssets bundles everything the guest side of banger needs in a +// rootfs that doesn't already have it. Callers (the daemon's PullImage) +// resolve the vsock-agent binary path via paths.CompanionBinaryPath and +// hand it in; the rest comes from the respective asset packages. +type GuestAgentAssets struct { + VsockAgentBin string // absolute path on the host, copied verbatim +} + +// InjectGuestAgents writes banger's guest-side assets (vsock agent +// binary + systemd unit, network bootstrap script + unit, vsock modules- +// load config, symlinks that enable the units at boot) into ext4File. +// All entries land with uid=0, gid=0 and appropriate modes. +// +// Runs in one debugfs -w invocation: dirs, files, sif (uid/gid/mode), +// and symlinks all in one scripted batch. No sudo required because the +// ext4 is owned by the runner. +func InjectGuestAgents(ctx context.Context, runner system.CommandRunner, ext4File string, assets GuestAgentAssets) error { + if assets.VsockAgentBin == "" { + return fmt.Errorf("vsock-agent binary path is required") + } + if _, err := os.Stat(assets.VsockAgentBin); err != nil { + return fmt.Errorf("vsock-agent binary %q missing: %w", assets.VsockAgentBin, err) + } + + // Stage content blobs as temp files so debugfs `write` can pick + // them up. All other commands (mkdir/sif/symlink) are inline. + stage, err := os.MkdirTemp("", "banger-inject-") + if err != nil { + return err + } + defer os.RemoveAll(stage) + + steps := []injectFile{ + { + hostSrc: assets.VsockAgentBin, + guestPath: vsockagent.GuestInstallPath, // /usr/local/bin/banger-vsock-agent + mode: 0o755, + }, + { + content: []byte(guestnet.BootstrapScript()), + guestPath: guestnet.GuestScriptPath, // /usr/local/libexec/banger-network-bootstrap + mode: 0o755, + }, + { + content: []byte(guestnet.SystemdServiceUnit()), + guestPath: "/etc/systemd/system/" + guestnet.SystemdServiceName, // banger-network.service + mode: 0o644, + }, + { + content: []byte(vsockagent.ServiceUnit()), + guestPath: "/etc/systemd/system/" + vsockagent.ServiceName, // banger-vsock-agent.service + mode: 0o644, + }, + { + content: []byte(vsockagent.ModulesLoadConfig()), + guestPath: "/etc/modules-load.d/banger-vsock.conf", + mode: 0o644, + }, + } + + // Resolve content-backed steps to on-disk temp files. + for i := range steps { + if steps[i].hostSrc != "" { + continue + } + tmp := filepath.Join(stage, fmt.Sprintf("blob-%d", i)) + if err := os.WriteFile(tmp, steps[i].content, 0o644); err != nil { + return err + } + steps[i].hostSrc = tmp + } + + symlinks := []injectSymlink{ + { + target: "/etc/systemd/system/" + guestnet.SystemdServiceName, + link: "/etc/systemd/system/multi-user.target.wants/" + guestnet.SystemdServiceName, + }, + { + target: "/etc/systemd/system/" + vsockagent.ServiceName, + link: "/etc/systemd/system/multi-user.target.wants/" + vsockagent.ServiceName, + }, + } + + script := buildInjectScript(steps, symlinks) + + stdinRunner, ok := runner.(system.StdinRunner) + if !ok { + return fmt.Errorf("inject requires a runner that supports stdin (got %T)", runner) + } + out, err := stdinRunner.RunStdin(ctx, script, "debugfs", "-w", "-f", "-", ext4File) + if err != nil { + return fmt.Errorf("debugfs inject: %w: %s", err, string(out)) + } + // Scan output for hard errors — debugfs keeps going past errors + // with -f, so we need to look at stdout/stderr-as-stdout for bad + // signs. mkdir errors on already-present dirs are expected; we + // ignore "File exists" and "Is a directory". Other errors bubble. + if bad := scanInjectOutput(out); bad != "" { + return fmt.Errorf("debugfs inject: %s", bad) + } + return nil +} + +type injectFile struct { + content []byte + hostSrc string // set by InjectGuestAgents after staging + guestPath string + mode uint32 // perm bits; type bits added by buildInjectScript +} + +type injectSymlink struct { + target string + link string +} + +// buildInjectScript emits the debugfs command stream. +func buildInjectScript(files []injectFile, symlinks []injectSymlink) *bytes.Buffer { + var buf bytes.Buffer + + // Create every ancestor directory of every file/symlink path. mkdir + // on an already-existing dir is benign (debugfs continues past the + // error), but we prune duplicates to keep the script clean. + dirs := collectAncestors(files, symlinks) + for _, d := range dirs { + fmt.Fprintf(&buf, "mkdir %s\n", d) + } + + // Write each file content. + for _, f := range files { + fmt.Fprintf(&buf, "write %s %s\n", f.hostSrc, f.guestPath) + } + + // Fix ownership + mode on every written file (uid=0, gid=0). + for _, f := range files { + fmt.Fprintf(&buf, "set_inode_field %s uid 0\n", f.guestPath) + fmt.Fprintf(&buf, "set_inode_field %s gid 0\n", f.guestPath) + fmt.Fprintf(&buf, "set_inode_field %s mode 0%o\n", f.guestPath, 0o100000|f.mode) + } + + // Fix dir ownership. Don't touch modes — mkdir's default 0755 is fine. + for _, d := range dirs { + fmt.Fprintf(&buf, "set_inode_field %s uid 0\n", d) + fmt.Fprintf(&buf, "set_inode_field %s gid 0\n", d) + } + + // Finally, create the enable-at-boot symlinks. + for _, s := range symlinks { + fmt.Fprintf(&buf, "symlink %s %s\n", s.link, s.target) + } + + return &buf +} + +// collectAncestors walks every file + symlink path and returns the unique +// set of parent directories, sorted shallowest first so mkdir ordering +// is valid. +func collectAncestors(files []injectFile, symlinks []injectSymlink) []string { + set := map[string]struct{}{} + add := func(p string) { + dir := filepath.Dir(p) + for dir != "" && dir != "/" { + set[dir] = struct{}{} + dir = filepath.Dir(dir) + } + } + for _, f := range files { + add(f.guestPath) + } + for _, s := range symlinks { + add(s.link) + } + out := make([]string, 0, len(set)) + for d := range set { + out = append(out, d) + } + // Shallow-first by depth, then lexicographic. + sort.Slice(out, func(i, j int) bool { + di := strings.Count(out[i], "/") + dj := strings.Count(out[j], "/") + if di != dj { + return di < dj + } + return out[i] < out[j] + }) + return out +} + +// scanInjectOutput returns a non-empty string if debugfs reported an +// error that's not a benign "File exists" from mkdir on an already- +// present directory. Debugfs emits errors on stderr AND stdout (which +// we capture together); we look for known failure signatures. +func scanInjectOutput(out []byte) string { + lines := strings.Split(string(out), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // Benign: mkdir on existing dir. + if strings.Contains(line, "File exists") { + continue + } + // Failure signatures we care about. + if strings.Contains(line, "error writing file") || + strings.Contains(line, "couldn't find") || + strings.Contains(line, "No such file") || + strings.Contains(line, "Unrecognized command") || + strings.Contains(line, "symlink:") { + return line + } + } + return "" +} diff --git a/internal/imagepull/inject_test.go b/internal/imagepull/inject_test.go new file mode 100644 index 0000000..a8ffa9b --- /dev/null +++ b/internal/imagepull/inject_test.go @@ -0,0 +1,111 @@ +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]) + } + } +}