banger/internal/daemon/images_pull_test.go
Thales Maciel a8c9983542
Phase 2: daemon PullImage orchestration
(d *Daemon).PullImage downloads an OCI image, flattens it into an
ext4 rootfs, and registers the result as a managed banger image.

Flow (internal/daemon/images_pull.go):
 1. Parse + validate the OCI ref via go-containerregistry/name.
 2. Derive a friendly default name from the ref ("debian-bookworm")
    when --name is omitted.
 3. Reject if an image with that name already exists.
 4. Resolve kernel info via the new shared resolveKernelInputs
    helper (refactored out of RegisterImage); ValidateKernelPaths
    checks the kernel triple alone.
 5. Acquire imageOpsMu, generate a fresh image id, and stage at
    <ImagesDir>/<id>.staging.
 6. imagepull.Pull → cache layers under OCICacheDir;
    imagepull.Flatten → temp rootfs tree under os.TempDir (so the
    state filesystem doesn't temporarily double in size).
 7. Default size: max(treeSize × 1.25, 1 GiB); --size override
    accepted.
 8. imagepull.BuildExt4 produces the rootfs.ext4 in the staging dir.
 9. imagemgr.StageBootArtifacts stages the kernel/initrd/modules
    into the same dir (reused unchanged).
 10. Atomic os.Rename(staging, finalDir) publishes the artifact dir.
 11. Persist model.Image with Managed=true. Failure at any step
     removes the staging dir; failure post-rename removes finalDir.

The pullAndFlatten field on Daemon is the test seam: tests stub it
to write a fixture tree into destDir and skip the real registry.

Refactor: extracted the "kernel-ref vs direct paths" resolution
out of RegisterImage into d.resolveKernelInputs so PullImage and
RegisterImage share one source of truth for that policy. Split
ValidateRegisterPaths into a kernel-only ValidateKernelPaths so
PullImage (which produces the rootfs itself) can validate just
the kernel triple without the rootfs check.

API: ImagePullParams { Ref, Name, KernelPath, InitrdPath,
ModulesDir, KernelRef, SizeBytes }. RPC dispatch case image.pull
mirrors image.register.

Tests cover: happy-path producing a managed image with all four
artifacts present + staging cleaned up, name-collision rejection,
missing-kernel rejection, and staging cleanup on a failed pull.
defaultImageNameFromRef handles tag/digest/no-suffix cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 17:27:32 -03:00

191 lines
5.6 KiB
Go

package daemon
import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"banger/internal/api"
"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
}
// 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) error {
if err := os.MkdirAll(filepath.Join(destDir, "etc"), 0o755); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(destDir, "etc", "hello"), []byte("world"), 0o644); err != nil {
return err
}
return os.WriteFile(filepath.Join(destDir, "marker"), []byte("ok"), 0o644)
}
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(),
pullAndFlatten: stubPullAndFlatten,
}
image, err := d.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(),
pullAndFlatten: stubPullAndFlatten,
}
// 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.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(),
pullAndFlatten: stubPullAndFlatten,
}
_, err := d.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) error {
return errors.New("network borked")
}
d := &Daemon{
layout: paths.Layout{ImagesDir: imagesDir, OCICacheDir: t.TempDir()},
store: openDaemonStore(t),
runner: system.NewRunner(),
pullAndFlatten: failureSeam,
}
_, err := d.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)
}
}
}