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

283 lines
8.9 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"}),
}
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"}),
}
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"}),
}
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{}),
}
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")
},
}
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{}),
}
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")
}
}