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

@ -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]+`)