banger/internal/daemon/images_pull_test.go
Thales Maciel 16702bd5e1
daemon split (6/n): extract wireServices + drop lazy service getters
Factor the service + capability wiring out of Daemon.Open() into
wireServices(d), an idempotent helper that constructs HostNetwork,
ImageService, WorkspaceService, and VMService from whatever
infrastructure (runner, store, config, layout, logger, closing) is
already set on d. Open() calls it once after filling the composition
root; tests that build &Daemon{...} literals call it to get a working
service graph, preinstalling stubs on the fields they want to fake.

Drops the four lazy-init getters on *Daemon — d.hostNet(),
d.imageSvc(), d.workspaceSvc(), d.vmSvc() — whose sole purpose was
keeping test literals working. Every production call site now reads
d.net / d.img / d.ws / d.vm directly; the services are guaranteed
non-nil once Open returns. No behavior change.

Mechanical: all existing `d.xxxSvc()` calls (production + tests)
rewritten to field access; each `d := &Daemon{...}` in tests gets a
trailing wireServices(d) so the literal + wiring are side-by-side.
Tests that override a pre-built service (e.g. d.img = &ImageService{
bundleFetch: stub}) now set the override before wireServices so the
replacement propagates into VMService's peer pointer.

Also nil-guards HostNetwork.stopVMDNS and d.store in Close() so
partially-initialised daemons (pre-reconcile open failure) still
tear down cleanly — same contract the old lazy getters provided.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:55:28 -03:00

231 lines
6.9 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
}
// 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,
}
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,
}
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,
}
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,
}
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)
}
}
}