banger/internal/daemon/images_pull_bundle_test.go
Thales Maciel 3edd7c6de7
daemon: build a work-seed during image pull, refresh doctor check
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>
2026-04-23 20:24:10 -03:00

289 lines
9.2 KiB
Go

package daemon
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"banger/internal/api"
"banger/internal/imagecat"
"banger/internal/imagepull"
"banger/internal/kernelcat"
"banger/internal/model"
"banger/internal/paths"
"banger/internal/system"
)
// stubBundleFetch writes a valid-enough rootfs.ext4 + manifest.json
// into destDir, simulating a successful bundle download + extract.
// The returned manifest echoes the entry's declared kernel_ref so the
// orchestration sees the same hints it would from a real fetch.
func stubBundleFetch(manifest imagecat.Manifest) func(context.Context, string, imagecat.CatEntry) (imagecat.Manifest, error) {
return func(_ context.Context, destDir string, entry imagecat.CatEntry) (imagecat.Manifest, error) {
if err := os.WriteFile(filepath.Join(destDir, imagecat.RootfsFilename), []byte("rootfs-bytes"), 0o644); err != nil {
return imagecat.Manifest{}, err
}
m := manifest
if m.Name == "" {
m.Name = entry.Name
}
data, err := json.Marshal(m)
if err != nil {
return imagecat.Manifest{}, err
}
if err := os.WriteFile(filepath.Join(destDir, imagecat.ManifestFilename), data, 0o644); err != nil {
return imagecat.Manifest{}, err
}
return m, nil
}
}
func seedKernel(t *testing.T, kernelsDir, name string) {
t.Helper()
if err := kernelcat.WriteLocal(kernelsDir, kernelcat.Entry{
Name: name,
Distro: "generic",
Arch: "x86_64",
Source: "test",
}); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(kernelsDir, name, "vmlinux"), []byte("kernel"), 0o644); err != nil {
t.Fatal(err)
}
}
func TestPullImageBundlePathRegistersFromCatalog(t *testing.T) {
imagesDir := t.TempDir()
kernelsDir := t.TempDir()
seedKernel(t, kernelsDir, "generic-6.12")
d := &Daemon{
layout: paths.Layout{ImagesDir: imagesDir, KernelsDir: kernelsDir},
store: openDaemonStore(t),
runner: system.NewRunner(),
}
d.img = &ImageService{
layout: d.layout,
store: d.store,
runner: d.runner,
bundleFetch: stubBundleFetch(imagecat.Manifest{KernelRef: "generic-6.12"}),
workSeedBuilder: stubWorkSeedBuilder,
}
wireServices(d)
entry := imagecat.CatEntry{
Name: "debian-bookworm",
Distro: "debian",
Arch: "x86_64",
KernelRef: "generic-6.12",
TarballURL: "https://example.com/x.tar.zst",
TarballSHA256: "abc",
}
image, err := d.img.pullFromBundle(context.Background(), api.ImagePullParams{Ref: "debian-bookworm"}, entry)
if err != nil {
t.Fatalf("pullFromBundle: %v", err)
}
if image.Name != "debian-bookworm" {
t.Errorf("Name = %q, want debian-bookworm", image.Name)
}
if !strings.HasPrefix(image.ArtifactDir, imagesDir) {
t.Errorf("ArtifactDir = %q, want under %q", image.ArtifactDir, imagesDir)
}
for _, rel := range []string{"rootfs.ext4", "kernel"} {
if _, err := os.Stat(filepath.Join(image.ArtifactDir, rel)); err != nil {
t.Errorf("missing artifact %s: %v", rel, err)
}
}
// manifest.json should not leak into the published artifact dir.
if _, err := os.Stat(filepath.Join(image.ArtifactDir, imagecat.ManifestFilename)); !os.IsNotExist(err) {
t.Errorf("manifest.json should be stripped, got err=%v", err)
}
}
func TestPullImageBundlePathOverrideNameAndKernelRef(t *testing.T) {
imagesDir := t.TempDir()
kernelsDir := t.TempDir()
seedKernel(t, kernelsDir, "custom-kernel")
// Overwrite the vmlinux with recognisable bytes so we can verify
// the staged kernel came from the --kernel-ref entry, not the
// catalog's kernel_ref.
customBytes := []byte("custom-kernel-marker")
if err := os.WriteFile(filepath.Join(kernelsDir, "custom-kernel", "vmlinux"), customBytes, 0o644); err != nil {
t.Fatal(err)
}
d := &Daemon{
layout: paths.Layout{ImagesDir: imagesDir, KernelsDir: kernelsDir},
store: openDaemonStore(t),
runner: system.NewRunner(),
}
d.img = &ImageService{
layout: d.layout,
store: d.store,
runner: d.runner,
bundleFetch: stubBundleFetch(imagecat.Manifest{KernelRef: "generic-6.12"}),
workSeedBuilder: stubWorkSeedBuilder,
}
wireServices(d)
entry := imagecat.CatEntry{
Name: "debian-bookworm", Arch: "x86_64",
KernelRef: "generic-6.12",
TarballURL: "https://example.com/x.tar.zst",
TarballSHA256: "abc",
}
image, err := d.img.pullFromBundle(context.Background(), api.ImagePullParams{
Ref: "debian-bookworm", Name: "my-sandbox", KernelRef: "custom-kernel",
}, entry)
if err != nil {
t.Fatalf("pullFromBundle: %v", err)
}
if image.Name != "my-sandbox" {
t.Errorf("Name = %q, want my-sandbox", image.Name)
}
staged, err := os.ReadFile(image.KernelPath)
if err != nil {
t.Fatalf("read staged kernel: %v", err)
}
if !strings.Contains(string(staged), "custom-kernel-marker") {
t.Errorf("staged kernel = %q, want custom-kernel bytes", staged)
}
}
func TestPullImageBundlePathRejectsExistingName(t *testing.T) {
imagesDir := t.TempDir()
kernelsDir := t.TempDir()
seedKernel(t, kernelsDir, "generic-6.12")
d := &Daemon{
layout: paths.Layout{ImagesDir: imagesDir, KernelsDir: kernelsDir},
store: openDaemonStore(t),
runner: system.NewRunner(),
}
d.img = &ImageService{
layout: d.layout,
store: d.store,
runner: d.runner,
bundleFetch: stubBundleFetch(imagecat.Manifest{KernelRef: "generic-6.12"}),
workSeedBuilder: stubWorkSeedBuilder,
}
wireServices(d)
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.pullFromBundle(context.Background(), api.ImagePullParams{Ref: "debian-bookworm"}, imagecat.CatEntry{
Name: "debian-bookworm", KernelRef: "generic-6.12",
TarballURL: "https://example.com/x.tar.zst", TarballSHA256: "abc",
})
if err == nil || !strings.Contains(err.Error(), "already exists") {
t.Fatalf("expected already-exists, got %v", err)
}
}
func TestPullImageBundlePathRequiresSomeKernelSource(t *testing.T) {
d := &Daemon{
layout: paths.Layout{ImagesDir: t.TempDir(), KernelsDir: t.TempDir()},
store: openDaemonStore(t),
runner: system.NewRunner(),
}
d.img = &ImageService{
layout: d.layout,
store: d.store,
runner: d.runner,
bundleFetch: stubBundleFetch(imagecat.Manifest{}),
workSeedBuilder: stubWorkSeedBuilder,
}
wireServices(d)
// Catalog entry has no kernel_ref, no --kernel-ref/--kernel passed.
_, err := d.img.pullFromBundle(context.Background(), api.ImagePullParams{Ref: "x"}, imagecat.CatEntry{
Name: "x", TarballURL: "https://example.com/x.tar.zst", TarballSHA256: "abc",
})
if err == nil || !strings.Contains(err.Error(), "kernel") {
t.Fatalf("expected kernel-required error, got %v", err)
}
}
func TestPullImageBundleFetchFailurePropagates(t *testing.T) {
imagesDir := t.TempDir()
kernelsDir := t.TempDir()
seedKernel(t, kernelsDir, "generic-6.12")
d := &Daemon{
layout: paths.Layout{ImagesDir: imagesDir, KernelsDir: kernelsDir},
store: openDaemonStore(t),
runner: system.NewRunner(),
}
d.img = &ImageService{
layout: d.layout,
store: d.store,
runner: d.runner,
bundleFetch: func(_ context.Context, _ string, _ imagecat.CatEntry) (imagecat.Manifest, error) {
return imagecat.Manifest{}, errors.New("r2 exploded")
},
workSeedBuilder: stubWorkSeedBuilder,
}
wireServices(d)
_, err := d.img.pullFromBundle(context.Background(), api.ImagePullParams{Ref: "x"}, imagecat.CatEntry{
Name: "x", KernelRef: "generic-6.12",
TarballURL: "https://example.com/x.tar.zst", TarballSHA256: "abc",
})
if err == nil || !strings.Contains(err.Error(), "r2 exploded") {
t.Fatalf("expected fetch failure propagated, got %v", err)
}
// Staging dir cleaned up.
stagings, _ := filepath.Glob(filepath.Join(imagesDir, "*.staging"))
if len(stagings) != 0 {
t.Errorf("staging dirs left behind: %v", stagings)
}
}
func TestPullImageDispatchFallsThroughToOCIWhenNoCatalogHit(t *testing.T) {
imagesDir := t.TempDir()
kernelsDir := t.TempDir()
seedKernel(t, kernelsDir, "generic-6.12")
ociCalled := false
d := &Daemon{
layout: paths.Layout{ImagesDir: imagesDir, KernelsDir: kernelsDir, OCICacheDir: t.TempDir()},
store: openDaemonStore(t),
runner: system.NewRunner(),
}
d.img = &ImageService{
layout: d.layout,
store: d.store,
runner: d.runner,
pullAndFlatten: func(_ context.Context, ref, _ string, destDir string) (imagepull.Metadata, error) {
ociCalled = true
if err := os.WriteFile(filepath.Join(destDir, "marker"), []byte("x"), 0o644); err != nil {
return imagepull.Metadata{}, err
}
return imagepull.Metadata{}, errors.New("stop here")
},
finalizePulledRootfs: stubFinalizePulledRootfs,
bundleFetch: stubBundleFetch(imagecat.Manifest{}),
workSeedBuilder: stubWorkSeedBuilder,
}
wireServices(d)
_, err := d.img.PullImage(context.Background(), api.ImagePullParams{
// Not a catalog name (catalog is empty in the embedded default).
Ref: "docker.io/library/debian:bookworm",
KernelRef: "generic-6.12",
})
if err == nil || !strings.Contains(err.Error(), "stop here") {
t.Fatalf("expected OCI path to be taken, got %v", err)
}
if !ociCalled {
t.Fatal("OCI seam was not invoked")
}
}