daemon: build a work-seed during image pull, refresh doctor check

Before this change `banger image pull` (both OCI-direct and bundle
paths) shipped images with an empty WorkSeedPath — the BuildWorkSeedImage
helper existed only behind the hidden `banger internal work-seed` CLI.
Every pulled image hit ensureWorkDisk's no-seed branch, and the guest
booted with a bare /root (no .bashrc, no .profile, none of the distro
defaults).

Pull now calls BuildWorkSeedImage after the rootfs is finalised (OCI)
or fetched (bundle). The builder is behind a new `workSeedBuilder` test
seam so existing pull tests don't accidentally demand sudo mount. The
build failure is non-fatal: any error logs a warning and leaves
WorkSeedPath empty — images stay publishable even if the pulled rootfs
has no /root to extract.

Verified end-to-end by wiping the cached smoke image and re-pulling:
work-seed.ext4 lands in the artifact dir next to rootfs.ext4, and all
21 smoke scenarios pass.

Also refreshes the "feature /root work disk" fallback tooling check —
the no-seed path no longer touches mount/umount/cp after commit
0e28504, so the doctor check now only requires truncate + mkfs.ext4.
The warn copy updates from "new VM creates will be slower" to "guest
/root will be empty", which matches the actual tradeoff post-refactor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thales Maciel 2026-04-23 20:24:10 -03:00
parent 02773c1cf5
commit 3edd7c6de7
No known key found for this signature in database
GPG key ID: 33112E6833C34679
8 changed files with 74 additions and 19 deletions

View file

@ -67,6 +67,7 @@ func TestFindOrAutoPullImagePullsFromCatalog(t *testing.T) {
pullCalls++
return stubBundleFetch(imagecat.Manifest{KernelRef: "generic-6.12"})(ctx, destDir, entry)
},
workSeedBuilder: stubWorkSeedBuilder,
}
wireServices(d)
// "debian-bookworm" is in the embedded imagecat catalog.

View file

@ -251,11 +251,11 @@ func (c workDiskCapability) AddDoctorChecks(_ context.Context, report *system.Re
}
}
checks := system.NewPreflight()
for _, command := range []string{"mkfs.ext4", "mount", "umount", "cp"} {
for _, command := range []string{"truncate", "mkfs.ext4"} {
checks.RequireCommand(command, toolHint(command))
}
report.AddPreflight("feature /root work disk", checks, "fallback /root work disk tooling available")
report.AddWarn("feature /root work disk", "default image has no work-seed artifact; new VM creates will be slower until the image is rebuilt")
report.AddWarn("feature /root work disk", "default image has no work-seed artifact; guest /root will be empty until the image is rebuilt")
}
// dnsCapability publishes + removes <vm>.vm records on the in-process

View file

@ -75,6 +75,7 @@ func TestPullImageDoesNotSerialiseOnDifferentNames(t *testing.T) {
runner: d.runner,
pullAndFlatten: slowPullAndFlatten,
finalizePulledRootfs: stubFinalizePulledRootfs,
workSeedBuilder: stubWorkSeedBuilder,
}
wireServices(d)
@ -162,6 +163,7 @@ func TestPullImageRejectsNameClashAtPublish(t *testing.T) {
runner: d.runner,
pullAndFlatten: pullAndFlatten,
finalizePulledRootfs: stubFinalizePulledRootfs,
workSeedBuilder: stubWorkSeedBuilder,
}
wireServices(d)

View file

@ -42,6 +42,7 @@ type ImageService struct {
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)
workSeedBuilder func(ctx context.Context, rootfsExt4, outPath string) error
// beginOperation is a test seam used by a couple of image ops that
// want structured operation logging. Nil → Daemon's beginOperation,

View file

@ -16,6 +16,7 @@ import (
"banger/internal/imagepull"
"banger/internal/model"
"banger/internal/paths"
"banger/internal/system"
"github.com/google/go-containerregistry/pkg/name"
)
@ -168,6 +169,7 @@ func (s *ImageService) pullFromOCI(ctx context.Context, params api.ImagePullPara
if err := s.runFinalizePulledRootfs(ctx, rootfsExt4, meta); err != nil {
return model.Image{}, err
}
workSeedExt4 := s.runBuildWorkSeed(ctx, rootfsExt4, stagingDir)
stagedKernel, stagedInitrd, stagedModules, err := imagemgr.StageBootArtifacts(ctx, s.runner, stagingDir, kernelPath, initrdPath, modulesDir)
if err != nil {
@ -187,6 +189,9 @@ func (s *ImageService) pullFromOCI(ctx context.Context, params api.ImagePullPara
CreatedAt: now,
UpdatedAt: now,
}
if workSeedExt4 != "" {
image.WorkSeedPath = filepath.Join(finalDir, filepath.Base(workSeedExt4))
}
published, err := s.publishImage(ctx, image, stagingDir, finalDir)
if err != nil {
return model.Image{}, err
@ -245,6 +250,7 @@ func (s *ImageService) pullFromBundle(ctx context.Context, params api.ImagePullP
// so the final artifact dir contains only boot-relevant files.
_ = os.Remove(filepath.Join(stagingDir, imagecat.ManifestFilename))
rootfsExt4 := filepath.Join(stagingDir, imagecat.RootfsFilename)
workSeedExt4 := s.runBuildWorkSeed(ctx, rootfsExt4, stagingDir)
stagedKernel, stagedInitrd, stagedModules, err := imagemgr.StageBootArtifacts(ctx, s.runner, stagingDir, kernelPath, initrdPath, modulesDir)
if err != nil {
@ -264,6 +270,9 @@ func (s *ImageService) pullFromBundle(ctx context.Context, params api.ImagePullP
CreatedAt: now,
UpdatedAt: now,
}
if workSeedExt4 != "" {
image.WorkSeedPath = filepath.Join(finalDir, filepath.Base(workSeedExt4))
}
published, err := s.publishImage(ctx, image, stagingDir, finalDir)
if err != nil {
return model.Image{}, err
@ -315,6 +324,30 @@ func (s *ImageService) runFinalizePulledRootfs(ctx context.Context, ext4File str
return nil
}
// runBuildWorkSeed extracts /root from the pulled rootfs into a
// sibling work-seed ext4 image. Any failure is treated as non-fatal:
// the image is still publishable without a seed, and VM create falls
// back to the empty-work-disk path (losing distro dotfiles but keeping
// every other guarantee). Returns the work-seed path on success, "" on
// failure (with a warn logged). Tests substitute via s.workSeedBuilder.
func (s *ImageService) runBuildWorkSeed(ctx context.Context, rootfsExt4, stagingDir string) string {
outPath := filepath.Join(stagingDir, "work-seed.ext4")
var err error
if s.workSeedBuilder != nil {
err = s.workSeedBuilder(ctx, rootfsExt4, outPath)
} else {
err = system.BuildWorkSeedImage(ctx, s.runner, rootfsExt4, outPath)
}
if err != nil {
if s.logger != nil {
s.logger.Warn("work-seed build failed; VMs using this image will start with an empty /root", "rootfs", rootfsExt4, "error", err.Error())
}
_ = os.Remove(outPath)
return ""
}
return outPath
}
// nameSanitize keeps lowercase alphanumerics + hyphens, collapses runs.
var nameSanitizeRE = regexp.MustCompile(`[^a-z0-9]+`)

View file

@ -72,6 +72,7 @@ func TestPullImageBundlePathRegistersFromCatalog(t *testing.T) {
store: d.store,
runner: d.runner,
bundleFetch: stubBundleFetch(imagecat.Manifest{KernelRef: "generic-6.12"}),
workSeedBuilder: stubWorkSeedBuilder,
}
wireServices(d)
@ -126,6 +127,7 @@ func TestPullImageBundlePathOverrideNameAndKernelRef(t *testing.T) {
store: d.store,
runner: d.runner,
bundleFetch: stubBundleFetch(imagecat.Manifest{KernelRef: "generic-6.12"}),
workSeedBuilder: stubWorkSeedBuilder,
}
wireServices(d)
@ -168,6 +170,7 @@ func TestPullImageBundlePathRejectsExistingName(t *testing.T) {
store: d.store,
runner: d.runner,
bundleFetch: stubBundleFetch(imagecat.Manifest{KernelRef: "generic-6.12"}),
workSeedBuilder: stubWorkSeedBuilder,
}
wireServices(d)
id, _ := model.NewID()
@ -198,6 +201,7 @@ func TestPullImageBundlePathRequiresSomeKernelSource(t *testing.T) {
store: d.store,
runner: d.runner,
bundleFetch: stubBundleFetch(imagecat.Manifest{}),
workSeedBuilder: stubWorkSeedBuilder,
}
wireServices(d)
// Catalog entry has no kernel_ref, no --kernel-ref/--kernel passed.
@ -226,6 +230,7 @@ func TestPullImageBundleFetchFailurePropagates(t *testing.T) {
bundleFetch: func(_ context.Context, _ string, _ imagecat.CatEntry) (imagecat.Manifest, error) {
return imagecat.Manifest{}, errors.New("r2 exploded")
},
workSeedBuilder: stubWorkSeedBuilder,
}
wireServices(d)
_, err := d.img.pullFromBundle(context.Background(), api.ImagePullParams{Ref: "x"}, imagecat.CatEntry{
@ -266,6 +271,7 @@ func TestPullImageDispatchFallsThroughToOCIWhenNoCatalogHit(t *testing.T) {
},
finalizePulledRootfs: stubFinalizePulledRootfs,
bundleFetch: stubBundleFetch(imagecat.Manifest{}),
workSeedBuilder: stubWorkSeedBuilder,
}
wireServices(d)

View file

@ -45,6 +45,15 @@ func stubFinalizePulledRootfs(_ context.Context, _ string, _ imagepull.Metadata)
return nil
}
// stubWorkSeedBuilder returns an error so runBuildWorkSeed treats
// the step as non-fatal and proceeds without a work-seed. Keeps tests
// off sudo mount without asserting on WorkSeedPath.
func stubWorkSeedBuilder(_ context.Context, _ string, _ string) error {
return errWorkSeedBuilderStub
}
var errWorkSeedBuilderStub = errors.New("work-seed builder stubbed in tests")
// 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) (imagepull.Metadata, error) {
@ -81,6 +90,7 @@ func TestPullImageHappyPath(t *testing.T) {
runner: d.runner,
pullAndFlatten: stubPullAndFlatten,
finalizePulledRootfs: stubFinalizePulledRootfs,
workSeedBuilder: stubWorkSeedBuilder,
}
wireServices(d)
@ -132,6 +142,7 @@ func TestPullImageRejectsExistingName(t *testing.T) {
runner: d.runner,
pullAndFlatten: stubPullAndFlatten,
finalizePulledRootfs: stubFinalizePulledRootfs,
workSeedBuilder: stubWorkSeedBuilder,
}
wireServices(d)
// Seed a preexisting image with the would-be derived name.
@ -166,6 +177,7 @@ func TestPullImageRequiresKernel(t *testing.T) {
runner: d.runner,
pullAndFlatten: stubPullAndFlatten,
finalizePulledRootfs: stubFinalizePulledRootfs,
workSeedBuilder: stubWorkSeedBuilder,
}
wireServices(d)
_, err := d.img.PullImage(context.Background(), api.ImagePullParams{
@ -194,6 +206,7 @@ func TestPullImageCleansStagingOnFailure(t *testing.T) {
runner: d.runner,
pullAndFlatten: failureSeam,
finalizePulledRootfs: stubFinalizePulledRootfs,
workSeedBuilder: stubWorkSeedBuilder,
}
wireServices(d)
_, err := d.img.PullImage(context.Background(), api.ImagePullParams{

View file

@ -175,4 +175,3 @@ func sshdGuestConfig() string {
"",
}, "\n")
}