file_sync: config-driven replacement for hardcoded auth sync
Replace the three hardcoded host→guest credential syncs (opencode,
claude, pi) with a generic `[[file_sync]]` config list. Default is
empty — users opt in to exactly what they want synced, with no
surprise about which tools banger "supports".
```toml
[[file_sync]]
host = "~/.local/share/opencode/auth.json"
guest = "~/.local/share/opencode/auth.json"
[[file_sync]]
host = "~/.aws" # directories are copied recursively
guest = "~/.aws"
[[file_sync]]
host = "~/bin/my-script"
guest = "~/bin/my-script"
mode = "0755" # optional; default 0600 for files
```
Semantics:
- Host `~/...` expands against the host user's $HOME. Absolute host
paths are used as-is.
- Guest must live under `~/` or `/root/...` — banger's work disk is
mounted at /root in the guest, so that's the syncable namespace.
Anything outside is rejected at config load.
- Validation at config load: reject empty paths, relative paths,
`..` traversal, `~user/...`, malformed mode strings. Errors name
the offending entry index.
- Missing host paths are a soft skip with a warn log (existing
behaviour). Other errors (read, mkdir, install) abort VM create.
- File entries: `install -o 0 -g 0 -m <mode>` (default 0600).
- Directory entries: walked in Go; each source file is installed
with its own source permissions preserved. The entry's `mode` is
ignored for directories.
Removed (all dead after this):
- `ensureOpencodeAuthOnWorkDisk`, `ensureClaudeAuthOnWorkDisk`,
`ensurePiAuthOnWorkDisk`, the shared `ensureAuthFileOnWorkDisk`,
their `warn*Skipped` helpers, `resolveHost{Opencode,Claude,Pi}AuthPath`,
and the work-disk relative-path + default display-path constants.
- The capability hook registering the three syncs now calls the
generic `runFileSync` once.
Seven tests exercising the old codepath deleted; six new tests cover
the new runFileSync (no-op on empty config, file copy, custom mode,
missing-host-skip, overwrite, recursive directory). Config-layer
test adds happy-path parsing and a case-per-shape table of invalid
entries (empty, relative host, guest outside /root, '..' traversal,
`~user`, bad mode).
README updated: replaces the "Credential sync" section with a
"File sync" section showing the new config shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
843314be5e
commit
0933deaeb1
7 changed files with 572 additions and 398 deletions
|
|
@ -210,13 +210,7 @@ func (workDiskCapability) PrepareHost(ctx context.Context, d *Daemon, vm *model.
|
|||
if err := d.ensureGitIdentityOnWorkDisk(ctx, vm); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.ensureOpencodeAuthOnWorkDisk(ctx, vm); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.ensureClaudeAuthOnWorkDisk(ctx, vm); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.ensurePiAuthOnWorkDisk(ctx, vm)
|
||||
return d.runFileSync(ctx, vm)
|
||||
}
|
||||
|
||||
func (workDiskCapability) AddDoctorChecks(_ context.Context, d *Daemon, report *system.Report) {
|
||||
|
|
|
|||
|
|
@ -14,17 +14,8 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
workDiskGitConfigRelativePath = ".gitconfig"
|
||||
workDiskOpencodeAuthDirRelativePath = ".local/share/opencode"
|
||||
workDiskOpencodeAuthRelativePath = workDiskOpencodeAuthDirRelativePath + "/auth.json"
|
||||
workDiskClaudeAuthDirRelativePath = ".claude"
|
||||
workDiskClaudeAuthRelativePath = workDiskClaudeAuthDirRelativePath + "/.credentials.json"
|
||||
workDiskPiAuthDirRelativePath = ".pi/agent"
|
||||
workDiskPiAuthRelativePath = workDiskPiAuthDirRelativePath + "/auth.json"
|
||||
hostGlobalGitIdentitySource = "git config --global"
|
||||
hostOpencodeAuthDefaultDisplayPath = "~/" + workDiskOpencodeAuthRelativePath
|
||||
hostClaudeAuthDefaultDisplayPath = "~/" + workDiskClaudeAuthRelativePath
|
||||
hostPiAuthDefaultDisplayPath = "~/" + workDiskPiAuthRelativePath
|
||||
workDiskGitConfigRelativePath = ".gitconfig"
|
||||
hostGlobalGitIdentitySource = "git config --global"
|
||||
)
|
||||
|
||||
type gitIdentity struct {
|
||||
|
|
@ -125,51 +116,17 @@ func (d *Daemon) ensureGitIdentityOnWorkDisk(ctx context.Context, vm *model.VMRe
|
|||
return writeGitIdentity(ctx, runner, filepath.Join(workMount, workDiskGitConfigRelativePath), identity)
|
||||
}
|
||||
|
||||
func (d *Daemon) ensureOpencodeAuthOnWorkDisk(ctx context.Context, vm *model.VMRecord) error {
|
||||
return d.ensureAuthFileOnWorkDisk(
|
||||
ctx,
|
||||
vm,
|
||||
"syncing opencode auth",
|
||||
hostOpencodeAuthDefaultDisplayPath,
|
||||
resolveHostOpencodeAuthPath,
|
||||
workDiskOpencodeAuthRelativePath,
|
||||
d.warnOpencodeAuthSyncSkipped,
|
||||
)
|
||||
}
|
||||
|
||||
func (d *Daemon) ensureClaudeAuthOnWorkDisk(ctx context.Context, vm *model.VMRecord) error {
|
||||
return d.ensureAuthFileOnWorkDisk(
|
||||
ctx,
|
||||
vm,
|
||||
"syncing claude auth",
|
||||
hostClaudeAuthDefaultDisplayPath,
|
||||
resolveHostClaudeAuthPath,
|
||||
workDiskClaudeAuthRelativePath,
|
||||
d.warnClaudeAuthSyncSkipped,
|
||||
)
|
||||
}
|
||||
|
||||
func (d *Daemon) ensurePiAuthOnWorkDisk(ctx context.Context, vm *model.VMRecord) error {
|
||||
return d.ensureAuthFileOnWorkDisk(
|
||||
ctx,
|
||||
vm,
|
||||
"syncing pi auth",
|
||||
hostPiAuthDefaultDisplayPath,
|
||||
resolveHostPiAuthPath,
|
||||
workDiskPiAuthRelativePath,
|
||||
d.warnPiAuthSyncSkipped,
|
||||
)
|
||||
}
|
||||
|
||||
func (d *Daemon) ensureAuthFileOnWorkDisk(ctx context.Context, vm *model.VMRecord, stageDetail, defaultDisplayPath string, resolveHostPath func() (string, error), guestRelativePath string, warn func(model.VMRecord, string, error)) error {
|
||||
hostAuthPath, err := resolveHostPath()
|
||||
if err != nil {
|
||||
warn(*vm, defaultDisplayPath, err)
|
||||
return nil
|
||||
}
|
||||
authData, err := os.ReadFile(hostAuthPath)
|
||||
if err != nil {
|
||||
warn(*vm, hostAuthPath, err)
|
||||
// runFileSync applies every [[file_sync]] entry from the daemon config
|
||||
// to the VM's work disk. Missing host paths are skipped with a warn.
|
||||
// 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.
|
||||
func (d *Daemon) runFileSync(ctx context.Context, vm *model.VMRecord) error {
|
||||
if len(d.config.FileSync) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -178,61 +135,141 @@ func (d *Daemon) ensureAuthFileOnWorkDisk(ctx context.Context, vm *model.VMRecor
|
|||
runner = system.NewRunner()
|
||||
}
|
||||
|
||||
vmCreateStage(ctx, "prepare_work_disk", stageDetail)
|
||||
workMount, cleanupWork, err := system.MountTempDir(ctx, runner, vm.Runtime.WorkDiskPath, false)
|
||||
hostHome, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
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 := d.flattenNestedWorkHome(ctx, workMount); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return workMount, nil
|
||||
}
|
||||
defer func() {
|
||||
if cleanupWork != nil {
|
||||
cleanupWork()
|
||||
}
|
||||
}()
|
||||
|
||||
for _, entry := range d.config.FileSync {
|
||||
hostPath := expandHostPath(entry.Host, hostHome)
|
||||
guestRel := guestPathRelativeToRoot(entry.Guest)
|
||||
|
||||
info, err := os.Stat(hostPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
d.warnFileSyncSkipped(*vm, hostPath, err)
|
||||
continue
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
if err := copyHostDir(ctx, runner, hostPath, target); err != nil {
|
||||
return fmt.Errorf("file_sync: copy directory %s → %s: %w", hostPath, target, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
mode := entry.Mode
|
||||
if mode == "" {
|
||||
mode = "0600"
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
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 are followed (target content is
|
||||
// copied as a regular file). Other special types (devices, FIFOs)
|
||||
// are skipped silently.
|
||||
func copyHostDir(ctx context.Context, runner system.CommandRunner, hostDir, guestTarget string) error {
|
||||
if _, err := runner.RunSudo(ctx, "mkdir", "-p", guestTarget); err != nil {
|
||||
return err
|
||||
}
|
||||
entries, err := os.ReadDir(hostDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanupWork()
|
||||
for _, entry := range entries {
|
||||
hostChild := filepath.Join(hostDir, entry.Name())
|
||||
guestChild := filepath.Join(guestTarget, entry.Name())
|
||||
|
||||
if err := d.flattenNestedWorkHome(ctx, workMount); err != nil {
|
||||
return err
|
||||
info, err := os.Stat(hostChild)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case info.IsDir():
|
||||
if err := copyHostDir(ctx, runner, 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 {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
authDir := filepath.Join(workMount, filepath.Dir(guestRelativePath))
|
||||
if _, err := runner.RunSudo(ctx, "mkdir", "-p", authDir); err != nil {
|
||||
return err
|
||||
}
|
||||
authPath := filepath.Join(workMount, guestRelativePath)
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "banger-auth-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
if _, err := tmpFile.Write(authData); err != nil {
|
||||
_ = tmpFile.Close()
|
||||
_ = os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
_, err = runner.RunSudo(ctx, "install", "-m", "600", tmpPath, authPath)
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveHostOpencodeAuthPath() (string, error) {
|
||||
return resolveHostAuthPath(workDiskOpencodeAuthRelativePath)
|
||||
}
|
||||
|
||||
func resolveHostClaudeAuthPath() (string, error) {
|
||||
return resolveHostAuthPath(workDiskClaudeAuthRelativePath)
|
||||
}
|
||||
|
||||
func resolveHostPiAuthPath() (string, error) {
|
||||
return resolveHostAuthPath(workDiskPiAuthRelativePath)
|
||||
}
|
||||
|
||||
func resolveHostAuthPath(relativePath string) (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
// expandHostPath expands a leading "~/" against the host user's
|
||||
// home. Already-absolute paths pass through unchanged.
|
||||
func expandHostPath(raw, home string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if strings.HasPrefix(raw, "~/") {
|
||||
return filepath.Join(home, strings.TrimPrefix(raw, "~/"))
|
||||
}
|
||||
return filepath.Join(home, relativePath), nil
|
||||
return raw
|
||||
}
|
||||
|
||||
// guestPathRelativeToRoot returns the guest path as a relative path
|
||||
// under /root (banger's work disk is mounted at /root in the guest,
|
||||
// so everything syncable lives there). "~/foo" and "/root/foo" both
|
||||
// return "foo"; config validation rejects anything outside that
|
||||
// scope, so the string prefixes are the only forms we see here.
|
||||
func guestPathRelativeToRoot(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
switch {
|
||||
case raw == "~" || raw == "/root":
|
||||
return ""
|
||||
case strings.HasPrefix(raw, "~/"):
|
||||
return strings.TrimPrefix(raw, "~/")
|
||||
case strings.HasPrefix(raw, "/root/"):
|
||||
return strings.TrimPrefix(raw, "/root/")
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func resolveHostGlobalGitIdentity(ctx context.Context, runner system.CommandRunner) (gitIdentity, error) {
|
||||
|
|
@ -298,25 +335,11 @@ func writeGitIdentity(ctx context.Context, runner system.CommandRunner, gitConfi
|
|||
return err
|
||||
}
|
||||
|
||||
func (d *Daemon) warnOpencodeAuthSyncSkipped(vm model.VMRecord, hostPath string, err error) {
|
||||
func (d *Daemon) warnFileSyncSkipped(vm model.VMRecord, hostPath string, err error) {
|
||||
if d.logger == nil || err == nil {
|
||||
return
|
||||
}
|
||||
d.logger.Warn("guest opencode auth sync skipped", append(vmLogAttrs(vm), "host_path", hostPath, "error", err.Error())...)
|
||||
}
|
||||
|
||||
func (d *Daemon) warnClaudeAuthSyncSkipped(vm model.VMRecord, hostPath string, err error) {
|
||||
if d.logger == nil || err == nil {
|
||||
return
|
||||
}
|
||||
d.logger.Warn("guest claude auth sync skipped", append(vmLogAttrs(vm), "host_path", hostPath, "error", err.Error())...)
|
||||
}
|
||||
|
||||
func (d *Daemon) warnPiAuthSyncSkipped(vm model.VMRecord, hostPath string, err error) {
|
||||
if d.logger == nil || err == nil {
|
||||
return
|
||||
}
|
||||
d.logger.Warn("guest pi auth sync skipped", append(vmLogAttrs(vm), "host_path", hostPath, "error", err.Error())...)
|
||||
d.logger.Warn("file_sync skipped", append(vmLogAttrs(vm), "host_path", hostPath, "error", err.Error())...)
|
||||
}
|
||||
|
||||
func (d *Daemon) warnGitIdentitySyncSkipped(vm model.VMRecord, source string, err error) {
|
||||
|
|
|
|||
|
|
@ -923,300 +923,210 @@ func TestEnsureGitIdentityOnWorkDiskWarnsAndSkipsWhenHostIdentityIncomplete(t *t
|
|||
}
|
||||
}
|
||||
|
||||
func TestEnsureOpencodeAuthOnWorkDiskCopiesHostAuth(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
hostAuthPath := filepath.Join(homeDir, workDiskOpencodeAuthRelativePath)
|
||||
if err := os.MkdirAll(filepath.Dir(hostAuthPath), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(host auth dir): %v", err)
|
||||
}
|
||||
hostAuth := []byte("{\"provider\":\"openai\"}\n")
|
||||
if err := os.WriteFile(hostAuthPath, hostAuth, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(host auth): %v", err)
|
||||
}
|
||||
|
||||
workDiskDir := t.TempDir()
|
||||
func TestRunFileSyncNoOpWhenConfigEmpty(t *testing.T) {
|
||||
d := &Daemon{runner: &filesystemRunner{t: t}}
|
||||
vm := testVM("auth-sync", "image-auth-sync", "172.16.0.63")
|
||||
vm.Runtime.WorkDiskPath = workDiskDir
|
||||
|
||||
if err := d.ensureOpencodeAuthOnWorkDisk(context.Background(), &vm); err != nil {
|
||||
t.Fatalf("ensureOpencodeAuthOnWorkDisk: %v", err)
|
||||
}
|
||||
|
||||
guestAuthPath := filepath.Join(workDiskDir, workDiskOpencodeAuthRelativePath)
|
||||
got, err := os.ReadFile(guestAuthPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(guest auth): %v", err)
|
||||
}
|
||||
if string(got) != string(hostAuth) {
|
||||
t.Fatalf("guest auth = %q, want %q", string(got), string(hostAuth))
|
||||
}
|
||||
info, err := os.Stat(guestAuthPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Stat(guest auth): %v", err)
|
||||
}
|
||||
if gotMode := info.Mode().Perm(); gotMode != 0o600 {
|
||||
t.Fatalf("guest auth mode = %o, want 600", gotMode)
|
||||
vm := testVM("no-sync", "image", "172.16.0.70")
|
||||
if err := d.runFileSync(context.Background(), &vm); err != nil {
|
||||
t.Fatalf("runFileSync: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureOpencodeAuthOnWorkDiskReplacesExistingGuestAuth(t *testing.T) {
|
||||
func TestRunFileSyncCopiesFile(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
hostAuthPath := filepath.Join(homeDir, workDiskOpencodeAuthRelativePath)
|
||||
if err := os.MkdirAll(filepath.Dir(hostAuthPath), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(host auth dir): %v", err)
|
||||
srcPath := filepath.Join(homeDir, ".secrets", "token")
|
||||
if err := os.MkdirAll(filepath.Dir(srcPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hostAuth := []byte("{\"token\":\"fresh\"}\n")
|
||||
if err := os.WriteFile(hostAuthPath, hostAuth, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(host auth): %v", err)
|
||||
}
|
||||
|
||||
workDiskDir := t.TempDir()
|
||||
guestAuthPath := filepath.Join(workDiskDir, workDiskOpencodeAuthRelativePath)
|
||||
if err := os.MkdirAll(filepath.Dir(guestAuthPath), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(guest auth dir): %v", err)
|
||||
}
|
||||
if err := os.WriteFile(guestAuthPath, []byte("{\"token\":\"stale\"}\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(guest auth): %v", err)
|
||||
}
|
||||
|
||||
d := &Daemon{runner: &filesystemRunner{t: t}}
|
||||
vm := testVM("auth-replace", "image-auth-replace", "172.16.0.64")
|
||||
vm.Runtime.WorkDiskPath = workDiskDir
|
||||
|
||||
if err := d.ensureOpencodeAuthOnWorkDisk(context.Background(), &vm); err != nil {
|
||||
t.Fatalf("ensureOpencodeAuthOnWorkDisk: %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(guestAuthPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(guest auth): %v", err)
|
||||
}
|
||||
if string(got) != string(hostAuth) {
|
||||
t.Fatalf("guest auth = %q, want %q", string(got), string(hostAuth))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureOpencodeAuthOnWorkDiskWarnsAndSkipsWhenHostAuthMissing(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
|
||||
workDiskDir := t.TempDir()
|
||||
guestAuthPath := filepath.Join(workDiskDir, workDiskOpencodeAuthRelativePath)
|
||||
if err := os.MkdirAll(filepath.Dir(guestAuthPath), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(guest auth dir): %v", err)
|
||||
}
|
||||
original := []byte("{\"token\":\"keep\"}\n")
|
||||
if err := os.WriteFile(guestAuthPath, original, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(guest auth): %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
logger, _, err := newDaemonLogger(&buf, "info")
|
||||
if err != nil {
|
||||
t.Fatalf("newDaemonLogger: %v", err)
|
||||
srcData := []byte(`{"token":"abc"}`)
|
||||
if err := os.WriteFile(srcPath, srcData, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
workDisk := t.TempDir()
|
||||
d := &Daemon{
|
||||
runner: &filesystemRunner{t: t},
|
||||
logger: logger,
|
||||
config: model.DaemonConfig{
|
||||
FileSync: []model.FileSyncEntry{
|
||||
{Host: "~/.secrets/token", Guest: "~/.secrets/token"},
|
||||
},
|
||||
},
|
||||
}
|
||||
vm := testVM("auth-missing", "image-auth-missing", "172.16.0.65")
|
||||
vm.Runtime.WorkDiskPath = workDiskDir
|
||||
|
||||
if err := d.ensureOpencodeAuthOnWorkDisk(context.Background(), &vm); err != nil {
|
||||
t.Fatalf("ensureOpencodeAuthOnWorkDisk: %v", err)
|
||||
vm := testVM("sync-file", "image", "172.16.0.71")
|
||||
vm.Runtime.WorkDiskPath = workDisk
|
||||
if err := d.runFileSync(context.Background(), &vm); err != nil {
|
||||
t.Fatalf("runFileSync: %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(guestAuthPath)
|
||||
dst := filepath.Join(workDisk, ".secrets", "token")
|
||||
got, err := os.ReadFile(dst)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(guest auth): %v", err)
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != string(original) {
|
||||
t.Fatalf("guest auth = %q, want preserved %q", string(got), string(original))
|
||||
if string(got) != string(srcData) {
|
||||
t.Fatalf("dst = %q, want %q", got, srcData)
|
||||
}
|
||||
|
||||
entries := parseLogEntries(t, buf.Bytes())
|
||||
if !hasLogEntry(entries, map[string]string{
|
||||
"msg": "guest opencode auth sync skipped",
|
||||
"vm_name": vm.Name,
|
||||
"host_path": filepath.Join(homeDir, workDiskOpencodeAuthRelativePath),
|
||||
}) {
|
||||
t.Fatalf("expected warn log, got %v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureOpencodeAuthOnWorkDiskWarnsAndSkipsWhenHostAuthUnreadable(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
hostAuthPath := filepath.Join(homeDir, workDiskOpencodeAuthRelativePath)
|
||||
if err := os.MkdirAll(hostAuthPath, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(host auth path as dir): %v", err)
|
||||
}
|
||||
|
||||
workDiskDir := t.TempDir()
|
||||
guestAuthPath := filepath.Join(workDiskDir, workDiskOpencodeAuthRelativePath)
|
||||
if err := os.MkdirAll(filepath.Dir(guestAuthPath), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(guest auth dir): %v", err)
|
||||
}
|
||||
original := []byte("{\"token\":\"keep\"}\n")
|
||||
if err := os.WriteFile(guestAuthPath, original, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(guest auth): %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
logger, _, err := newDaemonLogger(&buf, "info")
|
||||
info, err := os.Stat(dst)
|
||||
if err != nil {
|
||||
t.Fatalf("newDaemonLogger: %v", err)
|
||||
}
|
||||
|
||||
d := &Daemon{
|
||||
runner: &filesystemRunner{t: t},
|
||||
logger: logger,
|
||||
}
|
||||
vm := testVM("auth-unreadable", "image-auth-unreadable", "172.16.0.66")
|
||||
vm.Runtime.WorkDiskPath = workDiskDir
|
||||
|
||||
if err := d.ensureOpencodeAuthOnWorkDisk(context.Background(), &vm); err != nil {
|
||||
t.Fatalf("ensureOpencodeAuthOnWorkDisk: %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(guestAuthPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(guest auth): %v", err)
|
||||
}
|
||||
if string(got) != string(original) {
|
||||
t.Fatalf("guest auth = %q, want preserved %q", string(got), string(original))
|
||||
}
|
||||
|
||||
entries := parseLogEntries(t, buf.Bytes())
|
||||
if !hasLogEntry(entries, map[string]string{
|
||||
"msg": "guest opencode auth sync skipped",
|
||||
"vm_name": vm.Name,
|
||||
"host_path": hostAuthPath,
|
||||
"error": "is a directory",
|
||||
}) {
|
||||
t.Fatalf("expected warn log, got %v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureClaudeAuthOnWorkDiskCopiesHostAuth(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
hostAuthPath := filepath.Join(homeDir, workDiskClaudeAuthRelativePath)
|
||||
if err := os.MkdirAll(filepath.Dir(hostAuthPath), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(host auth dir): %v", err)
|
||||
}
|
||||
hostAuth := []byte("{\"token\":\"claude\"}\n")
|
||||
if err := os.WriteFile(hostAuthPath, hostAuth, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(host auth): %v", err)
|
||||
}
|
||||
|
||||
workDiskDir := t.TempDir()
|
||||
d := &Daemon{runner: &filesystemRunner{t: t}}
|
||||
vm := testVM("claude-auth", "image-claude-auth", "172.16.0.67")
|
||||
vm.Runtime.WorkDiskPath = workDiskDir
|
||||
|
||||
if err := d.ensureClaudeAuthOnWorkDisk(context.Background(), &vm); err != nil {
|
||||
t.Fatalf("ensureClaudeAuthOnWorkDisk: %v", err)
|
||||
}
|
||||
|
||||
guestAuthPath := filepath.Join(workDiskDir, workDiskClaudeAuthRelativePath)
|
||||
got, err := os.ReadFile(guestAuthPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(guest auth): %v", err)
|
||||
}
|
||||
if string(got) != string(hostAuth) {
|
||||
t.Fatalf("guest auth = %q, want %q", string(got), string(hostAuth))
|
||||
}
|
||||
info, err := os.Stat(guestAuthPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Stat(guest auth): %v", err)
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("guest auth mode = %v, want 0600", info.Mode().Perm())
|
||||
t.Fatalf("mode = %v, want 0600", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsurePiAuthOnWorkDiskCopiesHostAuth(t *testing.T) {
|
||||
func TestRunFileSyncRespectsCustomMode(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
hostAuthPath := filepath.Join(homeDir, workDiskPiAuthRelativePath)
|
||||
if err := os.MkdirAll(filepath.Dir(hostAuthPath), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(host auth dir): %v", err)
|
||||
}
|
||||
hostAuth := []byte("{\"token\":\"pi\"}\n")
|
||||
if err := os.WriteFile(hostAuthPath, hostAuth, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(host auth): %v", err)
|
||||
srcPath := filepath.Join(homeDir, "script")
|
||||
if err := os.WriteFile(srcPath, []byte("#!/bin/sh\nexit 0\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
workDiskDir := t.TempDir()
|
||||
d := &Daemon{runner: &filesystemRunner{t: t}}
|
||||
vm := testVM("pi-auth", "image-pi-auth", "172.16.0.68")
|
||||
vm.Runtime.WorkDiskPath = workDiskDir
|
||||
|
||||
if err := d.ensurePiAuthOnWorkDisk(context.Background(), &vm); err != nil {
|
||||
t.Fatalf("ensurePiAuthOnWorkDisk: %v", err)
|
||||
workDisk := t.TempDir()
|
||||
d := &Daemon{
|
||||
runner: &filesystemRunner{t: t},
|
||||
config: model.DaemonConfig{
|
||||
FileSync: []model.FileSyncEntry{
|
||||
{Host: "~/script", Guest: "~/bin/my-script", Mode: "0755"},
|
||||
},
|
||||
},
|
||||
}
|
||||
vm := testVM("sync-mode", "image", "172.16.0.72")
|
||||
vm.Runtime.WorkDiskPath = workDisk
|
||||
if err := d.runFileSync(context.Background(), &vm); err != nil {
|
||||
t.Fatalf("runFileSync: %v", err)
|
||||
}
|
||||
|
||||
guestAuthPath := filepath.Join(workDiskDir, workDiskPiAuthRelativePath)
|
||||
got, err := os.ReadFile(guestAuthPath)
|
||||
info, err := os.Stat(filepath.Join(workDisk, "bin", "my-script"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(guest auth): %v", err)
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != string(hostAuth) {
|
||||
t.Fatalf("guest auth = %q, want %q", string(got), string(hostAuth))
|
||||
if info.Mode().Perm() != 0o755 {
|
||||
t.Fatalf("mode = %v, want 0755", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsurePiAuthOnWorkDiskWarnsAndSkipsWhenHostAuthMissing(t *testing.T) {
|
||||
func TestRunFileSyncSkipsMissingHostPath(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
|
||||
workDiskDir := t.TempDir()
|
||||
guestAuthPath := filepath.Join(workDiskDir, workDiskPiAuthRelativePath)
|
||||
if err := os.MkdirAll(filepath.Dir(guestAuthPath), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(guest auth dir): %v", err)
|
||||
}
|
||||
original := []byte("{\"token\":\"keep\"}\n")
|
||||
if err := os.WriteFile(guestAuthPath, original, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(guest auth): %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
logger, _, err := newDaemonLogger(&buf, "info")
|
||||
if err != nil {
|
||||
t.Fatalf("newDaemonLogger: %v", err)
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
workDisk := t.TempDir()
|
||||
d := &Daemon{
|
||||
runner: &filesystemRunner{t: t},
|
||||
logger: logger,
|
||||
config: model.DaemonConfig{
|
||||
FileSync: []model.FileSyncEntry{
|
||||
{Host: "~/does-not-exist", Guest: "~/wherever"},
|
||||
},
|
||||
},
|
||||
}
|
||||
vm := testVM("pi-auth-missing", "image-pi-auth-missing", "172.16.0.69")
|
||||
vm.Runtime.WorkDiskPath = workDiskDir
|
||||
|
||||
if err := d.ensurePiAuthOnWorkDisk(context.Background(), &vm); err != nil {
|
||||
t.Fatalf("ensurePiAuthOnWorkDisk: %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(guestAuthPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(guest auth): %v", err)
|
||||
}
|
||||
if string(got) != string(original) {
|
||||
t.Fatalf("guest auth = %q, want preserved %q", string(got), string(original))
|
||||
vm := testVM("sync-missing", "image", "172.16.0.73")
|
||||
vm.Runtime.WorkDiskPath = workDisk
|
||||
if err := d.runFileSync(context.Background(), &vm); err != nil {
|
||||
t.Fatalf("runFileSync: %v", err)
|
||||
}
|
||||
|
||||
entries := parseLogEntries(t, buf.Bytes())
|
||||
if !hasLogEntry(entries, map[string]string{
|
||||
"msg": "guest pi auth sync skipped",
|
||||
"msg": "file_sync skipped",
|
||||
"vm_name": vm.Name,
|
||||
"host_path": filepath.Join(homeDir, workDiskPiAuthRelativePath),
|
||||
"host_path": filepath.Join(homeDir, "does-not-exist"),
|
||||
}) {
|
||||
t.Fatalf("expected warn log, got %v", entries)
|
||||
t.Fatalf("expected skipped log, got %v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunFileSyncOverwritesExistingGuestFile(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
srcPath := filepath.Join(homeDir, "token")
|
||||
if err := os.WriteFile(srcPath, []byte("fresh"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workDisk := t.TempDir()
|
||||
// Work disk is mounted at /root in the guest, so the guest path
|
||||
// "/root/token" maps to workDisk/token here.
|
||||
existing := filepath.Join(workDisk, "token")
|
||||
if err := os.WriteFile(existing, []byte("stale"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
d := &Daemon{
|
||||
runner: &filesystemRunner{t: t},
|
||||
config: model.DaemonConfig{
|
||||
FileSync: []model.FileSyncEntry{
|
||||
{Host: "~/token", Guest: "/root/token"},
|
||||
},
|
||||
},
|
||||
}
|
||||
vm := testVM("sync-overwrite", "image", "172.16.0.74")
|
||||
vm.Runtime.WorkDiskPath = workDisk
|
||||
if err := d.runFileSync(context.Background(), &vm); err != nil {
|
||||
t.Fatalf("runFileSync: %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(existing)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "fresh" {
|
||||
t.Fatalf("guest file = %q, want fresh", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunFileSyncCopiesDirectoryRecursively(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
srcDir := filepath.Join(homeDir, ".aws")
|
||||
if err := os.MkdirAll(srcDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(srcDir, "credentials"), []byte("access"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sub := filepath.Join(srcDir, "sso", "cache")
|
||||
if err := os.MkdirAll(sub, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sub, "token.json"), []byte("sso-token"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
workDisk := t.TempDir()
|
||||
d := &Daemon{
|
||||
runner: &filesystemRunner{t: t},
|
||||
config: model.DaemonConfig{
|
||||
FileSync: []model.FileSyncEntry{
|
||||
{Host: "~/.aws", Guest: "~/.aws"},
|
||||
},
|
||||
},
|
||||
}
|
||||
vm := testVM("sync-dir", "image", "172.16.0.75")
|
||||
vm.Runtime.WorkDiskPath = workDisk
|
||||
if err := d.runFileSync(context.Background(), &vm); err != nil {
|
||||
t.Fatalf("runFileSync: %v", err)
|
||||
}
|
||||
|
||||
creds, err := os.ReadFile(filepath.Join(workDisk, ".aws", "credentials"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(creds) != "access" {
|
||||
t.Fatalf("credentials = %q, want access", creds)
|
||||
}
|
||||
ssoToken, err := os.ReadFile(filepath.Join(workDisk, ".aws", "sso", "cache", "token.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(ssoToken) != "sso-token" {
|
||||
t.Fatalf("sso token = %q, want sso-token", ssoToken)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1931,26 +1841,61 @@ func (r *filesystemRunner) RunSudo(ctx context.Context, args ...string) ([]byte,
|
|||
}
|
||||
return os.ReadFile(args[1])
|
||||
case "install":
|
||||
if len(args) != 5 || args[1] != "-m" {
|
||||
return nil, fmt.Errorf("unexpected install args: %v", args)
|
||||
}
|
||||
mode, err := strconv.ParseUint(args[2], 8, 32)
|
||||
// Minimal install(1): expected forms are
|
||||
// install -m MODE SRC DST (5 args)
|
||||
// install -o 0 -g 0 -m MODE SRC DST (9 args, ignored owners)
|
||||
src, dst, mode, err := parseInstallArgs(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(args[3])
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(args[4]), 0o755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, os.WriteFile(args[4], data, os.FileMode(mode))
|
||||
return nil, os.WriteFile(dst, data, os.FileMode(mode))
|
||||
case "chown":
|
||||
// chown -R OWNER TARGET — owner is ignored under test; we
|
||||
// already run as the test user and os.Chown would require
|
||||
// CAP_CHOWN.
|
||||
if len(args) != 4 || args[1] != "-R" {
|
||||
return nil, fmt.Errorf("unexpected chown args: %v", args)
|
||||
}
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected sudo command: %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func parseInstallArgs(args []string) (string, string, os.FileMode, error) {
|
||||
switch len(args) {
|
||||
case 5:
|
||||
if args[1] != "-m" {
|
||||
return "", "", 0, fmt.Errorf("unexpected install args: %v", args)
|
||||
}
|
||||
mode, err := strconv.ParseUint(args[2], 8, 32)
|
||||
if err != nil {
|
||||
return "", "", 0, err
|
||||
}
|
||||
return args[3], args[4], os.FileMode(mode), nil
|
||||
case 9:
|
||||
if args[1] != "-o" || args[3] != "-g" || args[5] != "-m" {
|
||||
return "", "", 0, fmt.Errorf("unexpected install args: %v", args)
|
||||
}
|
||||
mode, err := strconv.ParseUint(args[6], 8, 32)
|
||||
if err != nil {
|
||||
return "", "", 0, err
|
||||
}
|
||||
return args[7], args[8], os.FileMode(mode), nil
|
||||
}
|
||||
return "", "", 0, fmt.Errorf("unexpected install args: %v", args)
|
||||
}
|
||||
|
||||
func copyIntoDir(sourcePath, targetDir string) error {
|
||||
targetDir = strings.TrimSuffix(targetDir, "/")
|
||||
info, err := os.Stat(sourcePath)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue