daemon: rewrite git identity sync + file_sync on ext4 toolkit
ensureGitIdentityOnWorkDisk, writeGitIdentity, runFileSync, and copyHostDir all dropped their mount + sudo install/mkdir/chmod/chown scaffolding. Every write now goes through MkdirExt4, WriteExt4FileOwned, ReadExt4File, and the new MkdirAllExt4 helper — all sudoless against user-owned ext4 images. Net effect with the prior two commits: ensureWorkDisk, authsync, image seeding, git identity sync, and file_sync no longer mount the work disk or spawn sudo mkdir/chmod/chown/cat/install. Only the image-build path (which legitimately produces root-owned artifacts) still touches MountTempDir. The filesystemRunner test harness grew a small debugfs/e2cp/e2rm emulator so the WorkspaceService tests keep exercising their real code paths without a live ext4 image. The mock is deliberately dumb — it only implements the subset runFileSync and writeGitIdentity drive. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f0685366ec
commit
6ab1a2b844
3 changed files with 253 additions and 74 deletions
|
|
@ -5,7 +5,9 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"banger/internal/guest"
|
||||
|
|
@ -115,17 +117,7 @@ func (s *WorkspaceService) ensureGitIdentityOnWorkDisk(ctx context.Context, vm *
|
|||
}
|
||||
|
||||
vmCreateStage(ctx, "prepare_work_disk", "syncing git identity")
|
||||
workMount, cleanupWork, err := system.MountTempDir(ctx, runner, vm.Runtime.WorkDiskPath, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanupWork()
|
||||
|
||||
if err := flattenNestedWorkHome(ctx, s.runner, workMount); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return writeGitIdentity(ctx, runner, filepath.Join(workMount, workDiskGitConfigRelativePath), identity)
|
||||
return writeGitIdentity(ctx, runner, vm.Runtime.WorkDiskPath, "/"+workDiskGitConfigRelativePath, identity)
|
||||
}
|
||||
|
||||
// runFileSync applies every [[file_sync]] entry from the daemon config
|
||||
|
|
@ -133,10 +125,10 @@ func (s *WorkspaceService) ensureGitIdentityOnWorkDisk(ctx context.Context, vm *
|
|||
// Other errors abort the VM create (since the user explicitly asked
|
||||
// for the sync).
|
||||
//
|
||||
// File entries: `install -o 0 -g 0 -m <mode>` (mode defaults to 0600).
|
||||
// Directory entries: walked in Go — each file is installed with its
|
||||
// source permissions, each subdir is mkdir'd. The entry's `mode`
|
||||
// field is only honoured for file entries.
|
||||
// Operates directly on the ext4 image via the sudoless toolkit — no
|
||||
// mount, no privileged install(1). Every write lands as root:root;
|
||||
// file modes come from the [[file_sync]] entry (default 0600),
|
||||
// directory modes from the source on the host.
|
||||
func (s *WorkspaceService) runFileSync(ctx context.Context, vm *model.VMRecord) error {
|
||||
if len(s.config.FileSync) == 0 {
|
||||
return nil
|
||||
|
|
@ -152,33 +144,12 @@ func (s *WorkspaceService) runFileSync(ctx context.Context, vm *model.VMRecord)
|
|||
return fmt.Errorf("resolve host user home: %w", err)
|
||||
}
|
||||
|
||||
// Mount the work disk once and reuse for every entry.
|
||||
var workMount string
|
||||
var cleanupWork func() error
|
||||
ensureMount := func() (string, error) {
|
||||
if workMount != "" {
|
||||
return workMount, nil
|
||||
}
|
||||
m, c, err := system.MountTempDir(ctx, runner, vm.Runtime.WorkDiskPath, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
workMount = m
|
||||
cleanupWork = c
|
||||
if err := flattenNestedWorkHome(ctx, s.runner, workMount); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return workMount, nil
|
||||
}
|
||||
defer func() {
|
||||
if cleanupWork != nil {
|
||||
cleanupWork()
|
||||
}
|
||||
}()
|
||||
workDisk := vm.Runtime.WorkDiskPath
|
||||
|
||||
for _, entry := range s.config.FileSync {
|
||||
hostPath := expandHostPath(entry.Host, hostHome)
|
||||
guestRel := guestPathRelativeToRoot(entry.Guest)
|
||||
guestImagePath := "/" + guestRel
|
||||
|
||||
info, err := os.Stat(hostPath)
|
||||
if err != nil {
|
||||
|
|
@ -189,48 +160,49 @@ func (s *WorkspaceService) runFileSync(ctx context.Context, vm *model.VMRecord)
|
|||
return fmt.Errorf("file_sync: stat %s: %w", hostPath, err)
|
||||
}
|
||||
|
||||
mount, err := ensureMount()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
vmCreateStage(ctx, "prepare_work_disk", "file sync: "+entry.Host+" → "+entry.Guest)
|
||||
|
||||
target := filepath.Join(mount, guestRel)
|
||||
if _, err := runner.RunSudo(ctx, "mkdir", "-p", filepath.Dir(target)); err != nil {
|
||||
return fmt.Errorf("file_sync: mkdir %s: %w", filepath.Dir(target), err)
|
||||
parent := path.Dir(guestImagePath)
|
||||
if parent != "/" && parent != "." {
|
||||
if err := system.MkdirAllExt4(ctx, runner, workDisk, parent, 0o755, 0, 0); err != nil {
|
||||
return fmt.Errorf("file_sync: mkdir %s: %w", parent, err)
|
||||
}
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
if err := s.copyHostDir(ctx, *vm, runner, hostPath, target); err != nil {
|
||||
return fmt.Errorf("file_sync: copy directory %s → %s: %w", hostPath, target, err)
|
||||
if err := s.copyHostDir(ctx, *vm, runner, workDisk, hostPath, guestImagePath); err != nil {
|
||||
return fmt.Errorf("file_sync: copy directory %s → %s: %w", hostPath, guestImagePath, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
mode := entry.Mode
|
||||
if mode == "" {
|
||||
mode = "0600"
|
||||
mode, err := parseFileSyncMode(entry.Mode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("file_sync: %s: %w", entry.Host, err)
|
||||
}
|
||||
if _, err := runner.RunSudo(ctx, "install", "-o", "0", "-g", "0", "-m", mode, hostPath, target); err != nil {
|
||||
return fmt.Errorf("file_sync: install %s → %s: %w", hostPath, target, err)
|
||||
data, err := os.ReadFile(hostPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("file_sync: read %s: %w", hostPath, err)
|
||||
}
|
||||
if err := system.WriteExt4FileOwned(ctx, runner, workDisk, guestImagePath, mode, 0, 0, data); err != nil {
|
||||
return fmt.Errorf("file_sync: write %s → %s: %w", hostPath, guestImagePath, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyHostDir recursively copies hostDir into guestTarget using only
|
||||
// `mkdir` (for subdirs) and `install` (for files). Each file's source
|
||||
// permissions are preserved; ownership is forced to root:root via
|
||||
// `install -o 0 -g 0`. Symlinks encountered during recursion are
|
||||
// SKIPPED with a warning — `os.Lstat` tells us the entry itself is a
|
||||
// link without resolving it, so a symlink inside ~/.aws that points
|
||||
// at ~/secrets can't leak out of the tree the user named. Other
|
||||
// special types (devices, FIFOs) are skipped silently. Top-level
|
||||
// host paths go through `os.Stat` back in runFileSync and still
|
||||
// follow, since the user explicitly named that path.
|
||||
func (s *WorkspaceService) copyHostDir(ctx context.Context, vm model.VMRecord, runner system.CommandRunner, hostDir, guestTarget string) error {
|
||||
if _, err := runner.RunSudo(ctx, "mkdir", "-p", guestTarget); err != nil {
|
||||
// copyHostDir recursively copies hostDir into guestTarget on the
|
||||
// ext4 image via the sudoless toolkit. Each file's source permissions
|
||||
// are preserved; directories get 0755; ownership is forced to
|
||||
// root:root. Symlinks are SKIPPED with a warning — os.Lstat identifies
|
||||
// the entry itself as a link without resolving it, so a symlink
|
||||
// inside ~/.aws that points at ~/secrets can't leak out of the tree
|
||||
// the user named. Other special types (devices, FIFOs) are skipped
|
||||
// silently. Top-level host paths go through os.Stat back in
|
||||
// runFileSync and still follow, since the user explicitly named that
|
||||
// path.
|
||||
func (s *WorkspaceService) copyHostDir(ctx context.Context, vm model.VMRecord, runner system.CommandRunner, imagePath, hostDir, guestTarget string) error {
|
||||
if err := system.MkdirExt4(ctx, runner, imagePath, guestTarget, 0o755, 0, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
entries, err := os.ReadDir(hostDir)
|
||||
|
|
@ -239,7 +211,7 @@ func (s *WorkspaceService) copyHostDir(ctx context.Context, vm model.VMRecord, r
|
|||
}
|
||||
for _, entry := range entries {
|
||||
hostChild := filepath.Join(hostDir, entry.Name())
|
||||
guestChild := filepath.Join(guestTarget, entry.Name())
|
||||
guestChild := path.Join(guestTarget, entry.Name())
|
||||
|
||||
info, err := os.Lstat(hostChild)
|
||||
if err != nil {
|
||||
|
|
@ -249,12 +221,15 @@ func (s *WorkspaceService) copyHostDir(ctx context.Context, vm model.VMRecord, r
|
|||
case info.Mode()&os.ModeSymlink != 0:
|
||||
s.warnFileSyncSymlinkSkipped(vm, hostChild)
|
||||
case info.IsDir():
|
||||
if err := s.copyHostDir(ctx, vm, runner, hostChild, guestChild); err != nil {
|
||||
if err := s.copyHostDir(ctx, vm, runner, imagePath, hostChild, guestChild); err != nil {
|
||||
return err
|
||||
}
|
||||
case info.Mode().IsRegular():
|
||||
mode := fmt.Sprintf("%04o", info.Mode().Perm())
|
||||
if _, err := runner.RunSudo(ctx, "install", "-o", "0", "-g", "0", "-m", mode, hostChild, guestChild); err != nil {
|
||||
data, err := os.ReadFile(hostChild)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := system.WriteExt4FileOwned(ctx, runner, imagePath, guestChild, info.Mode().Perm(), 0, 0, data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
@ -262,6 +237,21 @@ func (s *WorkspaceService) copyHostDir(ctx context.Context, vm model.VMRecord, r
|
|||
return nil
|
||||
}
|
||||
|
||||
// parseFileSyncMode parses the [[file_sync]] mode field (octal string,
|
||||
// default "0600"). Returns the parsed FileMode with only the permission
|
||||
// bits set; callers OR in S_IFREG via WriteExt4FileOwned.
|
||||
func parseFileSyncMode(raw string) (os.FileMode, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
raw = "0600"
|
||||
}
|
||||
v, err := strconv.ParseUint(raw, 8, 32)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse mode %q: %w", raw, err)
|
||||
}
|
||||
return os.FileMode(v) & os.ModePerm, nil
|
||||
}
|
||||
|
||||
// expandHostPath expands a leading "~/" against the host user's
|
||||
// home. Already-absolute paths pass through unchanged.
|
||||
func expandHostPath(raw, home string) string {
|
||||
|
|
@ -321,10 +311,23 @@ func gitConfigValue(ctx context.Context, runner system.CommandRunner, extraArgs
|
|||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
func writeGitIdentity(ctx context.Context, runner system.CommandRunner, gitConfigPath string, identity gitIdentity) error {
|
||||
existing, err := runner.RunSudo(ctx, "cat", gitConfigPath)
|
||||
// writeGitIdentity merges user.name + user.email into the on-image
|
||||
// gitconfig at guestPath. Reads the existing bytes via the ext4
|
||||
// toolkit (no-op to empty if absent), edits via `git config --file`
|
||||
// on a host tempfile so any pre-existing unrelated sections are
|
||||
// preserved verbatim, then writes back through WriteExt4FileOwned
|
||||
// at 0644 root:root.
|
||||
func writeGitIdentity(ctx context.Context, runner system.CommandRunner, imagePath, guestPath string, identity gitIdentity) error {
|
||||
var existing []byte
|
||||
exists, err := system.Ext4PathExists(ctx, runner, imagePath, guestPath)
|
||||
if err != nil {
|
||||
existing = nil
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
existing, err = system.ReadExt4File(ctx, runner, imagePath, guestPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "banger-gitconfig-*")
|
||||
|
|
@ -349,9 +352,12 @@ func writeGitIdentity(ctx context.Context, runner system.CommandRunner, gitConfi
|
|||
if _, err := runner.Run(ctx, "git", "config", "--file", tmpPath, "user.email", identity.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = runner.RunSudo(ctx, "install", "-m", "644", tmpPath, gitConfigPath)
|
||||
merged, err := os.ReadFile(tmpPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return system.WriteExt4FileOwned(ctx, runner, imagePath, guestPath, 0o644, 0, 0, merged)
|
||||
}
|
||||
|
||||
func (s *WorkspaceService) warnFileSyncSkipped(vm model.VMRecord, hostPath string, err error) {
|
||||
if s.logger == nil || err == nil {
|
||||
|
|
|
|||
|
|
@ -2031,11 +2031,147 @@ func (r *filesystemRunner) RunSudo(ctx context.Context, args ...string) ([]byte,
|
|||
default:
|
||||
return nil, fmt.Errorf("unexpected chown args: %v", args)
|
||||
}
|
||||
case "debugfs":
|
||||
return runFakeDebugfs(args[1:])
|
||||
case "e2cp":
|
||||
// e2cp SRC IMAGE:/GUEST → plain file copy into IMAGE dir
|
||||
if len(args) != 3 {
|
||||
return nil, fmt.Errorf("unexpected e2cp args: %v", args)
|
||||
}
|
||||
image, guest, ok := splitImageColonPath(args[2])
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("e2cp dst missing image:path separator: %v", args)
|
||||
}
|
||||
target := filepath.Join(image, guest)
|
||||
data, err := os.ReadFile(args[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, os.WriteFile(target, data, 0o600)
|
||||
case "e2rm":
|
||||
// e2rm IMAGE:/GUEST → plain file delete; missing is not fatal
|
||||
if len(args) != 2 {
|
||||
return nil, fmt.Errorf("unexpected e2rm args: %v", args)
|
||||
}
|
||||
image, guest, ok := splitImageColonPath(args[1])
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("e2rm missing image:path separator: %v", args)
|
||||
}
|
||||
target := filepath.Join(image, guest)
|
||||
if err := os.Remove(target); err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected sudo command: %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
// runFakeDebugfs emulates the subset of debugfs commands the ext4
|
||||
// toolkit drives in per-line mode (the stdin-batched path doesn't run
|
||||
// under filesystemRunner because it doesn't implement StdinRunner).
|
||||
// Supported: stat/cat, plus -w mkdir/set_inode_field. Inode 2 <2>
|
||||
// set_inode_field is a no-op — tests don't care about root-inode mode
|
||||
// beyond it not exploding.
|
||||
func runFakeDebugfs(args []string) ([]byte, error) {
|
||||
// Forms:
|
||||
// debugfs -R "<cmd>" <image> (read-only)
|
||||
// debugfs -w -R "<cmd>" <image> (single write)
|
||||
if len(args) < 3 {
|
||||
return nil, fmt.Errorf("unexpected debugfs args: %v", args)
|
||||
}
|
||||
write := false
|
||||
rest := args
|
||||
if rest[0] == "-w" {
|
||||
write = true
|
||||
rest = rest[1:]
|
||||
}
|
||||
if len(rest) != 3 || rest[0] != "-R" {
|
||||
return nil, fmt.Errorf("unexpected debugfs args: %v", args)
|
||||
}
|
||||
cmdLine := strings.TrimSpace(rest[1])
|
||||
image := rest[2]
|
||||
|
||||
fields := strings.Fields(cmdLine)
|
||||
if len(fields) == 0 {
|
||||
return nil, fmt.Errorf("empty debugfs command")
|
||||
}
|
||||
switch fields[0] {
|
||||
case "stat":
|
||||
if len(fields) != 2 {
|
||||
return nil, fmt.Errorf("unexpected debugfs stat: %q", cmdLine)
|
||||
}
|
||||
target := filepath.Join(image, strings.Trim(fields[1], `"`))
|
||||
if _, err := os.Stat(target); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []byte("stat: File not found by ext2_lookup while starting pathname"), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return []byte("Inode: 12 Type: directory"), nil
|
||||
case "cat":
|
||||
if len(fields) != 2 {
|
||||
return nil, fmt.Errorf("unexpected debugfs cat: %q", cmdLine)
|
||||
}
|
||||
target := filepath.Join(image, strings.Trim(fields[1], `"`))
|
||||
data, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data, nil
|
||||
case "mkdir":
|
||||
if !write {
|
||||
return nil, fmt.Errorf("debugfs mkdir requires -w: %q", cmdLine)
|
||||
}
|
||||
if len(fields) != 2 {
|
||||
return nil, fmt.Errorf("unexpected debugfs mkdir: %q", cmdLine)
|
||||
}
|
||||
target := filepath.Join(image, strings.Trim(fields[1], `"`))
|
||||
return nil, os.MkdirAll(target, 0o755)
|
||||
case "set_inode_field":
|
||||
// set_inode_field <path-or-<2>> <field> <value>
|
||||
// Mode changes on non-root targets: honour the perm bits so
|
||||
// tests can assert file mode. Root inode <2>, uid, gid are
|
||||
// no-ops — tests don't inspect them.
|
||||
if !write {
|
||||
return nil, fmt.Errorf("debugfs set_inode_field requires -w: %q", cmdLine)
|
||||
}
|
||||
if len(fields) != 4 {
|
||||
return nil, fmt.Errorf("unexpected set_inode_field: %q", cmdLine)
|
||||
}
|
||||
target := strings.Trim(fields[1], `"`)
|
||||
if target == "<2>" || fields[2] != "mode" {
|
||||
return nil, nil
|
||||
}
|
||||
raw := strings.TrimPrefix(fields[3], "0")
|
||||
v, err := strconv.ParseUint(raw, 8, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse set_inode_field mode %q: %w", fields[3], err)
|
||||
}
|
||||
return nil, os.Chmod(filepath.Join(image, target), os.FileMode(v)&os.ModePerm)
|
||||
case "rdump":
|
||||
// rdump <src> <dst>
|
||||
return nil, fmt.Errorf("rdump not supported in filesystemRunner")
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported debugfs cmd: %q", cmdLine)
|
||||
}
|
||||
}
|
||||
|
||||
// splitImageColonPath splits an e2cp/e2rm "image:path" argument.
|
||||
// Returns image, path, true on success. Only the LAST colon is split
|
||||
// on since image paths on disk may contain one (rare) and guest paths
|
||||
// always start with "/".
|
||||
func splitImageColonPath(arg string) (string, string, bool) {
|
||||
idx := strings.LastIndex(arg, ":/")
|
||||
if idx < 0 {
|
||||
return "", "", false
|
||||
}
|
||||
return arg[:idx], arg[idx+1:], true
|
||||
}
|
||||
|
||||
// parseInstallArgs recognises the `install` invocations banger emits
|
||||
// and returns (source, destination, parsed mode). Anything else is an
|
||||
// error so the test stub stays a closed set.
|
||||
|
|
|
|||
|
|
@ -49,6 +49,43 @@ func MkdirExt4(ctx context.Context, runner CommandRunner, imagePath, guestPath s
|
|||
return debugfsScript(ctx, runner, imagePath, &script)
|
||||
}
|
||||
|
||||
// MkdirAllExt4 creates each intermediate directory in guestPath that
|
||||
// doesn't already exist, with the given mode/uid/gid. Mirrors
|
||||
// os.MkdirAll's shape, not mkdir(1) -p: existing directories are left
|
||||
// with their current metadata untouched (we don't reset mode/uid/gid
|
||||
// on pre-existing parents, only on the final segment). Paths starting
|
||||
// at "/" are allowed — the root is treated as pre-existing.
|
||||
func MkdirAllExt4(ctx context.Context, runner CommandRunner, imagePath, guestPath string, mode os.FileMode, uid, gid int) error {
|
||||
if err := rejectDebugfsUnsafePath(guestPath); err != nil {
|
||||
return err
|
||||
}
|
||||
segments := strings.Split(strings.Trim(guestPath, "/"), "/")
|
||||
cur := ""
|
||||
for i, seg := range segments {
|
||||
if seg == "" {
|
||||
continue
|
||||
}
|
||||
cur = cur + "/" + seg
|
||||
exists, err := Ext4PathExists(ctx, runner, imagePath, cur)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
// Intermediate dirs inherit the requested mode/uid/gid too —
|
||||
// callers that want a different mode on parents should create
|
||||
// them explicitly. Matches the most common use (mkdir -p a
|
||||
// config tree where every hop is root-owned).
|
||||
if i < len(segments)-1 || !exists {
|
||||
if err := MkdirExt4(ctx, runner, imagePath, cur, mode, uid, gid); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteExt4FileOwned copies `data` into <imagePath>:<guestPath> and
|
||||
// forces the inode's uid/gid/mode to the requested values. Unlike
|
||||
// WriteExt4FileMode, this helper does NOT assume the image is a
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue