banger/internal/daemon/images_pull.go
Thales Maciel e0894376ea
vm create: auto-pull image and kernel from catalogs if missing
One-command sandbox: `banger vm run` on a fresh host now Just Works.
No prior `banger image pull` or `banger kernel pull` needed.

Changes:

- Default `default_image_name` flips from "default" to "debian-bookworm"
  so the golden image is the implicit target when `--image` is omitted.
- `CreateVM` resolves the image via a new `findOrAutoPullImage`: try
  the local store first, and on miss fall back to the embedded imagecat
  catalog + auto-pull. Emits a vm-create progress stage so the user
  sees "pulling from image catalog" in the create output.
- `resolveKernelInputs` gains context + the same pattern via
  `readOrAutoPullKernel`: try the local kernelcat, and on miss look up
  the embedded kernelcat and auto-pull. Fires whenever a bundle's
  manifest references a kernel the user hasn't pulled yet, not just
  during image pull — any CreateVM with an image that needs a kernel
  not yet local will resolve it.
- `--image` help text updated on both `vm run` and `vm create`.

Six tests cover local-hit-no-pull, auto-pull-on-miss, not-in-catalog
error propagation, and a non-ENOENT kernel read error does NOT trigger
a misleading "not in catalog" claim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:10:26 -03:00

350 lines
11 KiB
Go

package daemon
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
"banger/internal/api"
"banger/internal/daemon/imagemgr"
"banger/internal/imagecat"
"banger/internal/imagepull"
"banger/internal/model"
"banger/internal/paths"
"github.com/google/go-containerregistry/pkg/name"
)
// minPullExt4Size keeps the floor consistent with imagepull.MinExt4Size
// when the caller doesn't override --size and the OCI tree is tiny.
const minPullExt4Size int64 = 1 << 30 // 1 GiB
// PullImage downloads an image and registers it as a managed banger
// image. Two paths:
//
// - 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("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)
}
imgName := strings.TrimSpace(params.Name)
if imgName == "" {
imgName = defaultImageNameFromRef(parsed)
if imgName == "" {
return model.Image{}, errors.New("could not derive image name from ref; pass --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)
}
kernelPath, initrdPath, modulesDir, err := d.resolveKernelInputs(ctx, params.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)
}
}()
// Extract OCI layers into a working tree under TempDir so the
// state filesystem doesn't temporarily double in size.
rootfsTree, err := os.MkdirTemp("", "banger-pull-")
if err != nil {
return model.Image{}, err
}
defer os.RemoveAll(rootfsTree)
meta, err := d.runPullAndFlatten(ctx, ref, d.layout.OCICacheDir, rootfsTree)
if err != nil {
return model.Image{}, fmt.Errorf("pull oci image: %w", err)
}
sizeBytes := params.SizeBytes
if sizeBytes <= 0 {
treeSize, err := dirSizeBytes(rootfsTree)
if err != nil {
return model.Image{}, fmt.Errorf("size oci tree: %w", err)
}
sizeBytes = treeSize + treeSize/4 // +25% headroom
if sizeBytes < minPullExt4Size {
sizeBytes = minPullExt4Size
}
}
rootfsExt4 := filepath.Join(stagingDir, "rootfs.ext4")
if err := imagepull.BuildExt4(ctx, d.runner, rootfsTree, rootfsExt4, sizeBytes); err != nil {
return model.Image{}, fmt.Errorf("build rootfs ext4: %w", err)
}
if err := d.runFinalizePulledRootfs(ctx, rootfsExt4, meta); err != nil {
return model.Image{}, err
}
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
}
// 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(ctx, 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 {
return d.pullAndFlatten(ctx, ref, cacheDir, destDir)
}
pulled, err := imagepull.Pull(ctx, ref, cacheDir)
if err != nil {
return imagepull.Metadata{}, err
}
return imagepull.Flatten(ctx, pulled, destDir)
}
// runFinalizePulledRootfs applies ownership fixup and injects banger's
// guest agents. Tests substitute via d.finalizePulledRootfs; nil →
// real implementation using debugfs + the companion vsock-agent
// binary resolved via paths.CompanionBinaryPath.
func (d *Daemon) runFinalizePulledRootfs(ctx context.Context, ext4File string, meta imagepull.Metadata) error {
if d.finalizePulledRootfs != nil {
return d.finalizePulledRootfs(ctx, ext4File, meta)
}
if err := imagepull.ApplyOwnership(ctx, d.runner, ext4File, meta); err != nil {
return fmt.Errorf("apply ownership: %w", err)
}
vsockBin, err := paths.CompanionBinaryPath("banger-vsock-agent")
if err != nil {
return fmt.Errorf("locate vsock agent binary: %w", err)
}
if err := imagepull.InjectGuestAgents(ctx, d.runner, ext4File, imagepull.GuestAgentAssets{
VsockAgentBin: vsockBin,
}); err != nil {
return fmt.Errorf("inject guest agents: %w", err)
}
return nil
}
// nameSanitize keeps lowercase alphanumerics + hyphens, collapses runs.
var nameSanitizeRE = regexp.MustCompile(`[^a-z0-9]+`)
// defaultImageNameFromRef derives a friendly name like "debian-bookworm"
// from "docker.io/library/debian:bookworm". Returns "" if it can't.
func defaultImageNameFromRef(ref name.Reference) string {
repo := ref.Context().RepositoryStr() // e.g. library/debian
parts := strings.Split(repo, "/")
base := parts[len(parts)-1]
suffix := ""
switch r := ref.(type) {
case name.Tag:
if t := r.TagStr(); t != "" && t != "latest" {
suffix = "-" + t
}
case name.Digest:
// take the first 12 hex chars after sha256:
d := r.DigestStr()
if i := strings.Index(d, ":"); i >= 0 && len(d) >= i+13 {
suffix = "-" + d[i+1:i+13]
}
}
out := nameSanitizeRE.ReplaceAllString(strings.ToLower(base+suffix), "-")
out = strings.Trim(out, "-")
return out
}
// rebaseUnder rewrites a path that points inside oldRoot to point inside
// newRoot. Empty input returns empty (kept by StageBootArtifacts when an
// optional artifact is absent).
func rebaseUnder(path, oldRoot, newRoot string) string {
if path == "" {
return ""
}
if rel, err := filepath.Rel(oldRoot, path); err == nil && !strings.HasPrefix(rel, "..") {
return filepath.Join(newRoot, rel)
}
return path
}
// dirSizeBytes returns the sum of regular-file sizes under root, following
// no symlinks (lstat). Suitable for sizing an ext4 image.
func dirSizeBytes(root string) (int64, error) {
var total int64
err := filepath.WalkDir(root, func(_ string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.Type().IsRegular() {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
total += info.Size()
return nil
})
return total, err
}