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>
This commit is contained in:
Thales Maciel 2026-04-16 18:08:56 -03:00
parent 43982a4ae3
commit 491c8e1ebb
No known key found for this signature in database
GPG key ID: 33112E6833C34679
5 changed files with 393 additions and 18 deletions

View file

@ -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)

View file

@ -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]+`)

View file

@ -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",

View file

@ -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 ""
}

View file

@ -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])
}
}
}