banger/internal/daemon/autopull_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

153 lines
4.7 KiB
Go

package daemon
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"banger/internal/imagecat"
"banger/internal/model"
"banger/internal/paths"
"banger/internal/system"
)
func TestFindOrAutoPullImageReturnsLocalWithoutPulling(t *testing.T) {
d := &Daemon{
layout: paths.Layout{ImagesDir: t.TempDir()},
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) {
t.Fatal("bundleFetch should not be called when image is local")
return imagecat.Manifest{}, nil
},
}
wireServices(d)
id, _ := model.NewID()
if err := d.store.UpsertImage(context.Background(), model.Image{
ID: id,
Name: "my-local-image",
CreatedAt: model.Now(),
UpdatedAt: model.Now(),
}); err != nil {
t.Fatal(err)
}
image, err := d.vm.findOrAutoPullImage(context.Background(), "my-local-image")
if err != nil {
t.Fatalf("findOrAutoPullImage: %v", err)
}
if image.Name != "my-local-image" {
t.Fatalf("Name = %q, want my-local-image", image.Name)
}
}
func TestFindOrAutoPullImagePullsFromCatalog(t *testing.T) {
imagesDir := t.TempDir()
kernelsDir := t.TempDir()
seedKernel(t, kernelsDir, "generic-6.12")
pullCalls := 0
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(ctx context.Context, destDir string, entry imagecat.CatEntry) (imagecat.Manifest, error) {
pullCalls++
return stubBundleFetch(imagecat.Manifest{KernelRef: "generic-6.12"})(ctx, destDir, entry)
},
workSeedBuilder: stubWorkSeedBuilder,
}
wireServices(d)
// "debian-bookworm" is in the embedded imagecat catalog.
image, err := d.vm.findOrAutoPullImage(context.Background(), "debian-bookworm")
if err != nil {
t.Fatalf("findOrAutoPullImage: %v", err)
}
if image.Name != "debian-bookworm" {
t.Fatalf("Name = %q, want debian-bookworm", image.Name)
}
if pullCalls != 1 {
t.Fatalf("bundleFetch calls = %d, want 1", pullCalls)
}
}
func TestFindOrAutoPullImageReturnsOriginalErrorWhenNotInCatalog(t *testing.T) {
d := &Daemon{
layout: paths.Layout{ImagesDir: t.TempDir()},
store: openDaemonStore(t),
runner: system.NewRunner(),
}
wireServices(d)
_, err := d.vm.findOrAutoPullImage(context.Background(), "not-in-catalog-or-store")
if err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("err = %v, want not-found", err)
}
}
func TestReadOrAutoPullKernelReturnsLocalWithoutPulling(t *testing.T) {
kernelsDir := t.TempDir()
seedKernel(t, kernelsDir, "generic-6.12")
d := &Daemon{layout: paths.Layout{KernelsDir: kernelsDir}}
wireServices(d)
entry, err := d.img.readOrAutoPullKernel(context.Background(), "generic-6.12")
if err != nil {
t.Fatalf("readOrAutoPullKernel: %v", err)
}
if entry.Name != "generic-6.12" {
t.Fatalf("Name = %q", entry.Name)
}
}
func TestReadOrAutoPullKernelErrorsWhenNotInCatalog(t *testing.T) {
d := &Daemon{layout: paths.Layout{KernelsDir: t.TempDir()}}
wireServices(d)
_, err := d.img.readOrAutoPullKernel(context.Background(), "nonexistent-kernel")
if err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("err = %v, want not-found", err)
}
}
// TestReadOrAutoPullKernelSurfacesNonNotExistError covers the path where
// kernelcat.ReadLocal fails for a reason other than missing entry (e.g.
// corrupt manifest); the autopull logic should NOT try to fetch in that
// case since the entry clearly exists in some broken form.
func TestReadOrAutoPullKernelSurfacesNonNotExistError(t *testing.T) {
kernelsDir := t.TempDir()
// Seed a manifest that doesn't match the entry's own Name field —
// kernelcat.ReadLocal returns an error, not os.ErrNotExist.
dir := filepath.Join(kernelsDir, "broken-kernel")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(`{"name":"different-name"}`), 0o644); err != nil {
t.Fatal(err)
}
d := &Daemon{layout: paths.Layout{KernelsDir: kernelsDir}}
wireServices(d)
_, err := d.img.readOrAutoPullKernel(context.Background(), "broken-kernel")
if err == nil {
t.Fatal("want error")
}
// Must not be wrapped in an "auto-pull" message — the corrupt-manifest
// failure should surface as the primary cause.
if strings.Contains(err.Error(), "not found in catalog") {
t.Fatalf("err = %v, should not claim 'not in catalog'", err)
}
// Sanity: ensure it's not os.ErrNotExist-compatible.
if errors.Is(err, os.ErrNotExist) {
t.Fatalf("err = %v, should not be os.ErrNotExist", err)
}
}