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:
parent
43982a4ae3
commit
491c8e1ebb
5 changed files with 393 additions and 18 deletions
|
|
@ -52,6 +52,7 @@ type Daemon struct {
|
||||||
vmCaps []vmCapability
|
vmCaps []vmCapability
|
||||||
imageBuild func(context.Context, imageBuildSpec) error
|
imageBuild func(context.Context, imageBuildSpec) error
|
||||||
pullAndFlatten func(ctx context.Context, ref, cacheDir, destDir string) (imagepull.Metadata, 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
|
requestHandler func(context.Context, rpc.Request) rpc.Response
|
||||||
guestWaitForSSH func(context.Context, string, string, time.Duration) error
|
guestWaitForSSH func(context.Context, string, string, time.Duration) error
|
||||||
guestDial func(context.Context, string, string) (guestSSHClient, error)
|
guestDial func(context.Context, string, string) (guestSSHClient, error)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import (
|
||||||
"banger/internal/daemon/imagemgr"
|
"banger/internal/daemon/imagemgr"
|
||||||
"banger/internal/imagepull"
|
"banger/internal/imagepull"
|
||||||
"banger/internal/model"
|
"banger/internal/model"
|
||||||
|
"banger/internal/paths"
|
||||||
|
|
||||||
"github.com/google/go-containerregistry/pkg/name"
|
"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 {
|
if err := imagepull.BuildExt4(ctx, d.runner, rootfsTree, rootfsExt4, sizeBytes); err != nil {
|
||||||
return model.Image{}, fmt.Errorf("build rootfs ext4: %w", err)
|
return model.Image{}, fmt.Errorf("build rootfs ext4: %w", err)
|
||||||
}
|
}
|
||||||
if err := imagepull.ApplyOwnership(ctx, d.runner, rootfsExt4, meta); err != nil {
|
if err := d.runFinalizePulledRootfs(ctx, rootfsExt4, meta); err != nil {
|
||||||
return model.Image{}, fmt.Errorf("apply ownership: %w", err)
|
return model.Image{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
stagedKernel, stagedInitrd, stagedModules, err := imagemgr.StageBootArtifacts(ctx, d.runner, stagingDir, kernelPath, initrdPath, modulesDir)
|
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)
|
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.
|
// nameSanitize keeps lowercase alphanumerics + hyphens, collapses runs.
|
||||||
var nameSanitizeRE = regexp.MustCompile(`[^a-z0-9]+`)
|
var nameSanitizeRE = regexp.MustCompile(`[^a-z0-9]+`)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,12 @@ func writeFakeKernelTriple(t *testing.T) (kernelPath, initrdPath, modulesDir str
|
||||||
return
|
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
|
// stubPullAndFlatten writes a fixed file tree into destDir, simulating a
|
||||||
// successful OCI pull without the network or tarball machinery.
|
// successful OCI pull without the network or tarball machinery.
|
||||||
func stubPullAndFlatten(_ context.Context, _ string, _ string, destDir string) (imagepull.Metadata, error) {
|
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)
|
kernel, initrd, modules := writeFakeKernelTriple(t)
|
||||||
|
|
||||||
d := &Daemon{
|
d := &Daemon{
|
||||||
layout: paths.Layout{ImagesDir: imagesDir, OCICacheDir: cacheDir},
|
layout: paths.Layout{ImagesDir: imagesDir, OCICacheDir: cacheDir},
|
||||||
store: openDaemonStore(t),
|
store: openDaemonStore(t),
|
||||||
runner: system.NewRunner(),
|
runner: system.NewRunner(),
|
||||||
pullAndFlatten: stubPullAndFlatten,
|
pullAndFlatten: stubPullAndFlatten,
|
||||||
|
finalizePulledRootfs: stubFinalizePulledRootfs,
|
||||||
}
|
}
|
||||||
|
|
||||||
image, err := d.PullImage(context.Background(), api.ImagePullParams{
|
image, err := d.PullImage(context.Background(), api.ImagePullParams{
|
||||||
|
|
@ -109,10 +116,11 @@ func TestPullImageRejectsExistingName(t *testing.T) {
|
||||||
kernel, _, _ := writeFakeKernelTriple(t)
|
kernel, _, _ := writeFakeKernelTriple(t)
|
||||||
|
|
||||||
d := &Daemon{
|
d := &Daemon{
|
||||||
layout: paths.Layout{ImagesDir: imagesDir, OCICacheDir: t.TempDir()},
|
layout: paths.Layout{ImagesDir: imagesDir, OCICacheDir: t.TempDir()},
|
||||||
store: openDaemonStore(t),
|
store: openDaemonStore(t),
|
||||||
runner: system.NewRunner(),
|
runner: system.NewRunner(),
|
||||||
pullAndFlatten: stubPullAndFlatten,
|
pullAndFlatten: stubPullAndFlatten,
|
||||||
|
finalizePulledRootfs: stubFinalizePulledRootfs,
|
||||||
}
|
}
|
||||||
// Seed a preexisting image with the would-be derived name.
|
// Seed a preexisting image with the would-be derived name.
|
||||||
id, _ := model.NewID()
|
id, _ := model.NewID()
|
||||||
|
|
@ -136,10 +144,11 @@ func TestPullImageRejectsExistingName(t *testing.T) {
|
||||||
|
|
||||||
func TestPullImageRequiresKernel(t *testing.T) {
|
func TestPullImageRequiresKernel(t *testing.T) {
|
||||||
d := &Daemon{
|
d := &Daemon{
|
||||||
layout: paths.Layout{ImagesDir: t.TempDir(), OCICacheDir: t.TempDir()},
|
layout: paths.Layout{ImagesDir: t.TempDir(), OCICacheDir: t.TempDir()},
|
||||||
store: openDaemonStore(t),
|
store: openDaemonStore(t),
|
||||||
runner: system.NewRunner(),
|
runner: system.NewRunner(),
|
||||||
pullAndFlatten: stubPullAndFlatten,
|
pullAndFlatten: stubPullAndFlatten,
|
||||||
|
finalizePulledRootfs: stubFinalizePulledRootfs,
|
||||||
}
|
}
|
||||||
_, err := d.PullImage(context.Background(), api.ImagePullParams{
|
_, err := d.PullImage(context.Background(), api.ImagePullParams{
|
||||||
Ref: "docker.io/library/debian:bookworm",
|
Ref: "docker.io/library/debian:bookworm",
|
||||||
|
|
@ -157,10 +166,11 @@ func TestPullImageCleansStagingOnFailure(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
d := &Daemon{
|
d := &Daemon{
|
||||||
layout: paths.Layout{ImagesDir: imagesDir, OCICacheDir: t.TempDir()},
|
layout: paths.Layout{ImagesDir: imagesDir, OCICacheDir: t.TempDir()},
|
||||||
store: openDaemonStore(t),
|
store: openDaemonStore(t),
|
||||||
runner: system.NewRunner(),
|
runner: system.NewRunner(),
|
||||||
pullAndFlatten: failureSeam,
|
pullAndFlatten: failureSeam,
|
||||||
|
finalizePulledRootfs: stubFinalizePulledRootfs,
|
||||||
}
|
}
|
||||||
_, err := d.PullImage(context.Background(), api.ImagePullParams{
|
_, err := d.PullImage(context.Background(), api.ImagePullParams{
|
||||||
Ref: "docker.io/library/debian:bookworm",
|
Ref: "docker.io/library/debian:bookworm",
|
||||||
|
|
|
||||||
229
internal/imagepull/inject.go
Normal file
229
internal/imagepull/inject.go
Normal 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 ""
|
||||||
|
}
|
||||||
111
internal/imagepull/inject_test.go
Normal file
111
internal/imagepull/inject_test.go
Normal 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue