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") } }