daemon split (2/5): extract *ImageService service

Second phase of splitting the daemon god-struct. ImageService now owns
all image + kernel registry operations: register/promote/delete/pull
for images (bundle + OCI paths), the six kernel commands, and the
shared SSH-key/work-seed injection helpers. imageOpsMu (the
publication-window lock) lives on the service; so do the three OCI
pull test seams pullAndFlatten / finalizePulledRootfs / bundleFetch.
The four files images.go, images_pull.go, image_seed.go, kernels.go
flipped their receivers from *Daemon to *ImageService.

FindImage moved with the service. Daemon keeps a thin FindImage
forwarder so callers reading the dispatch code see the obvious
facade and tests that pre-date the split still compile.

flattenNestedWorkHome — called from image_seed.go, vm_authsync.go,
and vm_disk.go across future service boundaries — became a
package-level helper taking a CommandRunner explicitly. Daemon keeps
a deprecated forwarder for now; the other services will use the
package form.

Lazy-init helper imageSvc() on Daemon mirrors hostNet() from
Phase 1, so test literals like &Daemon{store: db, runner: r, ...}
that don't spell out an ImageService still get a working one.
Tests that override the image test seams (autopull_test,
concurrency_test, images_pull_test, images_pull_bundle_test) now
assign d.img = &ImageService{...seams...}; the two-statement pattern
matches what Phase 1 established for HostNetwork.

Dispatch in daemon.go is cleaner now: every image/kernel RPC handler
is a single-liner forwarding to d.imageSvc().*. Phase 5 will do the
same for VM lifecycle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thales Maciel 2026-04-20 20:30:32 -03:00
parent 362009d747
commit d7614a3b2b
No known key found for this signature in database
GPG key ID: 33112E6833C34679
15 changed files with 389 additions and 209 deletions

View file

@ -19,6 +19,11 @@ func TestFindOrAutoPullImageReturnsLocalWithoutPulling(t *testing.T) {
layout: paths.Layout{ImagesDir: t.TempDir()}, layout: paths.Layout{ImagesDir: t.TempDir()},
store: openDaemonStore(t), store: openDaemonStore(t),
runner: system.NewRunner(), runner: system.NewRunner(),
}
d.img = &ImageService{
layout: d.layout,
store: d.store,
runner: d.runner,
bundleFetch: func(context.Context, string, imagecat.CatEntry) (imagecat.Manifest, error) { bundleFetch: func(context.Context, string, imagecat.CatEntry) (imagecat.Manifest, error) {
t.Fatal("bundleFetch should not be called when image is local") t.Fatal("bundleFetch should not be called when image is local")
return imagecat.Manifest{}, nil return imagecat.Manifest{}, nil
@ -52,6 +57,11 @@ func TestFindOrAutoPullImagePullsFromCatalog(t *testing.T) {
layout: paths.Layout{ImagesDir: imagesDir, KernelsDir: kernelsDir}, layout: paths.Layout{ImagesDir: imagesDir, KernelsDir: kernelsDir},
store: openDaemonStore(t), store: openDaemonStore(t),
runner: system.NewRunner(), runner: system.NewRunner(),
}
d.img = &ImageService{
layout: d.layout,
store: d.store,
runner: d.runner,
bundleFetch: func(ctx context.Context, destDir string, entry imagecat.CatEntry) (imagecat.Manifest, error) { bundleFetch: func(ctx context.Context, destDir string, entry imagecat.CatEntry) (imagecat.Manifest, error) {
pullCalls++ pullCalls++
return stubBundleFetch(imagecat.Manifest{KernelRef: "generic-6.12"})(ctx, destDir, entry) return stubBundleFetch(imagecat.Manifest{KernelRef: "generic-6.12"})(ctx, destDir, entry)
@ -87,7 +97,7 @@ func TestReadOrAutoPullKernelReturnsLocalWithoutPulling(t *testing.T) {
seedKernel(t, kernelsDir, "generic-6.12") seedKernel(t, kernelsDir, "generic-6.12")
d := &Daemon{layout: paths.Layout{KernelsDir: kernelsDir}} d := &Daemon{layout: paths.Layout{KernelsDir: kernelsDir}}
entry, err := d.readOrAutoPullKernel(context.Background(), "generic-6.12") entry, err := d.imageSvc().readOrAutoPullKernel(context.Background(), "generic-6.12")
if err != nil { if err != nil {
t.Fatalf("readOrAutoPullKernel: %v", err) t.Fatalf("readOrAutoPullKernel: %v", err)
} }
@ -98,7 +108,7 @@ func TestReadOrAutoPullKernelReturnsLocalWithoutPulling(t *testing.T) {
func TestReadOrAutoPullKernelErrorsWhenNotInCatalog(t *testing.T) { func TestReadOrAutoPullKernelErrorsWhenNotInCatalog(t *testing.T) {
d := &Daemon{layout: paths.Layout{KernelsDir: t.TempDir()}} d := &Daemon{layout: paths.Layout{KernelsDir: t.TempDir()}}
_, err := d.readOrAutoPullKernel(context.Background(), "nonexistent-kernel") _, err := d.imageSvc().readOrAutoPullKernel(context.Background(), "nonexistent-kernel")
if err == nil || !strings.Contains(err.Error(), "not found") { if err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("err = %v, want not-found", err) t.Fatalf("err = %v, want not-found", err)
} }
@ -120,7 +130,7 @@ func TestReadOrAutoPullKernelSurfacesNonNotExistError(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
d := &Daemon{layout: paths.Layout{KernelsDir: kernelsDir}} d := &Daemon{layout: paths.Layout{KernelsDir: kernelsDir}}
_, err := d.readOrAutoPullKernel(context.Background(), "broken-kernel") _, err := d.imageSvc().readOrAutoPullKernel(context.Background(), "broken-kernel")
if err == nil { if err == nil {
t.Fatal("want error") t.Fatal("want error")
} }

View file

@ -65,9 +65,14 @@ func TestPullImageDoesNotSerialiseOnDifferentNames(t *testing.T) {
} }
d := &Daemon{ d := &Daemon{
layout: paths.Layout{ImagesDir: imagesDir, OCICacheDir: cacheDir}, layout: paths.Layout{ImagesDir: imagesDir, OCICacheDir: cacheDir},
store: openDaemonStore(t), store: openDaemonStore(t),
runner: system.NewRunner(), runner: system.NewRunner(),
}
d.img = &ImageService{
layout: d.layout,
store: d.store,
runner: d.runner,
pullAndFlatten: slowPullAndFlatten, pullAndFlatten: slowPullAndFlatten,
finalizePulledRootfs: stubFinalizePulledRootfs, finalizePulledRootfs: stubFinalizePulledRootfs,
} }
@ -88,7 +93,7 @@ func TestPullImageDoesNotSerialiseOnDifferentNames(t *testing.T) {
wg.Add(1) wg.Add(1)
go func(i int, name string) { go func(i int, name string) {
defer wg.Done() defer wg.Done()
_, err := d.PullImage(context.Background(), mkParams(name)) _, err := d.img.PullImage(context.Background(), mkParams(name))
errs[i] = err errs[i] = err
}(i, name) }(i, name)
} }
@ -146,9 +151,14 @@ func TestPullImageRejectsNameClashAtPublish(t *testing.T) {
} }
d := &Daemon{ d := &Daemon{
layout: paths.Layout{ImagesDir: imagesDir, OCICacheDir: cacheDir}, layout: paths.Layout{ImagesDir: imagesDir, OCICacheDir: cacheDir},
store: openDaemonStore(t), store: openDaemonStore(t),
runner: system.NewRunner(), runner: system.NewRunner(),
}
d.img = &ImageService{
layout: d.layout,
store: d.store,
runner: d.runner,
pullAndFlatten: pullAndFlatten, pullAndFlatten: pullAndFlatten,
finalizePulledRootfs: stubFinalizePulledRootfs, finalizePulledRootfs: stubFinalizePulledRootfs,
} }
@ -167,7 +177,7 @@ func TestPullImageRejectsNameClashAtPublish(t *testing.T) {
wg.Add(1) wg.Add(1)
go func(i int) { go func(i int) {
defer wg.Done() defer wg.Done()
_, err := d.PullImage(context.Background(), params) _, err := d.img.PullImage(context.Background(), params)
errs[i] = err errs[i] = err
}(i) }(i)
} }

View file

@ -19,8 +19,6 @@ import (
"banger/internal/config" "banger/internal/config"
"banger/internal/daemon/opstate" "banger/internal/daemon/opstate"
ws "banger/internal/daemon/workspace" ws "banger/internal/daemon/workspace"
"banger/internal/imagecat"
"banger/internal/imagepull"
"banger/internal/model" "banger/internal/model"
"banger/internal/paths" "banger/internal/paths"
"banger/internal/rpc" "banger/internal/rpc"
@ -35,7 +33,6 @@ type Daemon struct {
store *store.Store store *store.Store
runner system.CommandRunner runner system.CommandRunner
logger *slog.Logger logger *slog.Logger
imageOpsMu sync.Mutex
createVMMu sync.Mutex createVMMu sync.Mutex
createOps opstate.Registry[*vmCreateOperationState] createOps opstate.Registry[*vmCreateOperationState]
vmLocks vmLockSet vmLocks vmLockSet
@ -53,14 +50,12 @@ type Daemon struct {
// handles.json scratch file and OS inspection. // handles.json scratch file and OS inspection.
handles *handleCache handles *handleCache
net *HostNetwork net *HostNetwork
img *ImageService
closing chan struct{} closing chan struct{}
once sync.Once once sync.Once
pid int pid int
listener net.Listener listener net.Listener
vmCaps []vmCapability vmCaps []vmCapability
pullAndFlatten func(ctx context.Context, ref, cacheDir, destDir string) (imagepull.Metadata, error)
finalizePulledRootfs func(ctx context.Context, ext4File string, meta imagepull.Metadata) error
bundleFetch func(ctx context.Context, destDir string, entry imagecat.CatEntry) (imagecat.Manifest, 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)
@ -449,68 +444,68 @@ func (d *Daemon) dispatch(ctx context.Context, req rpc.Request) rpc.Response {
if err != nil { if err != nil {
return rpc.NewError("bad_request", err.Error()) return rpc.NewError("bad_request", err.Error())
} }
image, err := d.FindImage(ctx, params.IDOrName) image, err := d.imageSvc().FindImage(ctx, params.IDOrName)
return marshalResultOrError(api.ImageShowResult{Image: image}, err) return marshalResultOrError(api.ImageShowResult{Image: image}, err)
case "image.register": case "image.register":
params, err := rpc.DecodeParams[api.ImageRegisterParams](req) params, err := rpc.DecodeParams[api.ImageRegisterParams](req)
if err != nil { if err != nil {
return rpc.NewError("bad_request", err.Error()) return rpc.NewError("bad_request", err.Error())
} }
image, err := d.RegisterImage(ctx, params) image, err := d.imageSvc().RegisterImage(ctx, params)
return marshalResultOrError(api.ImageShowResult{Image: image}, err) return marshalResultOrError(api.ImageShowResult{Image: image}, err)
case "image.promote": case "image.promote":
params, err := rpc.DecodeParams[api.ImageRefParams](req) params, err := rpc.DecodeParams[api.ImageRefParams](req)
if err != nil { if err != nil {
return rpc.NewError("bad_request", err.Error()) return rpc.NewError("bad_request", err.Error())
} }
image, err := d.PromoteImage(ctx, params.IDOrName) image, err := d.imageSvc().PromoteImage(ctx, params.IDOrName)
return marshalResultOrError(api.ImageShowResult{Image: image}, err) return marshalResultOrError(api.ImageShowResult{Image: image}, err)
case "image.delete": case "image.delete":
params, err := rpc.DecodeParams[api.ImageRefParams](req) params, err := rpc.DecodeParams[api.ImageRefParams](req)
if err != nil { if err != nil {
return rpc.NewError("bad_request", err.Error()) return rpc.NewError("bad_request", err.Error())
} }
image, err := d.DeleteImage(ctx, params.IDOrName) image, err := d.imageSvc().DeleteImage(ctx, params.IDOrName)
return marshalResultOrError(api.ImageShowResult{Image: image}, err) return marshalResultOrError(api.ImageShowResult{Image: image}, err)
case "image.pull": case "image.pull":
params, err := rpc.DecodeParams[api.ImagePullParams](req) params, err := rpc.DecodeParams[api.ImagePullParams](req)
if err != nil { if err != nil {
return rpc.NewError("bad_request", err.Error()) return rpc.NewError("bad_request", err.Error())
} }
image, err := d.PullImage(ctx, params) image, err := d.imageSvc().PullImage(ctx, params)
return marshalResultOrError(api.ImageShowResult{Image: image}, err) return marshalResultOrError(api.ImageShowResult{Image: image}, err)
case "kernel.list": case "kernel.list":
return marshalResultOrError(d.KernelList(ctx)) return marshalResultOrError(d.imageSvc().KernelList(ctx))
case "kernel.show": case "kernel.show":
params, err := rpc.DecodeParams[api.KernelRefParams](req) params, err := rpc.DecodeParams[api.KernelRefParams](req)
if err != nil { if err != nil {
return rpc.NewError("bad_request", err.Error()) return rpc.NewError("bad_request", err.Error())
} }
entry, err := d.KernelShow(ctx, params.Name) entry, err := d.imageSvc().KernelShow(ctx, params.Name)
return marshalResultOrError(api.KernelShowResult{Entry: entry}, err) return marshalResultOrError(api.KernelShowResult{Entry: entry}, err)
case "kernel.delete": case "kernel.delete":
params, err := rpc.DecodeParams[api.KernelRefParams](req) params, err := rpc.DecodeParams[api.KernelRefParams](req)
if err != nil { if err != nil {
return rpc.NewError("bad_request", err.Error()) return rpc.NewError("bad_request", err.Error())
} }
err = d.KernelDelete(ctx, params.Name) err = d.imageSvc().KernelDelete(ctx, params.Name)
return marshalResultOrError(api.Empty{}, err) return marshalResultOrError(api.Empty{}, err)
case "kernel.import": case "kernel.import":
params, err := rpc.DecodeParams[api.KernelImportParams](req) params, err := rpc.DecodeParams[api.KernelImportParams](req)
if err != nil { if err != nil {
return rpc.NewError("bad_request", err.Error()) return rpc.NewError("bad_request", err.Error())
} }
entry, err := d.KernelImport(ctx, params) entry, err := d.imageSvc().KernelImport(ctx, params)
return marshalResultOrError(api.KernelShowResult{Entry: entry}, err) return marshalResultOrError(api.KernelShowResult{Entry: entry}, err)
case "kernel.pull": case "kernel.pull":
params, err := rpc.DecodeParams[api.KernelPullParams](req) params, err := rpc.DecodeParams[api.KernelPullParams](req)
if err != nil { if err != nil {
return rpc.NewError("bad_request", err.Error()) return rpc.NewError("bad_request", err.Error())
} }
entry, err := d.KernelPull(ctx, params) entry, err := d.imageSvc().KernelPull(ctx, params)
return marshalResultOrError(api.KernelShowResult{Entry: entry}, err) return marshalResultOrError(api.KernelShowResult{Entry: entry}, err)
case "kernel.catalog": case "kernel.catalog":
return marshalResultOrError(d.KernelCatalog(ctx)) return marshalResultOrError(d.imageSvc().KernelCatalog(ctx))
default: default:
return rpc.NewError("unknown_method", req.Method) return rpc.NewError("unknown_method", req.Method)
} }
@ -619,35 +614,11 @@ func (d *Daemon) FindVM(ctx context.Context, idOrName string) (model.VMRecord, e
return model.VMRecord{}, fmt.Errorf("vm %q not found", idOrName) return model.VMRecord{}, fmt.Errorf("vm %q not found", idOrName)
} }
// FindImage stays on Daemon as a thin forwarder to the image service
// lookup so callers reading dispatch code see the obvious facade, and
// tests that pre-date the service split still compile.
func (d *Daemon) FindImage(ctx context.Context, idOrName string) (model.Image, error) { func (d *Daemon) FindImage(ctx context.Context, idOrName string) (model.Image, error) {
if idOrName == "" { return d.imageSvc().FindImage(ctx, idOrName)
return model.Image{}, errors.New("image id or name is required")
}
if image, err := d.store.GetImageByName(ctx, idOrName); err == nil {
return image, nil
}
if image, err := d.store.GetImageByID(ctx, idOrName); err == nil {
return image, nil
}
images, err := d.store.ListImages(ctx)
if err != nil {
return model.Image{}, err
}
matchCount := 0
var match model.Image
for _, image := range images {
if strings.HasPrefix(image.ID, idOrName) || strings.HasPrefix(image.Name, idOrName) {
match = image
matchCount++
}
}
if matchCount == 1 {
return match, nil
}
if matchCount > 1 {
return model.Image{}, fmt.Errorf("multiple images match %q", idOrName)
}
return model.Image{}, fmt.Errorf("image %q not found", idOrName)
} }
func (d *Daemon) TouchVM(ctx context.Context, idOrName string) (model.VMRecord, error) { func (d *Daemon) TouchVM(ctx context.Context, idOrName string) (model.VMRecord, error) {

View file

@ -23,7 +23,7 @@ func TestRegisterImageRequiresKernel(t *testing.T) {
} }
d := &Daemon{store: openDaemonStore(t)} d := &Daemon{store: openDaemonStore(t)}
_, err := d.RegisterImage(context.Background(), api.ImageRegisterParams{ _, err := d.imageSvc().RegisterImage(context.Background(), api.ImageRegisterParams{
Name: "missing-kernel", Name: "missing-kernel",
RootfsPath: rootfs, RootfsPath: rootfs,
}) })
@ -100,7 +100,7 @@ func TestPromoteImageCopiesBootArtifactsIntoArtifactDir(t *testing.T) {
store: db, store: db,
runner: system.NewRunner(), runner: system.NewRunner(),
} }
got, err := d.PromoteImage(context.Background(), image.Name) got, err := d.imageSvc().PromoteImage(context.Background(), image.Name)
if err != nil { if err != nil {
t.Fatalf("PromoteImage: %v", err) t.Fatalf("PromoteImage: %v", err)
} }

View file

@ -12,48 +12,48 @@ import (
"banger/internal/system" "banger/internal/system"
) )
func (d *Daemon) seedAuthorizedKeyOnExt4Image(ctx context.Context, imagePath string) (string, error) { func (s *ImageService) seedAuthorizedKeyOnExt4Image(ctx context.Context, imagePath string) (string, error) {
if strings.TrimSpace(d.config.SSHKeyPath) == "" { if strings.TrimSpace(s.config.SSHKeyPath) == "" {
return "", nil return "", nil
} }
fingerprint, err := guest.AuthorizedPublicKeyFingerprint(d.config.SSHKeyPath) fingerprint, err := guest.AuthorizedPublicKeyFingerprint(s.config.SSHKeyPath)
if err != nil { if err != nil {
return "", fmt.Errorf("derive authorized ssh key fingerprint: %w", err) return "", fmt.Errorf("derive authorized ssh key fingerprint: %w", err)
} }
publicKey, err := guest.AuthorizedPublicKey(d.config.SSHKeyPath) publicKey, err := guest.AuthorizedPublicKey(s.config.SSHKeyPath)
if err != nil { if err != nil {
return "", fmt.Errorf("derive authorized ssh key: %w", err) return "", fmt.Errorf("derive authorized ssh key: %w", err)
} }
mountDir, cleanup, err := system.MountTempDir(ctx, d.runner, imagePath, false) mountDir, cleanup, err := system.MountTempDir(ctx, s.runner, imagePath, false)
if err != nil { if err != nil {
return "", err return "", err
} }
defer cleanup() defer cleanup()
if err := d.flattenNestedWorkHome(ctx, mountDir); err != nil { if err := flattenNestedWorkHome(ctx, s.runner, mountDir); err != nil {
return "", err return "", err
} }
// Same rationale as in ensureAuthorizedKeyOnWorkDisk — the seed's // Same rationale as in ensureAuthorizedKeyOnWorkDisk — the seed's
// filesystem root becomes /root inside the guest, and sshd's // filesystem root becomes /root inside the guest, and sshd's
// StrictModes check walks its ownership and mode. // StrictModes check walks its ownership and mode.
if err := normaliseHomeDirPerms(ctx, d.runner, mountDir); err != nil { if err := normaliseHomeDirPerms(ctx, s.runner, mountDir); err != nil {
return "", err return "", err
} }
sshDir := filepath.Join(mountDir, ".ssh") sshDir := filepath.Join(mountDir, ".ssh")
if _, err := d.runner.RunSudo(ctx, "mkdir", "-p", sshDir); err != nil { if _, err := s.runner.RunSudo(ctx, "mkdir", "-p", sshDir); err != nil {
return "", err return "", err
} }
if _, err := d.runner.RunSudo(ctx, "chmod", "700", sshDir); err != nil { if _, err := s.runner.RunSudo(ctx, "chmod", "700", sshDir); err != nil {
return "", err return "", err
} }
if _, err := d.runner.RunSudo(ctx, "chown", "0:0", sshDir); err != nil { if _, err := s.runner.RunSudo(ctx, "chown", "0:0", sshDir); err != nil {
return "", err return "", err
} }
authorizedKeysPath := filepath.Join(sshDir, "authorized_keys") authorizedKeysPath := filepath.Join(sshDir, "authorized_keys")
existing, err := d.runner.RunSudo(ctx, "cat", authorizedKeysPath) existing, err := s.runner.RunSudo(ctx, "cat", authorizedKeysPath)
if err != nil { if err != nil {
existing = nil existing = nil
} }
@ -73,17 +73,17 @@ func (d *Daemon) seedAuthorizedKeyOnExt4Image(ctx context.Context, imagePath str
return "", err return "", err
} }
defer os.Remove(tmpPath) defer os.Remove(tmpPath)
if _, err := d.runner.RunSudo(ctx, "install", "-m", "600", tmpPath, authorizedKeysPath); err != nil { if _, err := s.runner.RunSudo(ctx, "install", "-m", "600", tmpPath, authorizedKeysPath); err != nil {
return "", err return "", err
} }
return fingerprint, nil return fingerprint, nil
} }
func (d *Daemon) refreshManagedWorkSeedFingerprint(ctx context.Context, image model.Image, fingerprint string) error { func (s *ImageService) refreshManagedWorkSeedFingerprint(ctx context.Context, image model.Image, fingerprint string) error {
if !image.Managed || strings.TrimSpace(image.WorkSeedPath) == "" || strings.TrimSpace(fingerprint) == "" { if !image.Managed || strings.TrimSpace(image.WorkSeedPath) == "" || strings.TrimSpace(fingerprint) == "" {
return nil return nil
} }
seededFingerprint, err := d.seedAuthorizedKeyOnExt4Image(ctx, image.WorkSeedPath) seededFingerprint, err := s.seedAuthorizedKeyOnExt4Image(ctx, image.WorkSeedPath)
if err != nil { if err != nil {
return err return err
} }
@ -92,5 +92,5 @@ func (d *Daemon) refreshManagedWorkSeedFingerprint(ctx context.Context, image mo
} }
image.SeededSSHPublicKeyFingerprint = seededFingerprint image.SeededSSHPublicKeyFingerprint = seededFingerprint
image.UpdatedAt = model.Now() image.UpdatedAt = model.Now()
return d.store.UpsertImage(ctx, image) return s.store.UpsertImage(ctx, image)
} }

View file

@ -0,0 +1,129 @@
package daemon
import (
"context"
"fmt"
"log/slog"
"strings"
"sync"
"banger/internal/imagecat"
"banger/internal/imagepull"
"banger/internal/model"
"banger/internal/paths"
"banger/internal/store"
"banger/internal/system"
)
// ImageService owns everything image-registry-related: register /
// promote / delete / pull (bundle + OCI), plus the kernel catalog
// operations that share the same lifecycle primitives. The publication
// lock imageOpsMu lives here so its scope is obvious at the field
// definition, and the three OCI-pull test seams (pullAndFlatten,
// finalizePulledRootfs, bundleFetch) are fields on the service rather
// than mutable globals on Daemon.
//
// Kept unexported except where peer services (VMService) need it, and
// peer access goes through consumer-defined interfaces, not direct
// struct poking.
type ImageService struct {
runner system.CommandRunner
logger *slog.Logger
config model.DaemonConfig
layout paths.Layout
store *store.Store
// imageOpsMu is the publication-window lock: held only across the
// "recheck name free + atomic rename + UpsertImage" commit. See
// internal/daemon/ARCHITECTURE.md.
imageOpsMu sync.Mutex
// Test seams; nil → real implementation.
pullAndFlatten func(ctx context.Context, ref, cacheDir, destDir string) (imagepull.Metadata, error)
finalizePulledRootfs func(ctx context.Context, ext4File string, meta imagepull.Metadata) error
bundleFetch func(ctx context.Context, destDir string, entry imagecat.CatEntry) (imagecat.Manifest, error)
// beginOperation is a test seam used by a couple of image ops that
// want structured operation logging. Nil → Daemon's beginOperation,
// injected at construction.
beginOperation func(name string, attrs ...any) *operationLog
}
// imageServiceDeps names every handle ImageService needs from the
// Daemon composition root. Using a struct (rather than positional args)
// makes the wiring site in Daemon.Open read as a declaration.
type imageServiceDeps struct {
runner system.CommandRunner
logger *slog.Logger
config model.DaemonConfig
layout paths.Layout
store *store.Store
beginOperation func(name string, attrs ...any) *operationLog
}
func newImageService(deps imageServiceDeps) *ImageService {
return &ImageService{
runner: deps.runner,
logger: deps.logger,
config: deps.config,
layout: deps.layout,
store: deps.store,
beginOperation: deps.beginOperation,
}
}
// FindImage is the service-owned lookup helper. It falls back from
// exact-name → exact-id → prefix match, matching the historical
// daemon.FindImage behaviour. Kept on ImageService because image
// lookup is inherently a service concern.
func (s *ImageService) FindImage(ctx context.Context, idOrName string) (model.Image, error) {
if idOrName == "" {
return model.Image{}, fmt.Errorf("image id or name is required")
}
if image, err := s.store.GetImageByName(ctx, idOrName); err == nil {
return image, nil
}
if image, err := s.store.GetImageByID(ctx, idOrName); err == nil {
return image, nil
}
images, err := s.store.ListImages(ctx)
if err != nil {
return model.Image{}, err
}
matchCount := 0
var match model.Image
for _, image := range images {
if strings.HasPrefix(image.ID, idOrName) || strings.HasPrefix(image.Name, idOrName) {
match = image
matchCount++
}
}
if matchCount == 1 {
return match, nil
}
if matchCount > 1 {
return model.Image{}, fmt.Errorf("multiple images match %q", idOrName)
}
return model.Image{}, fmt.Errorf("image %q not found", idOrName)
}
// imageSvc is the Daemon-side getter that lazy-inits ImageService from
// current Daemon fields. Mirrors hostNet() so test literals can keep
// using `&Daemon{store: db, runner: r, ...}` and still end up with a
// working ImageService.
func (d *Daemon) imageSvc() *ImageService {
if d.img != nil {
return d.img
}
d.img = newImageService(imageServiceDeps{
runner: d.runner,
logger: d.logger,
config: d.config,
layout: d.layout,
store: d.store,
beginOperation: func(name string, attrs ...any) *operationLog {
return d.beginOperation(name, attrs...)
},
})
return d.img
}

View file

@ -20,7 +20,7 @@ import (
// validation + kernel resolution run without imageOpsMu — only the // validation + kernel resolution run without imageOpsMu — only the
// lookup-then-upsert atom is held under the lock so concurrent // lookup-then-upsert atom is held under the lock so concurrent
// registers of the same name don't race. // registers of the same name don't race.
func (d *Daemon) RegisterImage(ctx context.Context, params api.ImageRegisterParams) (image model.Image, err error) { func (s *ImageService) RegisterImage(ctx context.Context, params api.ImageRegisterParams) (image model.Image, err error) {
name := strings.TrimSpace(params.Name) name := strings.TrimSpace(params.Name)
if name == "" { if name == "" {
return model.Image{}, fmt.Errorf("image name is required") return model.Image{}, fmt.Errorf("image name is required")
@ -39,7 +39,7 @@ func (d *Daemon) RegisterImage(ctx context.Context, params api.ImageRegisterPara
} }
} }
} }
kernelPath, initrdPath, modulesDir, err := d.resolveKernelInputs(ctx, params.KernelRef, params.KernelPath, params.InitrdPath, params.ModulesDir) kernelPath, initrdPath, modulesDir, err := s.resolveKernelInputs(ctx, params.KernelRef, params.KernelPath, params.InitrdPath, params.ModulesDir)
if err != nil { if err != nil {
return model.Image{}, err return model.Image{}, err
} }
@ -48,11 +48,11 @@ func (d *Daemon) RegisterImage(ctx context.Context, params api.ImageRegisterPara
return model.Image{}, err return model.Image{}, err
} }
d.imageOpsMu.Lock() s.imageOpsMu.Lock()
defer d.imageOpsMu.Unlock() defer s.imageOpsMu.Unlock()
now := model.Now() now := model.Now()
existing, lookupErr := d.store.GetImageByName(ctx, name) existing, lookupErr := s.store.GetImageByName(ctx, name)
switch { switch {
case lookupErr == nil: case lookupErr == nil:
if existing.Managed { if existing.Managed {
@ -88,7 +88,7 @@ func (d *Daemon) RegisterImage(ctx context.Context, params api.ImageRegisterPara
return model.Image{}, lookupErr return model.Image{}, lookupErr
} }
if err := d.store.UpsertImage(ctx, image); err != nil { if err := s.store.UpsertImage(ctx, image); err != nil {
return model.Image{}, err return model.Image{}, err
} }
return image, nil return image, nil
@ -99,8 +99,8 @@ func (d *Daemon) RegisterImage(ctx context.Context, params api.ImageRegisterPara
// SSH-key seeding, and boot-artifact staging all happen outside // SSH-key seeding, and boot-artifact staging all happen outside
// imageOpsMu — only the find/rename/upsert commit atom holds the // imageOpsMu — only the find/rename/upsert commit atom holds the
// lock. // lock.
func (d *Daemon) PromoteImage(ctx context.Context, idOrName string) (image model.Image, err error) { func (s *ImageService) PromoteImage(ctx context.Context, idOrName string) (image model.Image, err error) {
op := d.beginOperation("image.promote") op := s.beginOperation("image.promote")
defer func() { defer func() {
if err != nil { if err != nil {
op.fail(err, imageLogAttrs(image)...) op.fail(err, imageLogAttrs(image)...)
@ -109,7 +109,7 @@ func (d *Daemon) PromoteImage(ctx context.Context, idOrName string) (image model
op.done(imageLogAttrs(image)...) op.done(imageLogAttrs(image)...)
}() }()
image, err = d.FindImage(ctx, idOrName) image, err = s.FindImage(ctx, idOrName)
if err != nil { if err != nil {
return model.Image{}, err return model.Image{}, err
} }
@ -119,21 +119,21 @@ func (d *Daemon) PromoteImage(ctx context.Context, idOrName string) (image model
if err := imagemgr.ValidatePromotePaths(image.RootfsPath, image.KernelPath, image.InitrdPath, image.ModulesDir); err != nil { if err := imagemgr.ValidatePromotePaths(image.RootfsPath, image.KernelPath, image.InitrdPath, image.ModulesDir); err != nil {
return model.Image{}, err return model.Image{}, err
} }
if strings.TrimSpace(d.layout.ImagesDir) == "" { if strings.TrimSpace(s.layout.ImagesDir) == "" {
return model.Image{}, errors.New("images dir is not configured") return model.Image{}, errors.New("images dir is not configured")
} }
if err := os.MkdirAll(d.layout.ImagesDir, 0o755); err != nil { if err := os.MkdirAll(s.layout.ImagesDir, 0o755); err != nil {
return model.Image{}, err return model.Image{}, err
} }
artifactDir := filepath.Join(d.layout.ImagesDir, image.ID) artifactDir := filepath.Join(s.layout.ImagesDir, image.ID)
if _, statErr := os.Stat(artifactDir); statErr == nil { if _, statErr := os.Stat(artifactDir); statErr == nil {
return model.Image{}, fmt.Errorf("artifact dir already exists: %s", artifactDir) return model.Image{}, fmt.Errorf("artifact dir already exists: %s", artifactDir)
} else if !os.IsNotExist(statErr) { } else if !os.IsNotExist(statErr) {
return model.Image{}, statErr return model.Image{}, statErr
} }
stageDir, err := os.MkdirTemp(d.layout.ImagesDir, image.ID+".promote-") stageDir, err := os.MkdirTemp(s.layout.ImagesDir, image.ID+".promote-")
if err != nil { if err != nil {
return model.Image{}, err return model.Image{}, err
} }
@ -167,14 +167,14 @@ func (d *Daemon) PromoteImage(ctx context.Context, idOrName string) (image model
if err := system.CopyFilePreferClone(image.WorkSeedPath, workSeedPath); err != nil { if err := system.CopyFilePreferClone(image.WorkSeedPath, workSeedPath); err != nil {
return model.Image{}, err return model.Image{}, err
} }
image.SeededSSHPublicKeyFingerprint, err = d.seedAuthorizedKeyOnExt4Image(ctx, workSeedPath) image.SeededSSHPublicKeyFingerprint, err = s.seedAuthorizedKeyOnExt4Image(ctx, workSeedPath)
if err != nil { if err != nil {
return model.Image{}, err return model.Image{}, err
} }
} else { } else {
image.SeededSSHPublicKeyFingerprint = "" image.SeededSSHPublicKeyFingerprint = ""
} }
_, initrdPath, modulesDir, err := imagemgr.StageBootArtifacts(ctx, d.runner, stageDir, image.KernelPath, image.InitrdPath, image.ModulesDir) _, initrdPath, modulesDir, err := imagemgr.StageBootArtifacts(ctx, s.runner, stageDir, image.KernelPath, image.InitrdPath, image.ModulesDir)
if err != nil { if err != nil {
return model.Image{}, err return model.Image{}, err
} }
@ -191,13 +191,13 @@ func (d *Daemon) PromoteImage(ctx context.Context, idOrName string) (image model
image.UpdatedAt = model.Now() image.UpdatedAt = model.Now()
op.stage("activate_artifacts", "artifact_dir", artifactDir) op.stage("activate_artifacts", "artifact_dir", artifactDir)
d.imageOpsMu.Lock() s.imageOpsMu.Lock()
defer d.imageOpsMu.Unlock() defer s.imageOpsMu.Unlock()
if err := os.Rename(stageDir, artifactDir); err != nil { if err := os.Rename(stageDir, artifactDir); err != nil {
return model.Image{}, err return model.Image{}, err
} }
cleanupStage = false cleanupStage = false
if err := d.store.UpsertImage(ctx, image); err != nil { if err := s.store.UpsertImage(ctx, image); err != nil {
_ = os.RemoveAll(artifactDir) _ = os.RemoveAll(artifactDir)
return model.Image{}, err return model.Image{}, err
} }
@ -208,22 +208,22 @@ func (d *Daemon) PromoteImage(ctx context.Context, idOrName string) (image model
// imageOpsMu so a concurrent CreateVM can't slip an image_id reference // imageOpsMu so a concurrent CreateVM can't slip an image_id reference
// in between the check and the delete. File cleanup happens after the // in between the check and the delete. File cleanup happens after the
// lock is released — the store row is the authoritative handle. // lock is released — the store row is the authoritative handle.
func (d *Daemon) DeleteImage(ctx context.Context, idOrName string) (model.Image, error) { func (s *ImageService) DeleteImage(ctx context.Context, idOrName string) (model.Image, error) {
image, err := func() (model.Image, error) { image, err := func() (model.Image, error) {
d.imageOpsMu.Lock() s.imageOpsMu.Lock()
defer d.imageOpsMu.Unlock() defer s.imageOpsMu.Unlock()
img, err := d.FindImage(ctx, idOrName) img, err := s.FindImage(ctx, idOrName)
if err != nil { if err != nil {
return model.Image{}, err return model.Image{}, err
} }
vms, err := d.store.FindVMsUsingImage(ctx, img.ID) vms, err := s.store.FindVMsUsingImage(ctx, img.ID)
if err != nil { if err != nil {
return model.Image{}, err return model.Image{}, err
} }
if len(vms) > 0 { if len(vms) > 0 {
return model.Image{}, fmt.Errorf("image %s is still referenced by %d VM(s)", img.Name, len(vms)) return model.Image{}, fmt.Errorf("image %s is still referenced by %d VM(s)", img.Name, len(vms))
} }
if err := d.store.DeleteImage(ctx, img.ID); err != nil { if err := s.store.DeleteImage(ctx, img.ID); err != nil {
return model.Image{}, err return model.Image{}, err
} }
return img, nil return img, nil
@ -253,7 +253,7 @@ func firstNonEmpty(values ...string) string {
// When kernelRef is given but not yet pulled locally, an auto-pull from the // When kernelRef is given but not yet pulled locally, an auto-pull from the
// embedded kernelcat catalog fires so the caller doesn't have to manage // embedded kernelcat catalog fires so the caller doesn't have to manage
// kernel/image ordering by hand. // kernel/image ordering by hand.
func (d *Daemon) resolveKernelInputs(ctx context.Context, kernelRef, kernelPath, initrdPath, modulesDir string) (string, string, string, error) { func (s *ImageService) resolveKernelInputs(ctx context.Context, kernelRef, kernelPath, initrdPath, modulesDir string) (string, string, string, error) {
kernelRef = strings.TrimSpace(kernelRef) kernelRef = strings.TrimSpace(kernelRef)
kernelPath = strings.TrimSpace(kernelPath) kernelPath = strings.TrimSpace(kernelPath)
initrdPath = strings.TrimSpace(initrdPath) initrdPath = strings.TrimSpace(initrdPath)
@ -263,7 +263,7 @@ func (d *Daemon) resolveKernelInputs(ctx context.Context, kernelRef, kernelPath,
if kernelPath != "" || initrdPath != "" || modulesDir != "" { if kernelPath != "" || initrdPath != "" || modulesDir != "" {
return "", "", "", fmt.Errorf("--kernel-ref is mutually exclusive with --kernel/--initrd/--modules") return "", "", "", fmt.Errorf("--kernel-ref is mutually exclusive with --kernel/--initrd/--modules")
} }
entry, err := d.readOrAutoPullKernel(ctx, kernelRef) entry, err := s.readOrAutoPullKernel(ctx, kernelRef)
if err != nil { if err != nil {
return "", "", "", err return "", "", "", err
} }
@ -278,8 +278,8 @@ func (d *Daemon) resolveKernelInputs(ctx context.Context, kernelRef, kernelPath,
// readOrAutoPullKernel tries the local kernelcat first; on miss, checks // readOrAutoPullKernel tries the local kernelcat first; on miss, checks
// the embedded catalog and auto-pulls the bundle. // the embedded catalog and auto-pulls the bundle.
func (d *Daemon) readOrAutoPullKernel(ctx context.Context, kernelRef string) (kernelcat.Entry, error) { func (s *ImageService) readOrAutoPullKernel(ctx context.Context, kernelRef string) (kernelcat.Entry, error) {
entry, err := kernelcat.ReadLocal(d.layout.KernelsDir, kernelRef) entry, err := kernelcat.ReadLocal(s.layout.KernelsDir, kernelRef)
if err == nil { if err == nil {
return entry, nil return entry, nil
} }
@ -294,8 +294,8 @@ func (d *Daemon) readOrAutoPullKernel(ctx context.Context, kernelRef string) (ke
return kernelcat.Entry{}, fmt.Errorf("kernel %q not found in catalog; run 'banger kernel list --available' to browse", kernelRef) return kernelcat.Entry{}, fmt.Errorf("kernel %q not found in catalog; run 'banger kernel list --available' to browse", kernelRef)
} }
vmCreateStage(ctx, "auto_pull_kernel", fmt.Sprintf("pulling kernel %s from catalog", kernelRef)) vmCreateStage(ctx, "auto_pull_kernel", fmt.Sprintf("pulling kernel %s from catalog", kernelRef))
if _, pullErr := d.KernelPull(ctx, api.KernelPullParams{Name: kernelRef}); pullErr != nil { if _, pullErr := s.KernelPull(ctx, api.KernelPullParams{Name: kernelRef}); pullErr != nil {
return kernelcat.Entry{}, fmt.Errorf("auto-pull kernel %q: %w", kernelRef, pullErr) return kernelcat.Entry{}, fmt.Errorf("auto-pull kernel %q: %w", kernelRef, pullErr)
} }
return kernelcat.ReadLocal(d.layout.KernelsDir, kernelRef) return kernelcat.ReadLocal(s.layout.KernelsDir, kernelRef)
} }

View file

@ -44,7 +44,7 @@ const minPullExt4Size int64 = 1 << 30 // 1 GiB
// staging dir to the final artifact dir, insert the store row. If two // staging dir to the final artifact dir, insert the store row. If two
// pulls race to the same name, the loser fails fast at the recheck // pulls race to the same name, the loser fails fast at the recheck
// and its staging dir is cleaned up via defer. // and its staging dir is cleaned up via defer.
func (d *Daemon) PullImage(ctx context.Context, params api.ImagePullParams) (model.Image, error) { func (s *ImageService) PullImage(ctx context.Context, params api.ImagePullParams) (model.Image, error) {
ref := strings.TrimSpace(params.Ref) ref := strings.TrimSpace(params.Ref)
if ref == "" { if ref == "" {
return model.Image{}, errors.New("reference is required") return model.Image{}, errors.New("reference is required")
@ -55,9 +55,9 @@ func (d *Daemon) PullImage(ctx context.Context, params api.ImagePullParams) (mod
return model.Image{}, fmt.Errorf("load image catalog: %w", err) return model.Image{}, fmt.Errorf("load image catalog: %w", err)
} }
if entry, lookupErr := catalog.Lookup(ref); lookupErr == nil { if entry, lookupErr := catalog.Lookup(ref); lookupErr == nil {
return d.pullFromBundle(ctx, params, entry) return s.pullFromBundle(ctx, params, entry)
} }
return d.pullFromOCI(ctx, params) return s.pullFromOCI(ctx, params)
} }
// publishImage is the narrow critical section shared by every image- // publishImage is the narrow critical section shared by every image-
@ -71,11 +71,11 @@ func (d *Daemon) PullImage(ctx context.Context, params api.ImagePullParams) (mod
// in place, e.g. RegisterImage which only touches the store). When // in place, e.g. RegisterImage which only touches the store). When
// non-empty the rename is the publication atom: finalDir must not // non-empty the rename is the publication atom: finalDir must not
// already exist before the rename fires. // already exist before the rename fires.
func (d *Daemon) publishImage(ctx context.Context, image model.Image, stagingDir, finalDir string) (model.Image, error) { func (s *ImageService) publishImage(ctx context.Context, image model.Image, stagingDir, finalDir string) (model.Image, error) {
d.imageOpsMu.Lock() s.imageOpsMu.Lock()
defer d.imageOpsMu.Unlock() defer s.imageOpsMu.Unlock()
if existing, err := d.store.GetImageByName(ctx, image.Name); err == nil { if existing, err := s.store.GetImageByName(ctx, image.Name); err == nil {
return model.Image{}, fmt.Errorf("image %q already exists (id=%s); pick a different --name or delete it first", image.Name, existing.ID) return model.Image{}, fmt.Errorf("image %q already exists (id=%s); pick a different --name or delete it first", image.Name, existing.ID)
} }
if finalDir != "" { if finalDir != "" {
@ -83,7 +83,7 @@ func (d *Daemon) publishImage(ctx context.Context, image model.Image, stagingDir
return model.Image{}, fmt.Errorf("publish artifact dir: %w", err) return model.Image{}, fmt.Errorf("publish artifact dir: %w", err)
} }
} }
if err := d.store.UpsertImage(ctx, image); err != nil { if err := s.store.UpsertImage(ctx, image); err != nil {
if finalDir != "" { if finalDir != "" {
_ = os.RemoveAll(finalDir) _ = os.RemoveAll(finalDir)
} }
@ -94,7 +94,7 @@ func (d *Daemon) publishImage(ctx context.Context, image model.Image, stagingDir
// pullFromOCI is the original OCI-registry-pull path. See PullImage for // pullFromOCI is the original OCI-registry-pull path. See PullImage for
// the intent. // the intent.
func (d *Daemon) pullFromOCI(ctx context.Context, params api.ImagePullParams) (image model.Image, err error) { func (s *ImageService) pullFromOCI(ctx context.Context, params api.ImagePullParams) (image model.Image, err error) {
ref := strings.TrimSpace(params.Ref) ref := strings.TrimSpace(params.Ref)
parsed, err := name.ParseReference(ref) parsed, err := name.ParseReference(ref)
if err != nil { if err != nil {
@ -108,11 +108,11 @@ func (d *Daemon) pullFromOCI(ctx context.Context, params api.ImagePullParams) (i
return model.Image{}, errors.New("could not derive image name from ref; pass --name") return model.Image{}, errors.New("could not derive image name from ref; pass --name")
} }
} }
if existing, lookupErr := d.store.GetImageByName(ctx, imgName); lookupErr == nil { if existing, lookupErr := s.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) 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) kernelPath, initrdPath, modulesDir, err := s.resolveKernelInputs(ctx, params.KernelRef, params.KernelPath, params.InitrdPath, params.ModulesDir)
if err != nil { if err != nil {
return model.Image{}, err return model.Image{}, err
} }
@ -124,7 +124,7 @@ func (d *Daemon) pullFromOCI(ctx context.Context, params api.ImagePullParams) (i
if err != nil { if err != nil {
return model.Image{}, err return model.Image{}, err
} }
finalDir := filepath.Join(d.layout.ImagesDir, id) finalDir := filepath.Join(s.layout.ImagesDir, id)
stagingDir := finalDir + ".staging" stagingDir := finalDir + ".staging"
if err := os.MkdirAll(stagingDir, 0o755); err != nil { if err := os.MkdirAll(stagingDir, 0o755); err != nil {
return model.Image{}, err return model.Image{}, err
@ -144,7 +144,7 @@ func (d *Daemon) pullFromOCI(ctx context.Context, params api.ImagePullParams) (i
} }
defer os.RemoveAll(rootfsTree) defer os.RemoveAll(rootfsTree)
meta, err := d.runPullAndFlatten(ctx, ref, d.layout.OCICacheDir, rootfsTree) meta, err := s.runPullAndFlatten(ctx, ref, s.layout.OCICacheDir, rootfsTree)
if err != nil { if err != nil {
return model.Image{}, fmt.Errorf("pull oci image: %w", err) return model.Image{}, fmt.Errorf("pull oci image: %w", err)
} }
@ -162,14 +162,14 @@ func (d *Daemon) pullFromOCI(ctx context.Context, params api.ImagePullParams) (i
} }
rootfsExt4 := filepath.Join(stagingDir, "rootfs.ext4") rootfsExt4 := filepath.Join(stagingDir, "rootfs.ext4")
if err := imagepull.BuildExt4(ctx, d.runner, rootfsTree, rootfsExt4, sizeBytes); err != nil { if err := imagepull.BuildExt4(ctx, s.runner, rootfsTree, rootfsExt4, sizeBytes); err != nil {
return model.Image{}, fmt.Errorf("build rootfs ext4: %w", err) return model.Image{}, fmt.Errorf("build rootfs ext4: %w", err)
} }
if err := d.runFinalizePulledRootfs(ctx, rootfsExt4, meta); err != nil { if err := s.runFinalizePulledRootfs(ctx, rootfsExt4, meta); err != nil {
return model.Image{}, err return model.Image{}, err
} }
stagedKernel, stagedInitrd, stagedModules, err := imagemgr.StageBootArtifacts(ctx, d.runner, stagingDir, kernelPath, initrdPath, modulesDir) stagedKernel, stagedInitrd, stagedModules, err := imagemgr.StageBootArtifacts(ctx, s.runner, stagingDir, kernelPath, initrdPath, modulesDir)
if err != nil { if err != nil {
return model.Image{}, fmt.Errorf("stage boot artifacts: %w", err) return model.Image{}, fmt.Errorf("stage boot artifacts: %w", err)
} }
@ -187,7 +187,7 @@ func (d *Daemon) pullFromOCI(ctx context.Context, params api.ImagePullParams) (i
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
published, err := d.publishImage(ctx, image, stagingDir, finalDir) published, err := s.publishImage(ctx, image, stagingDir, finalDir)
if err != nil { if err != nil {
return model.Image{}, err return model.Image{}, err
} }
@ -200,12 +200,12 @@ func (d *Daemon) pullFromOCI(ctx context.Context, params api.ImagePullParams) (i
// injected at build time), verify its sha256, and register the result // injected at build time), verify its sha256, and register the result
// as a managed image. No flatten / mkfs / debugfs work on the daemon // as a managed image. No flatten / mkfs / debugfs work on the daemon
// host. // host.
func (d *Daemon) pullFromBundle(ctx context.Context, params api.ImagePullParams, entry imagecat.CatEntry) (image model.Image, err error) { func (s *ImageService) pullFromBundle(ctx context.Context, params api.ImagePullParams, entry imagecat.CatEntry) (image model.Image, err error) {
imgName := strings.TrimSpace(params.Name) imgName := strings.TrimSpace(params.Name)
if imgName == "" { if imgName == "" {
imgName = entry.Name imgName = entry.Name
} }
if existing, lookupErr := d.store.GetImageByName(ctx, imgName); lookupErr == nil { if existing, lookupErr := s.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) return model.Image{}, fmt.Errorf("image %q already exists (id=%s); pick a different --name or delete it first", imgName, existing.ID)
} }
@ -214,7 +214,7 @@ func (d *Daemon) pullFromBundle(ctx context.Context, params api.ImagePullParams,
if kernelRef == "" && strings.TrimSpace(params.KernelPath) == "" { if kernelRef == "" && strings.TrimSpace(params.KernelPath) == "" {
kernelRef = strings.TrimSpace(entry.KernelRef) kernelRef = strings.TrimSpace(entry.KernelRef)
} }
kernelPath, initrdPath, modulesDir, err := d.resolveKernelInputs(ctx, kernelRef, params.KernelPath, params.InitrdPath, params.ModulesDir) kernelPath, initrdPath, modulesDir, err := s.resolveKernelInputs(ctx, kernelRef, params.KernelPath, params.InitrdPath, params.ModulesDir)
if err != nil { if err != nil {
return model.Image{}, err return model.Image{}, err
} }
@ -226,7 +226,7 @@ func (d *Daemon) pullFromBundle(ctx context.Context, params api.ImagePullParams,
if err != nil { if err != nil {
return model.Image{}, err return model.Image{}, err
} }
finalDir := filepath.Join(d.layout.ImagesDir, id) finalDir := filepath.Join(s.layout.ImagesDir, id)
stagingDir := finalDir + ".staging" stagingDir := finalDir + ".staging"
if err := os.MkdirAll(stagingDir, 0o755); err != nil { if err := os.MkdirAll(stagingDir, 0o755); err != nil {
return model.Image{}, err return model.Image{}, err
@ -238,7 +238,7 @@ func (d *Daemon) pullFromBundle(ctx context.Context, params api.ImagePullParams,
} }
}() }()
if _, err := d.runBundleFetch(ctx, stagingDir, entry); err != nil { if _, err := s.runBundleFetch(ctx, stagingDir, entry); err != nil {
return model.Image{}, fmt.Errorf("fetch bundle: %w", err) return model.Image{}, fmt.Errorf("fetch bundle: %w", err)
} }
// manifest.json is metadata we only need at fetch time; strip it // manifest.json is metadata we only need at fetch time; strip it
@ -246,7 +246,7 @@ func (d *Daemon) pullFromBundle(ctx context.Context, params api.ImagePullParams,
_ = os.Remove(filepath.Join(stagingDir, imagecat.ManifestFilename)) _ = os.Remove(filepath.Join(stagingDir, imagecat.ManifestFilename))
rootfsExt4 := filepath.Join(stagingDir, imagecat.RootfsFilename) rootfsExt4 := filepath.Join(stagingDir, imagecat.RootfsFilename)
stagedKernel, stagedInitrd, stagedModules, err := imagemgr.StageBootArtifacts(ctx, d.runner, stagingDir, kernelPath, initrdPath, modulesDir) stagedKernel, stagedInitrd, stagedModules, err := imagemgr.StageBootArtifacts(ctx, s.runner, stagingDir, kernelPath, initrdPath, modulesDir)
if err != nil { if err != nil {
return model.Image{}, fmt.Errorf("stage boot artifacts: %w", err) return model.Image{}, fmt.Errorf("stage boot artifacts: %w", err)
} }
@ -264,7 +264,7 @@ func (d *Daemon) pullFromBundle(ctx context.Context, params api.ImagePullParams,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
published, err := d.publishImage(ctx, image, stagingDir, finalDir) published, err := s.publishImage(ctx, image, stagingDir, finalDir)
if err != nil { if err != nil {
return model.Image{}, err return model.Image{}, err
} }
@ -273,17 +273,17 @@ func (d *Daemon) pullFromBundle(ctx context.Context, params api.ImagePullParams,
} }
// runBundleFetch is the seam tests substitute. nil → real implementation. // runBundleFetch is the seam tests substitute. nil → real implementation.
func (d *Daemon) runBundleFetch(ctx context.Context, destDir string, entry imagecat.CatEntry) (imagecat.Manifest, error) { func (s *ImageService) runBundleFetch(ctx context.Context, destDir string, entry imagecat.CatEntry) (imagecat.Manifest, error) {
if d.bundleFetch != nil { if s.bundleFetch != nil {
return d.bundleFetch(ctx, destDir, entry) return s.bundleFetch(ctx, destDir, entry)
} }
return imagecat.Fetch(ctx, nil, destDir, entry) return imagecat.Fetch(ctx, nil, destDir, entry)
} }
// runPullAndFlatten is the seam tests substitute. nil → real implementation. // runPullAndFlatten is the seam tests substitute. nil → real implementation.
func (d *Daemon) runPullAndFlatten(ctx context.Context, ref, cacheDir, destDir string) (imagepull.Metadata, error) { func (s *ImageService) runPullAndFlatten(ctx context.Context, ref, cacheDir, destDir string) (imagepull.Metadata, error) {
if d.pullAndFlatten != nil { if s.pullAndFlatten != nil {
return d.pullAndFlatten(ctx, ref, cacheDir, destDir) return s.pullAndFlatten(ctx, ref, cacheDir, destDir)
} }
pulled, err := imagepull.Pull(ctx, ref, cacheDir) pulled, err := imagepull.Pull(ctx, ref, cacheDir)
if err != nil { if err != nil {
@ -293,21 +293,21 @@ func (d *Daemon) runPullAndFlatten(ctx context.Context, ref, cacheDir, destDir s
} }
// runFinalizePulledRootfs applies ownership fixup and injects banger's // runFinalizePulledRootfs applies ownership fixup and injects banger's
// guest agents. Tests substitute via d.finalizePulledRootfs; nil → // guest agents. Tests substitute via s.finalizePulledRootfs; nil →
// real implementation using debugfs + the companion vsock-agent // real implementation using debugfs + the companion vsock-agent
// binary resolved via paths.CompanionBinaryPath. // binary resolved via paths.CompanionBinaryPath.
func (d *Daemon) runFinalizePulledRootfs(ctx context.Context, ext4File string, meta imagepull.Metadata) error { func (s *ImageService) runFinalizePulledRootfs(ctx context.Context, ext4File string, meta imagepull.Metadata) error {
if d.finalizePulledRootfs != nil { if s.finalizePulledRootfs != nil {
return d.finalizePulledRootfs(ctx, ext4File, meta) return s.finalizePulledRootfs(ctx, ext4File, meta)
} }
if err := imagepull.ApplyOwnership(ctx, d.runner, ext4File, meta); err != nil { if err := imagepull.ApplyOwnership(ctx, s.runner, ext4File, meta); err != nil {
return fmt.Errorf("apply ownership: %w", err) return fmt.Errorf("apply ownership: %w", err)
} }
vsockBin, err := paths.CompanionBinaryPath("banger-vsock-agent") vsockBin, err := paths.CompanionBinaryPath("banger-vsock-agent")
if err != nil { if err != nil {
return fmt.Errorf("locate vsock agent binary: %w", err) return fmt.Errorf("locate vsock agent binary: %w", err)
} }
if err := imagepull.InjectGuestAgents(ctx, d.runner, ext4File, imagepull.GuestAgentAssets{ if err := imagepull.InjectGuestAgents(ctx, s.runner, ext4File, imagepull.GuestAgentAssets{
VsockAgentBin: vsockBin, VsockAgentBin: vsockBin,
}); err != nil { }); err != nil {
return fmt.Errorf("inject guest agents: %w", err) return fmt.Errorf("inject guest agents: %w", err)

View file

@ -63,9 +63,14 @@ func TestPullImageBundlePathRegistersFromCatalog(t *testing.T) {
seedKernel(t, kernelsDir, "generic-6.12") seedKernel(t, kernelsDir, "generic-6.12")
d := &Daemon{ d := &Daemon{
layout: paths.Layout{ImagesDir: imagesDir, KernelsDir: kernelsDir}, layout: paths.Layout{ImagesDir: imagesDir, KernelsDir: kernelsDir},
store: openDaemonStore(t), store: openDaemonStore(t),
runner: system.NewRunner(), runner: system.NewRunner(),
}
d.img = &ImageService{
layout: d.layout,
store: d.store,
runner: d.runner,
bundleFetch: stubBundleFetch(imagecat.Manifest{KernelRef: "generic-6.12"}), bundleFetch: stubBundleFetch(imagecat.Manifest{KernelRef: "generic-6.12"}),
} }
@ -77,7 +82,7 @@ func TestPullImageBundlePathRegistersFromCatalog(t *testing.T) {
TarballURL: "https://example.com/x.tar.zst", TarballURL: "https://example.com/x.tar.zst",
TarballSHA256: "abc", TarballSHA256: "abc",
} }
image, err := d.pullFromBundle(context.Background(), api.ImagePullParams{Ref: "debian-bookworm"}, entry) image, err := d.img.pullFromBundle(context.Background(), api.ImagePullParams{Ref: "debian-bookworm"}, entry)
if err != nil { if err != nil {
t.Fatalf("pullFromBundle: %v", err) t.Fatalf("pullFromBundle: %v", err)
} }
@ -111,9 +116,14 @@ func TestPullImageBundlePathOverrideNameAndKernelRef(t *testing.T) {
} }
d := &Daemon{ d := &Daemon{
layout: paths.Layout{ImagesDir: imagesDir, KernelsDir: kernelsDir}, layout: paths.Layout{ImagesDir: imagesDir, KernelsDir: kernelsDir},
store: openDaemonStore(t), store: openDaemonStore(t),
runner: system.NewRunner(), runner: system.NewRunner(),
}
d.img = &ImageService{
layout: d.layout,
store: d.store,
runner: d.runner,
bundleFetch: stubBundleFetch(imagecat.Manifest{KernelRef: "generic-6.12"}), bundleFetch: stubBundleFetch(imagecat.Manifest{KernelRef: "generic-6.12"}),
} }
@ -123,7 +133,7 @@ func TestPullImageBundlePathOverrideNameAndKernelRef(t *testing.T) {
TarballURL: "https://example.com/x.tar.zst", TarballURL: "https://example.com/x.tar.zst",
TarballSHA256: "abc", TarballSHA256: "abc",
} }
image, err := d.pullFromBundle(context.Background(), api.ImagePullParams{ image, err := d.img.pullFromBundle(context.Background(), api.ImagePullParams{
Ref: "debian-bookworm", Name: "my-sandbox", KernelRef: "custom-kernel", Ref: "debian-bookworm", Name: "my-sandbox", KernelRef: "custom-kernel",
}, entry) }, entry)
if err != nil { if err != nil {
@ -147,9 +157,14 @@ func TestPullImageBundlePathRejectsExistingName(t *testing.T) {
seedKernel(t, kernelsDir, "generic-6.12") seedKernel(t, kernelsDir, "generic-6.12")
d := &Daemon{ d := &Daemon{
layout: paths.Layout{ImagesDir: imagesDir, KernelsDir: kernelsDir}, layout: paths.Layout{ImagesDir: imagesDir, KernelsDir: kernelsDir},
store: openDaemonStore(t), store: openDaemonStore(t),
runner: system.NewRunner(), runner: system.NewRunner(),
}
d.img = &ImageService{
layout: d.layout,
store: d.store,
runner: d.runner,
bundleFetch: stubBundleFetch(imagecat.Manifest{KernelRef: "generic-6.12"}), bundleFetch: stubBundleFetch(imagecat.Manifest{KernelRef: "generic-6.12"}),
} }
id, _ := model.NewID() id, _ := model.NewID()
@ -160,7 +175,7 @@ func TestPullImageBundlePathRejectsExistingName(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
_, err := d.pullFromBundle(context.Background(), api.ImagePullParams{Ref: "debian-bookworm"}, imagecat.CatEntry{ _, err := d.img.pullFromBundle(context.Background(), api.ImagePullParams{Ref: "debian-bookworm"}, imagecat.CatEntry{
Name: "debian-bookworm", KernelRef: "generic-6.12", Name: "debian-bookworm", KernelRef: "generic-6.12",
TarballURL: "https://example.com/x.tar.zst", TarballSHA256: "abc", TarballURL: "https://example.com/x.tar.zst", TarballSHA256: "abc",
}) })
@ -171,13 +186,18 @@ func TestPullImageBundlePathRejectsExistingName(t *testing.T) {
func TestPullImageBundlePathRequiresSomeKernelSource(t *testing.T) { func TestPullImageBundlePathRequiresSomeKernelSource(t *testing.T) {
d := &Daemon{ d := &Daemon{
layout: paths.Layout{ImagesDir: t.TempDir(), KernelsDir: t.TempDir()}, layout: paths.Layout{ImagesDir: t.TempDir(), KernelsDir: t.TempDir()},
store: openDaemonStore(t), store: openDaemonStore(t),
runner: system.NewRunner(), runner: system.NewRunner(),
}
d.img = &ImageService{
layout: d.layout,
store: d.store,
runner: d.runner,
bundleFetch: stubBundleFetch(imagecat.Manifest{}), bundleFetch: stubBundleFetch(imagecat.Manifest{}),
} }
// Catalog entry has no kernel_ref, no --kernel-ref/--kernel passed. // Catalog entry has no kernel_ref, no --kernel-ref/--kernel passed.
_, err := d.pullFromBundle(context.Background(), api.ImagePullParams{Ref: "x"}, imagecat.CatEntry{ _, err := d.img.pullFromBundle(context.Background(), api.ImagePullParams{Ref: "x"}, imagecat.CatEntry{
Name: "x", TarballURL: "https://example.com/x.tar.zst", TarballSHA256: "abc", Name: "x", TarballURL: "https://example.com/x.tar.zst", TarballSHA256: "abc",
}) })
if err == nil || !strings.Contains(err.Error(), "kernel") { if err == nil || !strings.Contains(err.Error(), "kernel") {
@ -194,11 +214,16 @@ func TestPullImageBundleFetchFailurePropagates(t *testing.T) {
layout: paths.Layout{ImagesDir: imagesDir, KernelsDir: kernelsDir}, layout: paths.Layout{ImagesDir: imagesDir, KernelsDir: kernelsDir},
store: openDaemonStore(t), store: openDaemonStore(t),
runner: system.NewRunner(), runner: system.NewRunner(),
}
d.img = &ImageService{
layout: d.layout,
store: d.store,
runner: d.runner,
bundleFetch: func(_ context.Context, _ string, _ imagecat.CatEntry) (imagecat.Manifest, error) { bundleFetch: func(_ context.Context, _ string, _ imagecat.CatEntry) (imagecat.Manifest, error) {
return imagecat.Manifest{}, errors.New("r2 exploded") return imagecat.Manifest{}, errors.New("r2 exploded")
}, },
} }
_, err := d.pullFromBundle(context.Background(), api.ImagePullParams{Ref: "x"}, imagecat.CatEntry{ _, err := d.img.pullFromBundle(context.Background(), api.ImagePullParams{Ref: "x"}, imagecat.CatEntry{
Name: "x", KernelRef: "generic-6.12", Name: "x", KernelRef: "generic-6.12",
TarballURL: "https://example.com/x.tar.zst", TarballSHA256: "abc", TarballURL: "https://example.com/x.tar.zst", TarballSHA256: "abc",
}) })
@ -222,6 +247,11 @@ func TestPullImageDispatchFallsThroughToOCIWhenNoCatalogHit(t *testing.T) {
layout: paths.Layout{ImagesDir: imagesDir, KernelsDir: kernelsDir, OCICacheDir: t.TempDir()}, layout: paths.Layout{ImagesDir: imagesDir, KernelsDir: kernelsDir, OCICacheDir: t.TempDir()},
store: openDaemonStore(t), store: openDaemonStore(t),
runner: system.NewRunner(), runner: system.NewRunner(),
}
d.img = &ImageService{
layout: d.layout,
store: d.store,
runner: d.runner,
pullAndFlatten: func(_ context.Context, ref, _ string, destDir string) (imagepull.Metadata, error) { pullAndFlatten: func(_ context.Context, ref, _ string, destDir string) (imagepull.Metadata, error) {
ociCalled = true ociCalled = true
if err := os.WriteFile(filepath.Join(destDir, "marker"), []byte("x"), 0o644); err != nil { if err := os.WriteFile(filepath.Join(destDir, "marker"), []byte("x"), 0o644); err != nil {
@ -233,7 +263,7 @@ func TestPullImageDispatchFallsThroughToOCIWhenNoCatalogHit(t *testing.T) {
bundleFetch: stubBundleFetch(imagecat.Manifest{}), bundleFetch: stubBundleFetch(imagecat.Manifest{}),
} }
_, err := d.PullImage(context.Background(), api.ImagePullParams{ _, err := d.img.PullImage(context.Background(), api.ImagePullParams{
// Not a catalog name (catalog is empty in the embedded default). // Not a catalog name (catalog is empty in the embedded default).
Ref: "docker.io/library/debian:bookworm", Ref: "docker.io/library/debian:bookworm",
KernelRef: "generic-6.12", KernelRef: "generic-6.12",

View file

@ -71,14 +71,19 @@ func TestPullImageHappyPath(t *testing.T) {
kernel, initrd, modules := writeFakeKernelTriple(t) kernel, initrd, modules := writeFakeKernelTriple(t)
d := &Daemon{ d := &Daemon{
layout: paths.Layout{ImagesDir: imagesDir, OCICacheDir: cacheDir}, layout: paths.Layout{ImagesDir: imagesDir, OCICacheDir: cacheDir},
store: openDaemonStore(t), store: openDaemonStore(t),
runner: system.NewRunner(), runner: system.NewRunner(),
}
d.img = &ImageService{
layout: d.layout,
store: d.store,
runner: d.runner,
pullAndFlatten: stubPullAndFlatten, pullAndFlatten: stubPullAndFlatten,
finalizePulledRootfs: stubFinalizePulledRootfs, finalizePulledRootfs: stubFinalizePulledRootfs,
} }
image, err := d.PullImage(context.Background(), api.ImagePullParams{ image, err := d.img.PullImage(context.Background(), api.ImagePullParams{
Ref: "docker.io/library/debian:bookworm", Ref: "docker.io/library/debian:bookworm",
KernelPath: kernel, KernelPath: kernel,
InitrdPath: initrd, InitrdPath: initrd,
@ -116,9 +121,14 @@ func TestPullImageRejectsExistingName(t *testing.T) {
kernel, _, _ := writeFakeKernelTriple(t) kernel, _, _ := writeFakeKernelTriple(t)
d := &Daemon{ d := &Daemon{
layout: paths.Layout{ImagesDir: imagesDir, OCICacheDir: t.TempDir()}, layout: paths.Layout{ImagesDir: imagesDir, OCICacheDir: t.TempDir()},
store: openDaemonStore(t), store: openDaemonStore(t),
runner: system.NewRunner(), runner: system.NewRunner(),
}
d.img = &ImageService{
layout: d.layout,
store: d.store,
runner: d.runner,
pullAndFlatten: stubPullAndFlatten, pullAndFlatten: stubPullAndFlatten,
finalizePulledRootfs: stubFinalizePulledRootfs, finalizePulledRootfs: stubFinalizePulledRootfs,
} }
@ -133,7 +143,7 @@ func TestPullImageRejectsExistingName(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
_, err := d.PullImage(context.Background(), api.ImagePullParams{ _, err := d.img.PullImage(context.Background(), api.ImagePullParams{
Ref: "docker.io/library/debian:bookworm", Ref: "docker.io/library/debian:bookworm",
KernelPath: kernel, KernelPath: kernel,
}) })
@ -144,13 +154,18 @@ func TestPullImageRejectsExistingName(t *testing.T) {
func TestPullImageRequiresKernel(t *testing.T) { func TestPullImageRequiresKernel(t *testing.T) {
d := &Daemon{ d := &Daemon{
layout: paths.Layout{ImagesDir: t.TempDir(), OCICacheDir: t.TempDir()}, layout: paths.Layout{ImagesDir: t.TempDir(), OCICacheDir: t.TempDir()},
store: openDaemonStore(t), store: openDaemonStore(t),
runner: system.NewRunner(), runner: system.NewRunner(),
}
d.img = &ImageService{
layout: d.layout,
store: d.store,
runner: d.runner,
pullAndFlatten: stubPullAndFlatten, pullAndFlatten: stubPullAndFlatten,
finalizePulledRootfs: stubFinalizePulledRootfs, finalizePulledRootfs: stubFinalizePulledRootfs,
} }
_, err := d.PullImage(context.Background(), api.ImagePullParams{ _, err := d.img.PullImage(context.Background(), api.ImagePullParams{
Ref: "docker.io/library/debian:bookworm", Ref: "docker.io/library/debian:bookworm",
}) })
if err == nil || !strings.Contains(err.Error(), "kernel") { if err == nil || !strings.Contains(err.Error(), "kernel") {
@ -166,13 +181,18 @@ func TestPullImageCleansStagingOnFailure(t *testing.T) {
} }
d := &Daemon{ d := &Daemon{
layout: paths.Layout{ImagesDir: imagesDir, OCICacheDir: t.TempDir()}, layout: paths.Layout{ImagesDir: imagesDir, OCICacheDir: t.TempDir()},
store: openDaemonStore(t), store: openDaemonStore(t),
runner: system.NewRunner(), runner: system.NewRunner(),
}
d.img = &ImageService{
layout: d.layout,
store: d.store,
runner: d.runner,
pullAndFlatten: failureSeam, pullAndFlatten: failureSeam,
finalizePulledRootfs: stubFinalizePulledRootfs, finalizePulledRootfs: stubFinalizePulledRootfs,
} }
_, err := d.PullImage(context.Background(), api.ImagePullParams{ _, err := d.img.PullImage(context.Background(), api.ImagePullParams{
Ref: "docker.io/library/debian:bookworm", Ref: "docker.io/library/debian:bookworm",
KernelPath: kernel, KernelPath: kernel,
}) })

View file

@ -14,8 +14,8 @@ import (
"banger/internal/system" "banger/internal/system"
) )
func (d *Daemon) KernelList(_ context.Context) (api.KernelListResult, error) { func (s *ImageService) KernelList(_ context.Context) (api.KernelListResult, error) {
entries, err := kernelcat.ListLocal(d.layout.KernelsDir) entries, err := kernelcat.ListLocal(s.layout.KernelsDir)
if err != nil { if err != nil {
return api.KernelListResult{}, err return api.KernelListResult{}, err
} }
@ -26,19 +26,19 @@ func (d *Daemon) KernelList(_ context.Context) (api.KernelListResult, error) {
return result, nil return result, nil
} }
func (d *Daemon) KernelShow(_ context.Context, name string) (api.KernelEntry, error) { func (s *ImageService) KernelShow(_ context.Context, name string) (api.KernelEntry, error) {
entry, err := kernelcat.ReadLocal(d.layout.KernelsDir, name) entry, err := kernelcat.ReadLocal(s.layout.KernelsDir, name)
if err != nil { if err != nil {
return api.KernelEntry{}, kernelNotFoundIfMissing(name, err) return api.KernelEntry{}, kernelNotFoundIfMissing(name, err)
} }
return kernelEntryToAPI(entry), nil return kernelEntryToAPI(entry), nil
} }
func (d *Daemon) KernelDelete(_ context.Context, name string) error { func (s *ImageService) KernelDelete(_ context.Context, name string) error {
if err := kernelcat.ValidateName(name); err != nil { if err := kernelcat.ValidateName(name); err != nil {
return err return err
} }
return kernelcat.DeleteLocal(d.layout.KernelsDir, name) return kernelcat.DeleteLocal(s.layout.KernelsDir, name)
} }
// KernelImport copies the kernel / initrd / modules artifacts produced by // KernelImport copies the kernel / initrd / modules artifacts produced by
@ -46,7 +46,7 @@ func (d *Daemon) KernelDelete(_ context.Context, name string) error {
// under params.Name and writes the manifest. It is the primary bridge from // under params.Name and writes the manifest. It is the primary bridge from
// "I built a kernel with the helper scripts" to "banger kernel list shows // "I built a kernel with the helper scripts" to "banger kernel list shows
// it and image register --kernel-ref works." // it and image register --kernel-ref works."
func (d *Daemon) KernelImport(ctx context.Context, params api.KernelImportParams) (api.KernelEntry, error) { func (s *ImageService) KernelImport(ctx context.Context, params api.KernelImportParams) (api.KernelEntry, error) {
name := strings.TrimSpace(params.Name) name := strings.TrimSpace(params.Name)
if err := kernelcat.ValidateName(name); err != nil { if err := kernelcat.ValidateName(name); err != nil {
return api.KernelEntry{}, err return api.KernelEntry{}, err
@ -61,9 +61,9 @@ func (d *Daemon) KernelImport(ctx context.Context, params api.KernelImportParams
return api.KernelEntry{}, fmt.Errorf("discover artifacts under %s: %w", fromDir, err) return api.KernelEntry{}, fmt.Errorf("discover artifacts under %s: %w", fromDir, err)
} }
targetDir := kernelcat.EntryDir(d.layout.KernelsDir, name) targetDir := kernelcat.EntryDir(s.layout.KernelsDir, name)
// Overwrite-by-default: clear any prior entry so a re-import is clean. // Overwrite-by-default: clear any prior entry so a re-import is clean.
if err := kernelcat.DeleteLocal(d.layout.KernelsDir, name); err != nil { if err := kernelcat.DeleteLocal(s.layout.KernelsDir, name); err != nil {
return api.KernelEntry{}, fmt.Errorf("clear prior catalog entry %q: %w", name, err) return api.KernelEntry{}, fmt.Errorf("clear prior catalog entry %q: %w", name, err)
} }
if err := os.MkdirAll(targetDir, 0o755); err != nil { if err := os.MkdirAll(targetDir, 0o755); err != nil {
@ -85,7 +85,7 @@ func (d *Daemon) KernelImport(ctx context.Context, params api.KernelImportParams
if err := os.MkdirAll(modulesTarget, 0o755); err != nil { if err := os.MkdirAll(modulesTarget, 0o755); err != nil {
return api.KernelEntry{}, err return api.KernelEntry{}, err
} }
if err := system.CopyDirContents(ctx, d.runner, discovered.ModulesDir, modulesTarget, false); err != nil { if err := system.CopyDirContents(ctx, s.runner, discovered.ModulesDir, modulesTarget, false); err != nil {
return api.KernelEntry{}, fmt.Errorf("copy modules: %w", err) return api.KernelEntry{}, fmt.Errorf("copy modules: %w", err)
} }
} }
@ -104,10 +104,10 @@ func (d *Daemon) KernelImport(ctx context.Context, params api.KernelImportParams
Source: "import:" + fromDir, Source: "import:" + fromDir,
ImportedAt: time.Now().UTC(), ImportedAt: time.Now().UTC(),
} }
if err := kernelcat.WriteLocal(d.layout.KernelsDir, entry); err != nil { if err := kernelcat.WriteLocal(s.layout.KernelsDir, entry); err != nil {
return api.KernelEntry{}, fmt.Errorf("write manifest: %w", err) return api.KernelEntry{}, fmt.Errorf("write manifest: %w", err)
} }
stored, err := kernelcat.ReadLocal(d.layout.KernelsDir, name) stored, err := kernelcat.ReadLocal(s.layout.KernelsDir, name)
if err != nil { if err != nil {
return api.KernelEntry{}, err return api.KernelEntry{}, err
} }
@ -116,14 +116,14 @@ func (d *Daemon) KernelImport(ctx context.Context, params api.KernelImportParams
// KernelPull downloads a catalog entry by name into the local catalog. It // KernelPull downloads a catalog entry by name into the local catalog. It
// refuses to overwrite an existing entry unless params.Force is set. // refuses to overwrite an existing entry unless params.Force is set.
func (d *Daemon) KernelPull(ctx context.Context, params api.KernelPullParams) (api.KernelEntry, error) { func (s *ImageService) KernelPull(ctx context.Context, params api.KernelPullParams) (api.KernelEntry, error) {
name := strings.TrimSpace(params.Name) name := strings.TrimSpace(params.Name)
if err := kernelcat.ValidateName(name); err != nil { if err := kernelcat.ValidateName(name); err != nil {
return api.KernelEntry{}, err return api.KernelEntry{}, err
} }
if !params.Force { if !params.Force {
if _, err := kernelcat.ReadLocal(d.layout.KernelsDir, name); err == nil { if _, err := kernelcat.ReadLocal(s.layout.KernelsDir, name); err == nil {
return api.KernelEntry{}, fmt.Errorf("kernel %q already pulled; pass --force to re-pull", name) return api.KernelEntry{}, fmt.Errorf("kernel %q already pulled; pass --force to re-pull", name)
} else if !os.IsNotExist(err) { } else if !os.IsNotExist(err) {
return api.KernelEntry{}, err return api.KernelEntry{}, err
@ -139,7 +139,7 @@ func (d *Daemon) KernelPull(ctx context.Context, params api.KernelPullParams) (a
return api.KernelEntry{}, fmt.Errorf("kernel %q not in catalog (run 'banger kernel list --available' to browse)", name) return api.KernelEntry{}, fmt.Errorf("kernel %q not in catalog (run 'banger kernel list --available' to browse)", name)
} }
stored, err := kernelcat.Fetch(ctx, nil, d.layout.KernelsDir, catEntry) stored, err := kernelcat.Fetch(ctx, nil, s.layout.KernelsDir, catEntry)
if err != nil { if err != nil {
return api.KernelEntry{}, err return api.KernelEntry{}, err
} }
@ -148,12 +148,12 @@ func (d *Daemon) KernelPull(ctx context.Context, params api.KernelPullParams) (a
// KernelCatalog returns every entry from the embedded catalog annotated // KernelCatalog returns every entry from the embedded catalog annotated
// with whether it has already been pulled locally. // with whether it has already been pulled locally.
func (d *Daemon) KernelCatalog(_ context.Context) (api.KernelCatalogResult, error) { func (s *ImageService) KernelCatalog(_ context.Context) (api.KernelCatalogResult, error) {
catalog, err := kernelcat.LoadEmbedded() catalog, err := kernelcat.LoadEmbedded()
if err != nil { if err != nil {
return api.KernelCatalogResult{}, err return api.KernelCatalogResult{}, err
} }
local, _ := kernelcat.ListLocal(d.layout.KernelsDir) local, _ := kernelcat.ListLocal(s.layout.KernelsDir)
pulled := make(map[string]bool, len(local)) pulled := make(map[string]bool, len(local))
for _, entry := range local { for _, entry := range local {
pulled[entry.Name] = true pulled[entry.Name] = true

View file

@ -38,7 +38,7 @@ func TestKernelListReturnsSeededEntries(t *testing.T) {
seedKernelEntry(t, kernelsDir, "alpine-3.23") seedKernelEntry(t, kernelsDir, "alpine-3.23")
d := &Daemon{layout: paths.Layout{KernelsDir: kernelsDir}} d := &Daemon{layout: paths.Layout{KernelsDir: kernelsDir}}
result, err := d.KernelList(context.Background()) result, err := d.imageSvc().KernelList(context.Background())
if err != nil { if err != nil {
t.Fatalf("KernelList: %v", err) t.Fatalf("KernelList: %v", err)
} }
@ -86,7 +86,7 @@ func TestKernelShowAndDeleteThroughDispatch(t *testing.T) {
func TestKernelShowMissingEntry(t *testing.T) { func TestKernelShowMissingEntry(t *testing.T) {
d := &Daemon{layout: paths.Layout{KernelsDir: t.TempDir()}} d := &Daemon{layout: paths.Layout{KernelsDir: t.TempDir()}}
_, err := d.KernelShow(context.Background(), "nope") _, err := d.imageSvc().KernelShow(context.Background(), "nope")
if err == nil || !strings.Contains(err.Error(), "not found") { if err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("KernelShow missing: err=%v", err) t.Fatalf("KernelShow missing: err=%v", err)
} }
@ -94,7 +94,7 @@ func TestKernelShowMissingEntry(t *testing.T) {
func TestKernelDeleteRejectsInvalidName(t *testing.T) { func TestKernelDeleteRejectsInvalidName(t *testing.T) {
d := &Daemon{layout: paths.Layout{KernelsDir: t.TempDir()}} d := &Daemon{layout: paths.Layout{KernelsDir: t.TempDir()}}
if err := d.KernelDelete(context.Background(), "../escape"); err == nil { if err := d.imageSvc().KernelDelete(context.Background(), "../escape"); err == nil {
t.Fatalf("KernelDelete should reject traversal") t.Fatalf("KernelDelete should reject traversal")
} }
} }
@ -113,7 +113,7 @@ func TestRegisterImageResolvesKernelRef(t *testing.T) {
store: openDaemonStore(t), store: openDaemonStore(t),
} }
image, err := d.RegisterImage(context.Background(), api.ImageRegisterParams{ image, err := d.imageSvc().RegisterImage(context.Background(), api.ImageRegisterParams{
Name: "testbox", Name: "testbox",
RootfsPath: rootfs, RootfsPath: rootfs,
KernelRef: "void-6.12", KernelRef: "void-6.12",
@ -139,7 +139,7 @@ func TestRegisterImageRejectsKernelRefAndPath(t *testing.T) {
layout: paths.Layout{KernelsDir: kernelsDir}, layout: paths.Layout{KernelsDir: kernelsDir},
store: openDaemonStore(t), store: openDaemonStore(t),
} }
_, err := d.RegisterImage(context.Background(), api.ImageRegisterParams{ _, err := d.imageSvc().RegisterImage(context.Background(), api.ImageRegisterParams{
Name: "testbox", Name: "testbox",
RootfsPath: rootfs, RootfsPath: rootfs,
KernelRef: "void-6.12", KernelRef: "void-6.12",
@ -175,7 +175,7 @@ func TestKernelImportCopiesArtifactsAndWritesManifest(t *testing.T) {
runner: system.NewRunner(), runner: system.NewRunner(),
} }
entry, err := d.KernelImport(context.Background(), api.KernelImportParams{ entry, err := d.imageSvc().KernelImport(context.Background(), api.KernelImportParams{
Name: "void-6.12", Name: "void-6.12",
FromDir: src, FromDir: src,
Distro: "void", Distro: "void",
@ -210,7 +210,7 @@ func TestKernelPullRejectsUnknownCatalogEntry(t *testing.T) {
layout: paths.Layout{KernelsDir: t.TempDir()}, layout: paths.Layout{KernelsDir: t.TempDir()},
runner: system.NewRunner(), runner: system.NewRunner(),
} }
_, err := d.KernelPull(context.Background(), api.KernelPullParams{Name: "unknown"}) _, err := d.imageSvc().KernelPull(context.Background(), api.KernelPullParams{Name: "unknown"})
if err == nil || !strings.Contains(err.Error(), "not in catalog") { if err == nil || !strings.Contains(err.Error(), "not in catalog") {
t.Fatalf("KernelPull unknown: err=%v", err) t.Fatalf("KernelPull unknown: err=%v", err)
} }
@ -224,7 +224,7 @@ func TestKernelPullRefusesOverwriteWithoutForce(t *testing.T) {
layout: paths.Layout{KernelsDir: kernelsDir}, layout: paths.Layout{KernelsDir: kernelsDir},
runner: system.NewRunner(), runner: system.NewRunner(),
} }
_, err := d.KernelPull(context.Background(), api.KernelPullParams{Name: "void-6.12"}) _, err := d.imageSvc().KernelPull(context.Background(), api.KernelPullParams{Name: "void-6.12"})
if err == nil || !strings.Contains(err.Error(), "already pulled") { if err == nil || !strings.Contains(err.Error(), "already pulled") {
t.Fatalf("KernelPull without --force: err=%v", err) t.Fatalf("KernelPull without --force: err=%v", err)
} }
@ -232,7 +232,7 @@ func TestKernelPullRefusesOverwriteWithoutForce(t *testing.T) {
func TestKernelCatalogReportsPulledStatus(t *testing.T) { func TestKernelCatalogReportsPulledStatus(t *testing.T) {
d := &Daemon{layout: paths.Layout{KernelsDir: t.TempDir()}} d := &Daemon{layout: paths.Layout{KernelsDir: t.TempDir()}}
result, err := d.KernelCatalog(context.Background()) result, err := d.imageSvc().KernelCatalog(context.Background())
if err != nil { if err != nil {
t.Fatalf("KernelCatalog: %v", err) t.Fatalf("KernelCatalog: %v", err)
} }
@ -247,7 +247,7 @@ func TestKernelImportRejectsMissingFromDir(t *testing.T) {
layout: paths.Layout{KernelsDir: t.TempDir()}, layout: paths.Layout{KernelsDir: t.TempDir()},
runner: system.NewRunner(), runner: system.NewRunner(),
} }
_, err := d.KernelImport(context.Background(), api.KernelImportParams{Name: "x"}) _, err := d.imageSvc().KernelImport(context.Background(), api.KernelImportParams{Name: "x"})
if err == nil || !strings.Contains(err.Error(), "--from") { if err == nil || !strings.Contains(err.Error(), "--from") {
t.Fatalf("KernelImport without --from: err=%v", err) t.Fatalf("KernelImport without --from: err=%v", err)
} }
@ -262,7 +262,7 @@ func TestRegisterImageMissingKernelRef(t *testing.T) {
layout: paths.Layout{KernelsDir: t.TempDir()}, layout: paths.Layout{KernelsDir: t.TempDir()},
store: openDaemonStore(t), store: openDaemonStore(t),
} }
_, err := d.RegisterImage(context.Background(), api.ImageRegisterParams{ _, err := d.imageSvc().RegisterImage(context.Background(), api.ImageRegisterParams{
Name: "testbox", Name: "testbox",
RootfsPath: rootfs, RootfsPath: rootfs,
KernelRef: "never-imported", KernelRef: "never-imported",

View file

@ -94,7 +94,7 @@ func (d *Daemon) ensureAuthorizedKeyOnWorkDisk(ctx context.Context, vm *model.VM
} }
if prep.ClonedFromSeed && image.Managed { if prep.ClonedFromSeed && image.Managed {
vmCreateStage(ctx, "prepare_work_disk", "refreshing managed work seed") vmCreateStage(ctx, "prepare_work_disk", "refreshing managed work seed")
if err := d.refreshManagedWorkSeedFingerprint(ctx, image, fingerprint); err != nil { if err := d.imageSvc().refreshManagedWorkSeedFingerprint(ctx, image, fingerprint); err != nil {
return err return err
} }
} }

View file

@ -175,7 +175,7 @@ func (d *Daemon) reserveVM(ctx context.Context, requestedName string, image mode
// therefore `vm run`) works on a fresh host without the user having // therefore `vm run`) works on a fresh host without the user having
// to run `image pull` first. // to run `image pull` first.
func (d *Daemon) findOrAutoPullImage(ctx context.Context, idOrName string) (model.Image, error) { func (d *Daemon) findOrAutoPullImage(ctx context.Context, idOrName string) (model.Image, error) {
image, err := d.FindImage(ctx, idOrName) image, err := d.imageSvc().FindImage(ctx, idOrName)
if err == nil { if err == nil {
return image, nil return image, nil
} }
@ -189,8 +189,8 @@ func (d *Daemon) findOrAutoPullImage(ctx context.Context, idOrName string) (mode
return model.Image{}, err return model.Image{}, err
} }
vmCreateStage(ctx, "auto_pull_image", fmt.Sprintf("pulling %s from image catalog", entry.Name)) vmCreateStage(ctx, "auto_pull_image", fmt.Sprintf("pulling %s from image catalog", entry.Name))
if _, pullErr := d.PullImage(ctx, api.ImagePullParams{Ref: entry.Name}); pullErr != nil { if _, pullErr := d.imageSvc().PullImage(ctx, api.ImagePullParams{Ref: entry.Name}); pullErr != nil {
return model.Image{}, fmt.Errorf("auto-pull image %q: %w", entry.Name, pullErr) return model.Image{}, fmt.Errorf("auto-pull image %q: %w", entry.Name, pullErr)
} }
return d.FindImage(ctx, idOrName) return d.imageSvc().FindImage(ctx, idOrName)
} }

View file

@ -190,12 +190,15 @@ func sshdGuestConfig() string {
}, "\n") }, "\n")
} }
func (d *Daemon) flattenNestedWorkHome(ctx context.Context, workMount string) error { // flattenNestedWorkHome is a package-level helper used by the image,
// workspace-sync, and VM-disk paths, so it takes the runner explicitly
// rather than belonging to any one service struct.
func flattenNestedWorkHome(ctx context.Context, runner system.CommandRunner, workMount string) error {
nestedHome := filepath.Join(workMount, "root") nestedHome := filepath.Join(workMount, "root")
if !exists(nestedHome) { if !exists(nestedHome) {
return nil return nil
} }
if _, err := d.runner.RunSudo(ctx, "chmod", "755", nestedHome); err != nil { if _, err := runner.RunSudo(ctx, "chmod", "755", nestedHome); err != nil {
return err return err
} }
entries, err := os.ReadDir(nestedHome) entries, err := os.ReadDir(nestedHome)
@ -204,10 +207,17 @@ func (d *Daemon) flattenNestedWorkHome(ctx context.Context, workMount string) er
} }
for _, entry := range entries { for _, entry := range entries {
sourcePath := filepath.Join(nestedHome, entry.Name()) sourcePath := filepath.Join(nestedHome, entry.Name())
if _, err := d.runner.RunSudo(ctx, "cp", "-a", sourcePath, workMount+"/"); err != nil { if _, err := runner.RunSudo(ctx, "cp", "-a", sourcePath, workMount+"/"); err != nil {
return err return err
} }
} }
_, err = d.runner.RunSudo(ctx, "rm", "-rf", nestedHome) _, err = runner.RunSudo(ctx, "rm", "-rf", nestedHome)
return err return err
} }
// Deprecated forwarder: until every caller learns the package-level
// helper, Daemon keeps a receiver-method form. Will be deleted once
// the last caller is rewritten.
func (d *Daemon) flattenNestedWorkHome(ctx context.Context, workMount string) error {
return flattenNestedWorkHome(ctx, d.runner, workMount)
}