diff --git a/internal/cli/commands_vm.go b/internal/cli/commands_vm.go index 2238712..bfda996 100644 --- a/internal/cli/commands_vm.go +++ b/internal/cli/commands_vm.go @@ -39,6 +39,7 @@ Quick reference: banger vm run ./repo -- make test ship a repo, run a command, exit banger vm create --name dev persistent VM; pair with 'vm ssh' banger vm ssh open a shell in a running VM + banger vm exec -- make test run a command in the workspace with mise toolchain banger vm stop | vm restart graceful lifecycle banger vm kill force-kill if stop hangs banger vm delete stop + remove disks @@ -49,7 +50,7 @@ Quick reference: Example: strings.TrimSpace(` banger vm run -- uname -a banger vm run ./project -- npm test - banger vm create --name agent && banger vm ssh agent + banger vm create --name dev && banger vm workspace prepare dev . && banger vm exec dev -- make test `), RunE: helpNoArgs, } @@ -66,6 +67,7 @@ Quick reference: d.newVMPruneCommand(), d.newVMSetCommand(), d.newVMSSHCommand(), + d.newVMExecCommand(), d.newVMWorkspaceCommand(), d.newVMLogsCommand(), d.newVMStatsCommand(), diff --git a/internal/cli/printers.go b/internal/cli/printers.go index ad988a7..aaea21c 100644 --- a/internal/cli/printers.go +++ b/internal/cli/printers.go @@ -98,13 +98,13 @@ func printVMIDList(out anyWriter, vms []model.VMRecord) error { func printVMListTable(out anyWriter, vms []model.VMRecord, imageNames map[string]string) error { w := tabwriter.NewWriter(out, 0, 8, 2, ' ', 0) - if _, err := fmt.Fprintln(w, "ID\tNAME\tSTATE\tIMAGE\tIP\tVCPU\tMEM\tDISK\tCREATED"); err != nil { + if _, err := fmt.Fprintln(w, "ID\tNAME\tSTATE\tIMAGE\tIP\tVCPU\tMEM\tDISK\tWORKSPACE\tCREATED"); err != nil { return err } for _, vm := range vms { if _, err := fmt.Fprintf( w, - "%s\t%s\t%s\t%s\t%s\t%d\t%d MiB\t%s\t%s\n", + "%s\t%s\t%s\t%s\t%s\t%d\t%d MiB\t%s\t%s\t%s\n", shortID(vm.ID), vm.Name, vm.State, @@ -113,6 +113,7 @@ func printVMListTable(out anyWriter, vms []model.VMRecord, imageNames map[string vm.Spec.VCPUCount, vm.Spec.MemoryMiB, model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes), + dashIfEmpty(vm.Workspace.GuestPath), relativeTime(vm.CreatedAt), ); err != nil { return err diff --git a/internal/cli/vm_exec.go b/internal/cli/vm_exec.go new file mode 100644 index 0000000..cfd8453 --- /dev/null +++ b/internal/cli/vm_exec.go @@ -0,0 +1,184 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "os/exec" + "strings" + + "banger/internal/api" + "banger/internal/model" + "banger/internal/rpc" + + "github.com/spf13/cobra" +) + +func (d *deps) newVMExecCommand() *cobra.Command { + var guestPath string + var autoPrepare bool + cmd := &cobra.Command{ + Use: "exec -- [args...]", + Short: "Run a command in the VM workspace with the repo toolchain", + Long: strings.TrimSpace(` +Run a command inside a persistent VM, automatically cd-ing into the +prepared workspace and wrapping the command with 'mise exec' so all +mise-managed tools (Go, Node, Python, etc.) are on PATH. + +The workspace path comes from the last 'vm workspace prepare' or +'vm run ./repo' on this VM. If the host repo has advanced since then, +banger warns; pass --auto-prepare to re-sync the workspace first. + +Exit code of the guest command is propagated verbatim. +`), + Example: strings.TrimSpace(` + banger vm exec dev -- make test + banger vm exec dev -- go build ./... + banger vm exec dev --auto-prepare -- npm ci && npm test + banger vm exec dev --guest-path /root/other -- make lint +`), + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + // Split on -- : everything before is [vm-name], everything after is the command. + dash := cmd.ArgsLenAtDash() + var vmRef string + var command []string + switch { + case dash < 0: + // No -- separator: first arg is VM, rest is command. + if len(args) < 2 { + return errors.New("usage: banger vm exec -- [args...]") + } + vmRef = args[0] + command = args[1:] + case dash == 0 || len(args[dash:]) == 0: + return errors.New("usage: banger vm exec -- [args...]") + default: + vmRef = args[:dash][0] + command = args[dash:] + } + + layout, cfg, err := d.ensureDaemon(cmd.Context()) + if err != nil { + return err + } + if err := validateSSHPrereqs(cfg); err != nil { + return err + } + + // Fetch the full VM record — we need Workspace and GuestIP. + result, err := rpc.Call[api.VMShowResult](cmd.Context(), layout.SocketPath, "vm.show", api.VMRefParams{IDOrName: vmRef}) + if err != nil { + return err + } + vm := result.VM + if vm.State != model.VMStateRunning { + return fmt.Errorf("vm %q is not running (state: %s)", vm.Name, vm.State) + } + + // Resolve effective guest workspace path. + execGuestPath := strings.TrimSpace(guestPath) + if execGuestPath == "" { + execGuestPath = vm.Workspace.GuestPath + } + if execGuestPath == "" { + execGuestPath = "/root/repo" + } + + // Dirty-workspace check: compare stored HEAD with current host HEAD. + isDirty, currentHead, _ := d.vmExecDirtyCheck(cmd.Context(), vm.Workspace) + if isDirty { + storedShort := shortRef(vm.Workspace.HeadCommit) + currentShort := shortRef(currentHead) + preparedLabel := relativeTime(vm.Workspace.PreparedAt) + + if autoPrepare && vm.Workspace.SourcePath != "" { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), + "[vm exec] workspace stale (prepared %s from %s, host HEAD now %s) — re-preparing\n", + preparedLabel, storedShort, currentShort) + if err := validateVMRunPrereqs(cfg); err != nil { + return err + } + if _, err := d.vmWorkspacePrepare(cmd.Context(), layout.SocketPath, api.VMWorkspacePrepareParams{ + IDOrName: vmRef, + SourcePath: vm.Workspace.SourcePath, + GuestPath: execGuestPath, + Mode: string(model.WorkspacePrepareModeShallowOverlay), + }); err != nil { + return fmt.Errorf("auto-prepare workspace: %w", err) + } + } else { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), + "[vm exec] warning: workspace stale (prepared %s from %s, host HEAD now %s) — use --auto-prepare to re-sync\n", + preparedLabel, storedShort, currentShort) + } + } + + // Build and run the exec script. + script := buildVMExecScript(execGuestPath, command) + sshArgs, err := sshCommandArgs(cfg, vm.Runtime.GuestIP, []string{"bash", "-lc", script}) + if err != nil { + return fmt.Errorf("vm %q: build ssh args: %w", vm.Name, err) + } + if err := d.sshExec(cmd.Context(), cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), sshArgs); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return ExitCodeError{Code: exitErr.ExitCode()} + } + return err + } + return nil + }, + } + cmd.Flags().StringVar(&guestPath, "guest-path", "", "workspace directory in the guest (default: from last workspace prepare, or /root/repo)") + cmd.Flags().BoolVar(&autoPrepare, "auto-prepare", false, "re-sync the workspace from the host repo before running if it's stale") + _ = cmd.RegisterFlagCompletionFunc("guest-path", cobra.NoFileCompletions) + return cmd +} + +// buildVMExecScript returns the bash -lc argument that cd's into the +// workspace and runs the command through mise exec when mise is +// available, falling back to a plain exec if it's not. Each command +// argument is shell-quoted so spaces and special characters survive +// the bash re-parse inside the -lc string. +func buildVMExecScript(guestPath string, command []string) string { + parts := make([]string, len(command)) + for i, a := range command { + parts[i] = shellQuote(a) + } + quotedCmd := strings.Join(parts, " ") + return fmt.Sprintf( + "cd %s && if command -v mise >/dev/null 2>&1; then mise exec -- %s; else %s; fi", + shellQuote(guestPath), + quotedCmd, + quotedCmd, + ) +} + +// vmExecDirtyCheck compares the HEAD commit stored in the VM's +// workspace record against the current HEAD of the host repo. Returns +// (false, "", nil) when the check can't be performed (no workspace +// recorded, path gone, not a repo, git not installed) so callers +// treat unknown as "not dirty" rather than blocking the exec. +func (d *deps) vmExecDirtyCheck(ctx context.Context, ws model.VMWorkspace) (isDirty bool, currentHead string, err error) { + if ws.SourcePath == "" || ws.HeadCommit == "" { + return false, "", nil + } + out, err := d.hostCommandOutput(ctx, "git", "-C", ws.SourcePath, "rev-parse", "HEAD") + if err != nil { + // Source path gone, not a git repo, or git not installed — + // treat as unknown rather than blocking. + return false, "", nil + } + currentHead = strings.TrimSpace(string(out)) + return currentHead != ws.HeadCommit, currentHead, nil +} + +// shortRef returns the first 8 characters of a git ref / commit SHA +// for display. Returns the full string if it's already short. +func shortRef(ref string) string { + if len(ref) > 8 { + return ref[:8] + } + return ref +} diff --git a/internal/daemon/workspace.go b/internal/daemon/workspace.go index 7a78ef6..17a2fd1 100644 --- a/internal/daemon/workspace.go +++ b/internal/daemon/workspace.go @@ -205,7 +205,7 @@ func (s *WorkspaceService) miseTrustGuestRepo(ctx context.Context, client ws.Gue // edit doesn't accidentally drop the `command -v` guard. func miseTrustScript(guestPath string) string { return fmt.Sprintf( - "if command -v mise >/dev/null 2>&1; then mise trust --quiet --all %s 2>/dev/null || true; fi\n", + "if command -v mise >/dev/null 2>&1; then cd %s && mise trust --quiet --all 2>/dev/null || true; fi\n", ws.ShellQuote(guestPath), ) } @@ -247,6 +247,18 @@ func (s *WorkspaceService) prepareVMWorkspaceGuestIO(ctx context.Context, vm mod // 'banger vm run ./repo -- ' invocation. Best-effort: a // missing mise binary or a 'trust' that does nothing is fine. s.miseTrustGuestRepo(ctx, client, guestPath) + preparedAt := model.Now() + // Persist workspace state so `vm exec` and dirty-checking can + // resolve guest path + HEAD commit without re-stating them. Best + // effort: a store failure here doesn't roll back the prepare. + if err := s.store.SetVMWorkspace(ctx, vm.ID, model.VMWorkspace{ + GuestPath: guestPath, + SourcePath: spec.SourcePath, + HeadCommit: spec.HeadCommit, + PreparedAt: preparedAt, + }); err != nil && s.logger != nil { + s.logger.Warn("failed to persist workspace state", "vm_id", vm.ID, "error", err) + } return model.WorkspacePrepareResult{ VMID: vm.ID, SourcePath: spec.SourcePath, @@ -258,6 +270,6 @@ func (s *WorkspaceService) prepareVMWorkspaceGuestIO(ctx context.Context, vm mod CurrentBranch: spec.CurrentBranch, BranchName: spec.BranchName, BaseCommit: spec.BaseCommit, - PreparedAt: model.Now(), + PreparedAt: preparedAt, }, nil } diff --git a/internal/daemon/workspace_test.go b/internal/daemon/workspace_test.go index c5aae6d..2f19996 100644 --- a/internal/daemon/workspace_test.go +++ b/internal/daemon/workspace_test.go @@ -619,7 +619,7 @@ func TestMiseTrustScriptShape(t *testing.T) { got := miseTrustScript("/root/repo") for _, want := range []string{ "command -v mise", - "mise trust --quiet --all '/root/repo'", + "cd '/root/repo' && mise trust --quiet --all", "|| true", } { if !strings.Contains(got, want) { diff --git a/internal/model/types.go b/internal/model/types.go index d3a44fc..7be2ffb 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -144,7 +144,8 @@ type VMRecord struct { LastTouchedAt time.Time `json:"last_touched_at"` Spec VMSpec `json:"spec"` Runtime VMRuntime `json:"runtime"` - Stats VMStats `json:"stats"` + Stats VMStats `json:"stats"` + Workspace VMWorkspace `json:"workspace"` } type VMCreateRequest struct { @@ -166,6 +167,18 @@ type VMSetRequest struct { NATEnabled *bool } +// VMWorkspace records the last successful workspace.prepare result on +// a VM so callers can skip re-stating the source path on every exec +// and so banger can detect drift between the guest copy and the host +// repo. Stored as workspace_json in the vms table; zero value means +// no workspace has been prepared on this VM yet. +type VMWorkspace struct { + GuestPath string `json:"guest_path,omitempty"` + SourcePath string `json:"source_path,omitempty"` + HeadCommit string `json:"head_commit,omitempty"` + PreparedAt time.Time `json:"prepared_at,omitempty"` +} + type WorkspacePrepareMode string const ( diff --git a/internal/store/migrations.go b/internal/store/migrations.go index ea54187..1b23efb 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -25,6 +25,7 @@ type migration struct { var migrations = []migration{ {id: 1, name: "baseline", up: migrateBaseline}, {id: 2, name: "drop_images_docker", up: migrateDropImagesDocker}, + {id: 3, name: "add_vm_workspace", up: migrateAddVMWorkspace}, } // runMigrations ensures schema_migrations exists, then applies every @@ -152,3 +153,14 @@ func migrateDropImagesDocker(tx *sql.Tx) error { _, err := tx.Exec(`ALTER TABLE images DROP COLUMN docker;`) return err } + +// migrateAddVMWorkspace adds the workspace_json column that records +// the last workspace.prepare result (guest path, host source path, +// HEAD commit, and timestamp) per VM. Default '{}' means no workspace +// has been prepared yet. The column is managed exclusively via +// Store.SetVMWorkspace; lifecycle UpsertVM calls never touch it so +// workspace state survives VM stop/start cycles. +func migrateAddVMWorkspace(tx *sql.Tx) error { + _, err := tx.Exec(`ALTER TABLE vms ADD COLUMN workspace_json TEXT NOT NULL DEFAULT '{}'`) + return err +} diff --git a/internal/store/store.go b/internal/store/store.go index 9cd00e0..e3c7502 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -236,7 +236,7 @@ func (s *Store) UpsertVM(ctx context.Context, vm model.VMRecord) error { func (s *Store) GetVM(ctx context.Context, idOrName string) (model.VMRecord, error) { const query = ` SELECT id, name, image_id, guest_ip, state, created_at, updated_at, last_touched_at, - spec_json, runtime_json, stats_json + spec_json, runtime_json, stats_json, workspace_json FROM vms WHERE id = ? OR name = ? ` @@ -247,7 +247,7 @@ func (s *Store) GetVM(ctx context.Context, idOrName string) (model.VMRecord, err func (s *Store) GetVMByID(ctx context.Context, id string) (model.VMRecord, error) { row := s.db.QueryRowContext(ctx, ` SELECT id, name, image_id, guest_ip, state, created_at, updated_at, last_touched_at, - spec_json, runtime_json, stats_json + spec_json, runtime_json, stats_json, workspace_json FROM vms WHERE id = ?`, id) return scanVMRow(row) } @@ -261,7 +261,7 @@ func (s *Store) GetVMByID(ctx context.Context, id string) (model.VMRecord, error func (s *Store) GetVMByName(ctx context.Context, name string) (model.VMRecord, error) { row := s.db.QueryRowContext(ctx, ` SELECT id, name, image_id, guest_ip, state, created_at, updated_at, last_touched_at, - spec_json, runtime_json, stats_json + spec_json, runtime_json, stats_json, workspace_json FROM vms WHERE name = ?`, name) return scanVMRow(row) } @@ -269,7 +269,7 @@ func (s *Store) GetVMByName(ctx context.Context, name string) (model.VMRecord, e func (s *Store) ListVMs(ctx context.Context) ([]model.VMRecord, error) { rows, err := s.db.QueryContext(ctx, ` SELECT id, name, image_id, guest_ip, state, created_at, updated_at, last_touched_at, - spec_json, runtime_json, stats_json + spec_json, runtime_json, stats_json, workspace_json FROM vms ORDER BY created_at ASC`) if err != nil { return nil, err @@ -293,10 +293,27 @@ func (s *Store) DeleteVM(ctx context.Context, id string) error { return err } +// SetVMWorkspace persists the workspace state from a workspace.prepare +// result onto the VM row. Called after a successful prepare so the +// guest path, host source path, and HEAD commit survive daemon +// restarts and are available to `vm exec` without re-stating them. +// Best-effort from the caller's perspective — a failure here does not +// roll back the prepare itself. +func (s *Store) SetVMWorkspace(ctx context.Context, vmID string, workspace model.VMWorkspace) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() + data, err := json.Marshal(workspace) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, "UPDATE vms SET workspace_json = ? WHERE id = ?", string(data), vmID) + return err +} + func (s *Store) FindVMsUsingImage(ctx context.Context, imageID string) ([]model.VMRecord, error) { rows, err := s.db.QueryContext(ctx, ` SELECT id, name, image_id, guest_ip, state, created_at, updated_at, last_touched_at, - spec_json, runtime_json, stats_json + spec_json, runtime_json, stats_json, workspace_json FROM vms WHERE image_id = ?`, imageID) if err != nil { return nil, err @@ -400,7 +417,7 @@ func scanVMRows(rows scanner) (model.VMRecord, error) { func scanVMInto(row scanner) (model.VMRecord, error) { var vm model.VMRecord - var state, createdAt, updatedAt, touchedAt, specJSON, runtimeJSON, statsJSON string + var state, createdAt, updatedAt, touchedAt, specJSON, runtimeJSON, statsJSON, workspaceJSON string err := row.Scan( &vm.ID, &vm.Name, @@ -413,6 +430,7 @@ func scanVMInto(row scanner) (model.VMRecord, error) { &specJSON, &runtimeJSON, &statsJSON, + &workspaceJSON, ) if err != nil { return vm, err @@ -429,6 +447,11 @@ func scanVMInto(row scanner) (model.VMRecord, error) { return vm, err } } + if workspaceJSON != "" && workspaceJSON != "{}" { + if err := json.Unmarshal([]byte(workspaceJSON), &vm.Workspace); err != nil { + return vm, err + } + } var parseErr error vm.CreatedAt, parseErr = time.Parse(time.RFC3339, createdAt) if parseErr != nil {