From 408ad6756c46e640ce0bd3e0fbf4b244c7985f71 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Sun, 26 Apr 2026 20:18:23 -0300 Subject: [PATCH] system: build work-seed without sudo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BuildWorkSeedImage used to mount the source rootfs and the new seed image — both via sudo. After the privilege split (59e48e8) the owner daemon runs without sudo and those mounts fail silently inside the image-pull pipeline (runBuildWorkSeed swallows errors), so every freshly pulled image landed in the store with an empty WorkSeedPath and 'banger doctor' kept warning that /root would be empty. Rewrite the builder around the existing sudoless toolkit: 1. RdumpExt4Dir extracts /root from the source rootfs into a host tempdir (debugfs, no mount). 2. truncate + mkfs.ext4 -F -E root_owner=0:0 produces an empty user-owned ext4 file. 3. A Go walk over the staged tree calls MkdirExt4 / WriteExt4FileOwned for every dir + regular file, forcing root:root and preserving mode bits. Symlinks and special files in /root are skipped — extremely rare on a stock distro and not part of what makes a useful seed. Fix won't retroactively populate already-pulled images: re-pull the default image (e.g. 'banger image delete debian-bookworm && banger image pull debian-bookworm') to get a seeded work-seed.ext4. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/system/files.go | 70 +++++++++++++++++++++++++++++++++------- 1 file changed, 59 insertions(+), 11 deletions(-) diff --git a/internal/system/files.go b/internal/system/files.go index 3ca732e..6a991a1 100644 --- a/internal/system/files.go +++ b/internal/system/files.go @@ -68,14 +68,33 @@ func WorkSeedPath(rootfsPath string) string { return rootfsPath + ".work-seed" } +// BuildWorkSeedImage creates a sized ext4 image at outPath containing +// the /root subtree of rootfsPath. Uses only sudoless tooling: rdump +// to extract via debugfs, mkfs.ext4 to create the empty image (the +// output file is user-owned, so no elevation needed), and the ext4 +// toolkit (MkdirExt4 / WriteExt4FileOwned) to ingest each entry as +// root:root. Symlinks and special files are skipped — /root in a +// stock distro contains regular files and dirs only. func BuildWorkSeedImage(ctx context.Context, runner CommandRunner, rootfsPath, outPath string) error { - rootMount, cleanupRoot, err := MountTempDir(ctx, runner, rootfsPath, true) + stage, err := os.MkdirTemp("", "banger-work-seed-stage-") if err != nil { return err } - defer cleanupRoot() + defer os.RemoveAll(stage) + + if err := RdumpExt4Dir(ctx, runner, rootfsPath, "/root", stage); err != nil { + return fmt.Errorf("extract /root from %s: %w", rootfsPath, err) + } + rootHome := filepath.Join(stage, "root") + if _, err := os.Stat(rootHome); err != nil { + // rootfs has no /root (unusual). Build an empty seed so the + // caller still gets a usable artifact — VMs cloning it will + // just see an empty fs root, same as the no-seed fallback. + if err := os.MkdirAll(rootHome, 0o755); err != nil { + return err + } + } - rootHome := filepath.Join(rootMount, "root") sizeBytes, err := estimateWorkSeedSize(ctx, runner, rootHome) if err != nil { return err @@ -93,17 +112,46 @@ func BuildWorkSeedImage(ctx context.Context, runner CommandRunner, rootfsPath, o if err := os.Truncate(outPath, sizeBytes); err != nil { return err } - if _, err := runner.Run(ctx, "mkfs.ext4", "-F", outPath); err != nil { + // `-E root_owner=0:0` stamps inode 2 (which becomes /root in the + // guest) as root:root. Per-entry owners are forced via the ext4 + // toolkit walk below. + if _, err := runner.Run(ctx, "mkfs.ext4", "-F", "-E", "root_owner=0:0", outPath); err != nil { return err } + return ingestWorkSeedTree(ctx, runner, outPath, rootHome) +} - workMount, cleanupWork, err := MountTempDir(ctx, runner, outPath, false) - if err != nil { - return err - } - defer cleanupWork() - - return CopyDirContents(ctx, runner, rootHome, workMount, true) +// ingestWorkSeedTree walks the staged host tree and writes every +// directory and regular file into the work-seed ext4 as root:root, +// preserving source mode bits. Symlinks and special files are +// skipped silently — they are vanishingly rare in distro /root and +// don't survive the work-seed → work-disk clone path either. +func ingestWorkSeedTree(ctx context.Context, runner CommandRunner, imagePath, srcRoot string) error { + srcRoot = filepath.Clean(srcRoot) + return filepath.Walk(srcRoot, func(hostPath string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + if hostPath == srcRoot { + return nil + } + rel, err := filepath.Rel(srcRoot, hostPath) + if err != nil { + return err + } + guestPath := "/" + filepath.ToSlash(rel) + switch { + case info.IsDir(): + return MkdirExt4(ctx, runner, imagePath, guestPath, info.Mode().Perm(), 0, 0) + case info.Mode().IsRegular(): + data, err := os.ReadFile(hostPath) + if err != nil { + return err + } + return WriteExt4FileOwned(ctx, runner, imagePath, guestPath, info.Mode().Perm(), 0, 0, data) + } + return nil + }) } func estimateWorkSeedSize(ctx context.Context, runner CommandRunner, rootHome string) (int64, error) {