diff --git a/README.md b/README.md index 869f814..eea367d 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,8 @@ Provisioned images include: - `opencode` - a default guest `opencode` service on `0.0.0.0:4096` +If host `~/.local/share/opencode/auth.json` exists, `banger` syncs it into the guest at `/root/.local/share/opencode/auth.json` on VM start. Changes on the host take effect after the VM is restarted. + From the host: ```bash diff --git a/internal/daemon/capabilities.go b/internal/daemon/capabilities.go index d2ec524..78031e3 100644 --- a/internal/daemon/capabilities.go +++ b/internal/daemon/capabilities.go @@ -204,7 +204,10 @@ func (workDiskCapability) PrepareHost(ctx context.Context, d *Daemon, vm *model. if err != nil { return err } - return d.ensureAuthorizedKeyOnWorkDisk(ctx, vm, image, prep) + if err := d.ensureAuthorizedKeyOnWorkDisk(ctx, vm, image, prep); err != nil { + return err + } + return d.ensureOpencodeAuthOnWorkDisk(ctx, vm) } func (workDiskCapability) AddDoctorChecks(_ context.Context, d *Daemon, report *system.Report) { diff --git a/internal/daemon/vm.go b/internal/daemon/vm.go index 9ed25cd..afb34ad 100644 --- a/internal/daemon/vm.go +++ b/internal/daemon/vm.go @@ -31,6 +31,12 @@ var ( vsockReadyPoll = 200 * time.Millisecond ) +const ( + workDiskOpencodeAuthDirRelativePath = ".local/share/opencode" + workDiskOpencodeAuthRelativePath = workDiskOpencodeAuthDirRelativePath + "/auth.json" + hostOpencodeAuthDefaultDisplayPath = "~/" + workDiskOpencodeAuthRelativePath +) + func (d *Daemon) CreateVM(ctx context.Context, params api.VMCreateParams) (vm model.VMRecord, err error) { d.mu.Lock() defer d.mu.Unlock() @@ -927,6 +933,70 @@ func (d *Daemon) ensureAuthorizedKeyOnWorkDisk(ctx context.Context, vm *model.VM return nil } +func (d *Daemon) ensureOpencodeAuthOnWorkDisk(ctx context.Context, vm *model.VMRecord) error { + hostAuthPath, err := resolveHostOpencodeAuthPath() + if err != nil { + d.warnOpencodeAuthSyncSkipped(*vm, hostOpencodeAuthDefaultDisplayPath, err) + return nil + } + authData, err := os.ReadFile(hostAuthPath) + if err != nil { + d.warnOpencodeAuthSyncSkipped(*vm, hostAuthPath, err) + return nil + } + + vmCreateStage(ctx, "prepare_work_disk", "syncing opencode auth") + workMount, cleanupWork, err := system.MountTempDir(ctx, d.runner, vm.Runtime.WorkDiskPath, false) + if err != nil { + return err + } + defer cleanupWork() + + if err := d.flattenNestedWorkHome(ctx, workMount); err != nil { + return err + } + + authDir := filepath.Join(workMount, workDiskOpencodeAuthDirRelativePath) + if _, err := d.runner.RunSudo(ctx, "mkdir", "-p", authDir); err != nil { + return err + } + authPath := filepath.Join(workMount, workDiskOpencodeAuthRelativePath) + + tmpFile, err := os.CreateTemp("", "banger-opencode-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 = d.runner.RunSudo(ctx, "install", "-m", "600", tmpPath, authPath) + return err +} + +func resolveHostOpencodeAuthPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, workDiskOpencodeAuthRelativePath), nil +} + +func (d *Daemon) warnOpencodeAuthSyncSkipped(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 mergeAuthorizedKey(existing, managed []byte) []byte { managedLine := strings.TrimSpace(string(managed)) if managedLine == "" { diff --git a/internal/daemon/vm_test.go b/internal/daemon/vm_test.go index 0dfb223..a3ddc76 100644 --- a/internal/daemon/vm_test.go +++ b/internal/daemon/vm_test.go @@ -1,6 +1,7 @@ package daemon import ( + "bytes" "context" "crypto/rand" "crypto/rsa" @@ -807,6 +808,185 @@ func TestEnsureAuthorizedKeyOnWorkDiskRepairsNestedRootLayout(t *testing.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() + 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) + } +} + +func TestEnsureOpencodeAuthOnWorkDiskReplacesExistingGuestAuth(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("{\"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) + } + + d := &Daemon{ + runner: &filesystemRunner{t: t}, + logger: logger, + } + 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) + } + + 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": 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") + 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 TestCreateVMRejectsNonPositiveCPUAndMemory(t *testing.T) { d := &Daemon{} if _, err := d.CreateVM(context.Background(), api.VMCreateParams{VCPUCount: ptr(0)}); err == nil || !strings.Contains(err.Error(), "vcpu must be a positive integer") {