image pull: dispatch to imagecat bundle path before OCI

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>
This commit is contained in:
Thales Maciel 2026-04-17 15:43:33 -03:00
parent d22d05555c
commit 5bdc9985c2
No known key found for this signature in database
GPG key ID: 33112E6833C34679
4 changed files with 391 additions and 19 deletions

View file

@ -12,6 +12,7 @@ import (
"banger/internal/api"
"banger/internal/daemon/imagemgr"
"banger/internal/imagecat"
"banger/internal/imagepull"
"banger/internal/model"
"banger/internal/paths"
@ -23,22 +24,41 @@ import (
// when the caller doesn't override --size and the OCI tree is tiny.
const minPullExt4Size int64 = 1 << 30 // 1 GiB
// PullImage downloads an OCI image, flattens it into an ext4 rootfs, and
// registers it as a managed banger image. Kernel info comes via --kernel-ref
// or direct paths, mirroring RegisterImage.
// PullImage downloads an image and registers it as a managed banger
// image. Two paths:
//
// The pulled rootfs's file ownership is the runner's uid/gid (Phase A v1
// limitation; see internal/imagepull). The image is suitable as input to
// `image build --from-image` but is not directly bootable until a future
// fixup pass lands.
func (d *Daemon) PullImage(ctx context.Context, params api.ImagePullParams) (image model.Image, err error) {
// - Bundle path: `ref` matches an entry in the embedded imagecat
// catalog. The `.tar.zst` bundle is fetched, `rootfs.ext4` is
// already flattened + ownership-fixed + agent-injected at build
// time, so this path is strictly faster than the OCI one.
// - OCI path: otherwise treat `ref` as an OCI reference, pull its
// layers, flatten, fix ownership, inject agents.
//
// Kernel info falls back through: `params.KernelRef` → catalog entry's
// `kernel_ref` (bundle path only) → `params.Kernel/Initrd/ModulesDir`.
func (d *Daemon) PullImage(ctx context.Context, params api.ImagePullParams) (model.Image, error) {
d.imageOpsMu.Lock()
defer d.imageOpsMu.Unlock()
ref := strings.TrimSpace(params.Ref)
if ref == "" {
return model.Image{}, errors.New("oci reference is required")
return model.Image{}, errors.New("reference is required")
}
catalog, err := imagecat.LoadEmbedded()
if err != nil {
return model.Image{}, fmt.Errorf("load image catalog: %w", err)
}
if entry, lookupErr := catalog.Lookup(ref); lookupErr == nil {
return d.pullFromBundle(ctx, params, entry)
}
return d.pullFromOCI(ctx, params)
}
// pullFromOCI is the original OCI-registry-pull path. See PullImage for
// the intent.
func (d *Daemon) pullFromOCI(ctx context.Context, params api.ImagePullParams) (image model.Image, err error) {
ref := strings.TrimSpace(params.Ref)
parsed, err := name.ParseReference(ref)
if err != nil {
return model.Image{}, fmt.Errorf("parse oci ref %q: %w", ref, err)
@ -142,6 +162,95 @@ func (d *Daemon) PullImage(ctx context.Context, params api.ImagePullParams) (ima
return image, nil
}
// pullFromBundle is the imagecat-backed path: download a ready-to-boot
// bundle (rootfs.ext4 already flattened + ownership-fixed + agent-
// injected at build time), verify its sha256, and register the result
// as a managed image. No flatten / mkfs / debugfs work on the daemon
// host.
func (d *Daemon) pullFromBundle(ctx context.Context, params api.ImagePullParams, entry imagecat.CatEntry) (image model.Image, err error) {
imgName := strings.TrimSpace(params.Name)
if imgName == "" {
imgName = entry.Name
}
if existing, lookupErr := d.store.GetImageByName(ctx, imgName); lookupErr == nil {
return model.Image{}, fmt.Errorf("image %q already exists (id=%s); pick a different --name or delete it first", imgName, existing.ID)
}
// Kernel resolution precedence: params > catalog entry's kernel_ref.
kernelRef := strings.TrimSpace(params.KernelRef)
if kernelRef == "" && strings.TrimSpace(params.KernelPath) == "" {
kernelRef = strings.TrimSpace(entry.KernelRef)
}
kernelPath, initrdPath, modulesDir, err := d.resolveKernelInputs(kernelRef, params.KernelPath, params.InitrdPath, params.ModulesDir)
if err != nil {
return model.Image{}, err
}
if err := imagemgr.ValidateKernelPaths(kernelPath, initrdPath, modulesDir); err != nil {
return model.Image{}, err
}
id, err := model.NewID()
if err != nil {
return model.Image{}, err
}
finalDir := filepath.Join(d.layout.ImagesDir, id)
stagingDir := finalDir + ".staging"
if err := os.MkdirAll(stagingDir, 0o755); err != nil {
return model.Image{}, err
}
cleanupStaging := true
defer func() {
if cleanupStaging {
_ = os.RemoveAll(stagingDir)
}
}()
if _, err := d.runBundleFetch(ctx, stagingDir, entry); err != nil {
return model.Image{}, fmt.Errorf("fetch bundle: %w", err)
}
// manifest.json is metadata we only need at fetch time; strip it
// so the final artifact dir contains only boot-relevant files.
_ = os.Remove(filepath.Join(stagingDir, imagecat.ManifestFilename))
rootfsExt4 := filepath.Join(stagingDir, imagecat.RootfsFilename)
stagedKernel, stagedInitrd, stagedModules, err := imagemgr.StageBootArtifacts(ctx, d.runner, stagingDir, kernelPath, initrdPath, modulesDir)
if err != nil {
return model.Image{}, fmt.Errorf("stage boot artifacts: %w", err)
}
if err := os.Rename(stagingDir, finalDir); err != nil {
return model.Image{}, fmt.Errorf("publish artifact dir: %w", err)
}
cleanupStaging = false
now := model.Now()
image = model.Image{
ID: id,
Name: imgName,
Managed: true,
ArtifactDir: finalDir,
RootfsPath: filepath.Join(finalDir, filepath.Base(rootfsExt4)),
KernelPath: rebaseUnder(stagedKernel, stagingDir, finalDir),
InitrdPath: rebaseUnder(stagedInitrd, stagingDir, finalDir),
ModulesDir: rebaseUnder(stagedModules, stagingDir, finalDir),
CreatedAt: now,
UpdatedAt: now,
}
if err := d.store.UpsertImage(ctx, image); err != nil {
_ = os.RemoveAll(finalDir)
return model.Image{}, err
}
return image, nil
}
// runBundleFetch is the seam tests substitute. nil → real implementation.
func (d *Daemon) runBundleFetch(ctx context.Context, destDir string, entry imagecat.CatEntry) (imagecat.Manifest, error) {
if d.bundleFetch != nil {
return d.bundleFetch(ctx, destDir, entry)
}
return imagecat.Fetch(ctx, nil, destDir, entry)
}
// runPullAndFlatten is the seam tests substitute. nil → real implementation.
func (d *Daemon) runPullAndFlatten(ctx context.Context, ref, cacheDir, destDir string) (imagepull.Metadata, error) {
if d.pullAndFlatten != nil {