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>
This commit is contained in:
Thales Maciel 2026-04-16 17:27:32 -03:00
parent 78376ba6ec
commit a8c9983542
No known key found for this signature in database
GPG key ID: 33112E6833C34679
6 changed files with 467 additions and 25 deletions

View file

@ -263,6 +263,16 @@ type ImageRegisterParams struct {
Docker bool `json:"docker,omitempty"` Docker bool `json:"docker,omitempty"`
} }
type ImagePullParams struct {
Ref string `json:"ref"`
Name string `json:"name,omitempty"`
KernelPath string `json:"kernel_path,omitempty"`
InitrdPath string `json:"initrd_path,omitempty"`
ModulesDir string `json:"modules_dir,omitempty"`
KernelRef string `json:"kernel_ref,omitempty"`
SizeBytes int64 `json:"size_bytes,omitempty"`
}
type ImageRefParams struct { type ImageRefParams struct {
IDOrName string `json:"id_or_name"` IDOrName string `json:"id_or_name"`
} }

View file

@ -50,6 +50,7 @@ type Daemon struct {
vmDNS *vmdns.Server vmDNS *vmdns.Server
vmCaps []vmCapability vmCaps []vmCapability
imageBuild func(context.Context, imageBuildSpec) error imageBuild func(context.Context, imageBuildSpec) error
pullAndFlatten func(ctx context.Context, ref, cacheDir, destDir string) error
requestHandler func(context.Context, rpc.Request) rpc.Response requestHandler func(context.Context, rpc.Request) rpc.Response
guestWaitForSSH func(context.Context, string, string, time.Duration) error guestWaitForSSH func(context.Context, string, string, time.Duration) error
guestDial func(context.Context, string, string) (guestSSHClient, error) guestDial func(context.Context, string, string) (guestSSHClient, error)
@ -527,6 +528,13 @@ func (d *Daemon) dispatch(ctx context.Context, req rpc.Request) rpc.Response {
} }
image, err := d.DeleteImage(ctx, params.IDOrName) image, err := d.DeleteImage(ctx, params.IDOrName)
return marshalResultOrError(api.ImageShowResult{Image: image}, err) return marshalResultOrError(api.ImageShowResult{Image: image}, err)
case "image.pull":
params, err := rpc.DecodeParams[api.ImagePullParams](req)
if err != nil {
return rpc.NewError("bad_request", err.Error())
}
image, err := d.PullImage(ctx, params)
return marshalResultOrError(api.ImageShowResult{Image: image}, err)
case "kernel.list": case "kernel.list":
return marshalResultOrError(d.KernelList(ctx)) return marshalResultOrError(d.KernelList(ctx))
case "kernel.show": case "kernel.show":

View file

@ -21,17 +21,29 @@ import (
func ValidateRegisterPaths(rootfsPath, workSeedPath, kernelPath, initrdPath, modulesDir string) error { func ValidateRegisterPaths(rootfsPath, workSeedPath, kernelPath, initrdPath, modulesDir string) error {
checks := system.NewPreflight() checks := system.NewPreflight()
checks.RequireFile(rootfsPath, "rootfs image", `pass --rootfs <path>`) checks.RequireFile(rootfsPath, "rootfs image", `pass --rootfs <path>`)
checks.RequireFile(kernelPath, "kernel image", `pass --kernel <path>`)
if workSeedPath != "" { if workSeedPath != "" {
checks.RequireFile(workSeedPath, "work-seed image", `pass --work-seed <path> or rebuild the image with a work seed`) checks.RequireFile(workSeedPath, "work-seed image", `pass --work-seed <path> or rebuild the image with a work seed`)
} }
addKernelChecks(checks, kernelPath, initrdPath, modulesDir)
return checks.Err("image register failed")
}
// ValidateKernelPaths checks the kernel triple alone, used by flows
// (e.g. image pull) that produce the rootfs themselves.
func ValidateKernelPaths(kernelPath, initrdPath, modulesDir string) error {
checks := system.NewPreflight()
addKernelChecks(checks, kernelPath, initrdPath, modulesDir)
return checks.Err("kernel preflight failed")
}
func addKernelChecks(checks *system.Preflight, kernelPath, initrdPath, modulesDir string) {
checks.RequireFile(kernelPath, "kernel image", `pass --kernel <path>`)
if initrdPath != "" { if initrdPath != "" {
checks.RequireFile(initrdPath, "initrd image", `pass --initrd <path>`) checks.RequireFile(initrdPath, "initrd image", `pass --initrd <path>`)
} }
if modulesDir != "" { if modulesDir != "" {
checks.RequireDir(modulesDir, "kernel modules dir", `pass --modules <dir>`) checks.RequireDir(modulesDir, "kernel modules dir", `pass --modules <dir>`)
} }
return checks.Err("image register failed")
} }
// ValidatePromotePaths checks that an existing registered image's artifacts // ValidatePromotePaths checks that an existing registered image's artifacts

View file

@ -179,29 +179,9 @@ func (d *Daemon) RegisterImage(ctx context.Context, params api.ImageRegisterPara
} }
} }
} }
kernelPath := strings.TrimSpace(params.KernelPath) kernelPath, initrdPath, modulesDir, err := d.resolveKernelInputs(params.KernelRef, params.KernelPath, params.InitrdPath, params.ModulesDir)
initrdPath := strings.TrimSpace(params.InitrdPath) if err != nil {
modulesDir := strings.TrimSpace(params.ModulesDir) return model.Image{}, err
kernelRef := strings.TrimSpace(params.KernelRef)
if kernelRef != "" {
if kernelPath != "" || initrdPath != "" || modulesDir != "" {
return model.Image{}, fmt.Errorf("--kernel-ref is mutually exclusive with --kernel/--initrd/--modules")
}
entry, err := kernelcat.ReadLocal(d.layout.KernelsDir, kernelRef)
if err != nil {
if os.IsNotExist(err) {
return model.Image{}, fmt.Errorf("kernel %q not found in catalog; run 'banger kernel list' to see available entries", kernelRef)
}
return model.Image{}, fmt.Errorf("resolve kernel %q: %w", kernelRef, err)
}
kernelPath = entry.KernelPath
initrdPath = entry.InitrdPath
modulesDir = entry.ModulesDir
}
if kernelPath == "" {
return model.Image{}, fmt.Errorf("kernel path is required (pass --kernel <path> or --kernel-ref <name>)")
} }
if err := imagemgr.ValidateRegisterPaths(rootfsPath, workSeedPath, kernelPath, initrdPath, modulesDir); err != nil { if err := imagemgr.ValidateRegisterPaths(rootfsPath, workSeedPath, kernelPath, initrdPath, modulesDir); err != nil {
@ -391,3 +371,31 @@ func firstNonEmpty(values ...string) string {
} }
return "" return ""
} }
// resolveKernelInputs canonicalises user-supplied kernel info: either direct
// paths or a kernel-catalog ref. Shared by RegisterImage and PullImage.
func (d *Daemon) resolveKernelInputs(kernelRef, kernelPath, initrdPath, modulesDir string) (string, string, string, error) {
kernelRef = strings.TrimSpace(kernelRef)
kernelPath = strings.TrimSpace(kernelPath)
initrdPath = strings.TrimSpace(initrdPath)
modulesDir = strings.TrimSpace(modulesDir)
if kernelRef != "" {
if kernelPath != "" || initrdPath != "" || modulesDir != "" {
return "", "", "", fmt.Errorf("--kernel-ref is mutually exclusive with --kernel/--initrd/--modules")
}
entry, err := kernelcat.ReadLocal(d.layout.KernelsDir, kernelRef)
if err != nil {
if os.IsNotExist(err) {
return "", "", "", fmt.Errorf("kernel %q not found in catalog; run 'banger kernel list' to see available entries", kernelRef)
}
return "", "", "", fmt.Errorf("resolve kernel %q: %w", kernelRef, err)
}
return entry.KernelPath, entry.InitrdPath, entry.ModulesDir, nil
}
if kernelPath == "" {
return "", "", "", fmt.Errorf("kernel path is required (pass --kernel <path> or --kernel-ref <name>)")
}
return kernelPath, initrdPath, modulesDir, nil
}

View file

@ -0,0 +1,213 @@
package daemon
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
"banger/internal/api"
"banger/internal/daemon/imagemgr"
"banger/internal/imagepull"
"banger/internal/model"
"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 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.
//
// 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) {
d.imageOpsMu.Lock()
defer d.imageOpsMu.Unlock()
ref := strings.TrimSpace(params.Ref)
if ref == "" {
return model.Image{}, errors.New("oci reference is required")
}
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(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)
if err := d.runPullAndFlatten(ctx, ref, d.layout.OCICacheDir, rootfsTree); 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)
}
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
}
// runPullAndFlatten is the seam tests substitute. nil → real implementation.
func (d *Daemon) runPullAndFlatten(ctx context.Context, ref, cacheDir, destDir string) error {
if d.pullAndFlatten != nil {
return d.pullAndFlatten(ctx, ref, cacheDir, destDir)
}
pulled, err := imagepull.Pull(ctx, ref, cacheDir)
if err != nil {
return err
}
return imagepull.Flatten(ctx, pulled, destDir)
}
// 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
}

View file

@ -0,0 +1,191 @@
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)
}
}
}