Before this change `banger image pull` (both OCI-direct and bundle
paths) shipped images with an empty WorkSeedPath — the BuildWorkSeedImage
helper existed only behind the hidden `banger internal work-seed` CLI.
Every pulled image hit ensureWorkDisk's no-seed branch, and the guest
booted with a bare /root (no .bashrc, no .profile, none of the distro
defaults).
Pull now calls BuildWorkSeedImage after the rootfs is finalised (OCI)
or fetched (bundle). The builder is behind a new `workSeedBuilder` test
seam so existing pull tests don't accidentally demand sudo mount. The
build failure is non-fatal: any error logs a warning and leaves
WorkSeedPath empty — images stay publishable even if the pulled rootfs
has no /root to extract.
Verified end-to-end by wiping the cached smoke image and re-pulling:
work-seed.ext4 lands in the artifact dir next to rootfs.ext4, and all
21 smoke scenarios pass.
Also refreshes the "feature /root work disk" fallback tooling check —
the no-seed path no longer touches mount/umount/cp after commit
0e28504, so the doctor check now only requires truncate + mkfs.ext4.
The warn copy updates from "new VM creates will be slower" to "guest
/root will be empty", which matches the actual tradeoff post-refactor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
244 lines
7.5 KiB
Go
244 lines
7.5 KiB
Go
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"banger/internal/api"
|
|
"banger/internal/imagepull"
|
|
"banger/internal/model"
|
|
"banger/internal/paths"
|
|
"banger/internal/system"
|
|
|
|
"github.com/google/go-containerregistry/pkg/name"
|
|
)
|
|
|
|
func writeFakeKernelTriple(t *testing.T) (kernelPath, initrdPath, modulesDir string) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
kernelPath = filepath.Join(dir, "vmlinux")
|
|
if err := os.WriteFile(kernelPath, []byte("kernel"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
initrdPath = filepath.Join(dir, "initrd.img")
|
|
if err := os.WriteFile(initrdPath, []byte("initrd"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
modulesDir = filepath.Join(dir, "modules")
|
|
if err := os.MkdirAll(modulesDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(modulesDir, "modules.dep"), []byte(""), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
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
|
|
}
|
|
|
|
// stubWorkSeedBuilder returns an error so runBuildWorkSeed treats
|
|
// the step as non-fatal and proceeds without a work-seed. Keeps tests
|
|
// off sudo mount without asserting on WorkSeedPath.
|
|
func stubWorkSeedBuilder(_ context.Context, _ string, _ string) error {
|
|
return errWorkSeedBuilderStub
|
|
}
|
|
|
|
var errWorkSeedBuilderStub = errors.New("work-seed builder stubbed in tests")
|
|
|
|
// 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) {
|
|
if err := os.MkdirAll(filepath.Join(destDir, "etc"), 0o755); err != nil {
|
|
return imagepull.Metadata{}, err
|
|
}
|
|
if err := os.WriteFile(filepath.Join(destDir, "etc", "hello"), []byte("world"), 0o644); err != nil {
|
|
return imagepull.Metadata{}, err
|
|
}
|
|
if err := os.WriteFile(filepath.Join(destDir, "marker"), []byte("ok"), 0o644); err != nil {
|
|
return imagepull.Metadata{}, err
|
|
}
|
|
// Tiny synthetic metadata — daemon-level tests exercise the seam
|
|
// plumbing, not the ownership pass itself.
|
|
return imagepull.Metadata{Entries: map[string]imagepull.FileMeta{}}, nil
|
|
}
|
|
|
|
func TestPullImageHappyPath(t *testing.T) {
|
|
if _, err := exec.LookPath("mkfs.ext4"); err != nil {
|
|
t.Skip("mkfs.ext4 not available; skipping")
|
|
}
|
|
imagesDir := t.TempDir()
|
|
cacheDir := t.TempDir()
|
|
kernel, initrd, modules := writeFakeKernelTriple(t)
|
|
|
|
d := &Daemon{
|
|
layout: paths.Layout{ImagesDir: imagesDir, OCICacheDir: cacheDir},
|
|
store: openDaemonStore(t),
|
|
runner: system.NewRunner(),
|
|
}
|
|
d.img = &ImageService{
|
|
layout: d.layout,
|
|
store: d.store,
|
|
runner: d.runner,
|
|
pullAndFlatten: stubPullAndFlatten,
|
|
finalizePulledRootfs: stubFinalizePulledRootfs,
|
|
workSeedBuilder: stubWorkSeedBuilder,
|
|
}
|
|
wireServices(d)
|
|
|
|
image, err := d.img.PullImage(context.Background(), api.ImagePullParams{
|
|
Ref: "docker.io/library/debian:bookworm",
|
|
KernelPath: kernel,
|
|
InitrdPath: initrd,
|
|
ModulesDir: modules,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("PullImage: %v", err)
|
|
}
|
|
|
|
if image.Name != "debian-bookworm" {
|
|
t.Errorf("Name = %q, want debian-bookworm", image.Name)
|
|
}
|
|
if !image.Managed {
|
|
t.Errorf("expected Managed=true")
|
|
}
|
|
if image.ArtifactDir == "" || !strings.HasPrefix(image.ArtifactDir, imagesDir) {
|
|
t.Errorf("ArtifactDir = %q, want under %q", image.ArtifactDir, imagesDir)
|
|
}
|
|
|
|
for _, rel := range []string{"rootfs.ext4", "kernel", "initrd.img", "modules"} {
|
|
if _, err := os.Stat(filepath.Join(image.ArtifactDir, rel)); err != nil {
|
|
t.Errorf("missing artifact %s: %v", rel, err)
|
|
}
|
|
}
|
|
|
|
// Staging dir should be gone after publish.
|
|
stagings, _ := filepath.Glob(filepath.Join(imagesDir, "*.staging"))
|
|
if len(stagings) != 0 {
|
|
t.Errorf("staging dirs left behind: %v", stagings)
|
|
}
|
|
}
|
|
|
|
func TestPullImageRejectsExistingName(t *testing.T) {
|
|
imagesDir := t.TempDir()
|
|
kernel, _, _ := writeFakeKernelTriple(t)
|
|
|
|
d := &Daemon{
|
|
layout: paths.Layout{ImagesDir: imagesDir, OCICacheDir: t.TempDir()},
|
|
store: openDaemonStore(t),
|
|
runner: system.NewRunner(),
|
|
}
|
|
d.img = &ImageService{
|
|
layout: d.layout,
|
|
store: d.store,
|
|
runner: d.runner,
|
|
pullAndFlatten: stubPullAndFlatten,
|
|
finalizePulledRootfs: stubFinalizePulledRootfs,
|
|
workSeedBuilder: stubWorkSeedBuilder,
|
|
}
|
|
wireServices(d)
|
|
// Seed a preexisting image with the would-be derived name.
|
|
id, _ := model.NewID()
|
|
if err := d.store.UpsertImage(context.Background(), model.Image{
|
|
ID: id,
|
|
Name: "debian-bookworm",
|
|
CreatedAt: model.Now(),
|
|
UpdatedAt: model.Now(),
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err := d.img.PullImage(context.Background(), api.ImagePullParams{
|
|
Ref: "docker.io/library/debian:bookworm",
|
|
KernelPath: kernel,
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "already exists") {
|
|
t.Fatalf("expected already-exists error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPullImageRequiresKernel(t *testing.T) {
|
|
d := &Daemon{
|
|
layout: paths.Layout{ImagesDir: t.TempDir(), OCICacheDir: t.TempDir()},
|
|
store: openDaemonStore(t),
|
|
runner: system.NewRunner(),
|
|
}
|
|
d.img = &ImageService{
|
|
layout: d.layout,
|
|
store: d.store,
|
|
runner: d.runner,
|
|
pullAndFlatten: stubPullAndFlatten,
|
|
finalizePulledRootfs: stubFinalizePulledRootfs,
|
|
workSeedBuilder: stubWorkSeedBuilder,
|
|
}
|
|
wireServices(d)
|
|
_, err := d.img.PullImage(context.Background(), api.ImagePullParams{
|
|
Ref: "docker.io/library/debian:bookworm",
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "kernel") {
|
|
t.Fatalf("expected kernel-required error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPullImageCleansStagingOnFailure(t *testing.T) {
|
|
imagesDir := t.TempDir()
|
|
kernel, _, _ := writeFakeKernelTriple(t)
|
|
failureSeam := func(_ context.Context, _ string, _ string, _ string) (imagepull.Metadata, error) {
|
|
return imagepull.Metadata{}, errors.New("network borked")
|
|
}
|
|
|
|
d := &Daemon{
|
|
layout: paths.Layout{ImagesDir: imagesDir, OCICacheDir: t.TempDir()},
|
|
store: openDaemonStore(t),
|
|
runner: system.NewRunner(),
|
|
}
|
|
d.img = &ImageService{
|
|
layout: d.layout,
|
|
store: d.store,
|
|
runner: d.runner,
|
|
pullAndFlatten: failureSeam,
|
|
finalizePulledRootfs: stubFinalizePulledRootfs,
|
|
workSeedBuilder: stubWorkSeedBuilder,
|
|
}
|
|
wireServices(d)
|
|
_, err := d.img.PullImage(context.Background(), api.ImagePullParams{
|
|
Ref: "docker.io/library/debian:bookworm",
|
|
KernelPath: kernel,
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "network borked") {
|
|
t.Fatalf("expected propagated pull error, got %v", err)
|
|
}
|
|
stagings, _ := filepath.Glob(filepath.Join(imagesDir, "*.staging"))
|
|
if len(stagings) != 0 {
|
|
t.Errorf("staging dir left behind on failure: %v", stagings)
|
|
}
|
|
}
|
|
|
|
func TestDefaultImageNameFromRef(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
want string
|
|
}{
|
|
{"docker.io/library/debian:bookworm", "debian-bookworm"},
|
|
{"alpine:3.20", "alpine-3-20"},
|
|
{"docker.io/library/debian", "debian"},
|
|
{"ghcr.io/some/org/my-image:v2.1", "my-image-v2-1"},
|
|
}
|
|
for _, tc := range cases {
|
|
ref, err := name.ParseReference(tc.in)
|
|
if err != nil {
|
|
t.Fatalf("parse %s: %v", tc.in, err)
|
|
}
|
|
if got := defaultImageNameFromRef(ref); got != tc.want {
|
|
t.Errorf("defaultImageNameFromRef(%s) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
}
|