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