PullImage now checks the embedded imagecat catalog first. If the
ref matches a catalog entry, it takes the bundle path:
1. Fetch the .tar.zst bundle into a staging dir (rootfs.ext4 +
manifest.json).
2. Strip manifest.json (staging-only metadata).
3. Stage kernel/initrd/modules alongside rootfs.ext4.
4. Publish the staging dir and upsert the image row.
Bundle rootfs is already flattened + ownership-fixed + agent-
injected at build time, so the daemon-side work is strictly I/O —
no flatten, no mkfs, no debugfs.
Kernel resolution in the bundle path: --kernel-ref > entry.kernel_ref
> --kernel/--initrd/--modules.
If the ref doesn't match a catalog entry, PullImage falls through
to the existing OCI path unchanged (extracted into pullFromOCI).
New test seam: d.bundleFetch. Six unit tests cover happy path,
--kernel-ref override, existing-name rejection, kernel-required
error, fetch-failure cleanup, and the catalog → OCI fallthrough.
CLI help updated: image pull now documents both forms and takes
<name-or-oci-ref> instead of requiring an OCI ref.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
247 lines
8.2 KiB
Go
247 lines
8.2 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(),
|
|
bundleFetch: stubBundleFetch(imagecat.Manifest{KernelRef: "generic-6.12"}),
|
|
}
|
|
|
|
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.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(),
|
|
bundleFetch: stubBundleFetch(imagecat.Manifest{KernelRef: "generic-6.12"}),
|
|
}
|
|
|
|
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.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(),
|
|
bundleFetch: stubBundleFetch(imagecat.Manifest{KernelRef: "generic-6.12"}),
|
|
}
|
|
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.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(),
|
|
bundleFetch: stubBundleFetch(imagecat.Manifest{}),
|
|
}
|
|
// Catalog entry has no kernel_ref, no --kernel-ref/--kernel passed.
|
|
_, err := d.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(),
|
|
bundleFetch: func(_ context.Context, _ string, _ imagecat.CatEntry) (imagecat.Manifest, error) {
|
|
return imagecat.Manifest{}, errors.New("r2 exploded")
|
|
},
|
|
}
|
|
_, err := d.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(),
|
|
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{}),
|
|
}
|
|
|
|
_, err := d.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")
|
|
}
|
|
}
|