Introduces three interconnected features for persistent VM workflows: 1. `banger vm exec <vm> -- <cmd>`: runs a command in the prepared workspace, automatically cd-ing into the guest path and wrapping via `mise exec --` so mise-managed tools are on PATH. Falls back to a plain exec when mise isn't available. Exit code propagates verbatim. 2. Workspace persistence: workspace.prepare now stores the guest path, host source path, and HEAD commit into a new `workspace_json` column on the vms table (migration 3). This state survives daemon restarts and informs both dirty-checking and auto-prepare. 3. Dirty detection: `vm exec` compares the stored HEAD commit against the current host repo HEAD. When stale it warns and, with --auto-prepare, re-syncs the workspace before running. Also: - WORKSPACE column added to `banger ps` / `vm list` - `banger vm` quick reference updated with `vm exec` entry
184 lines
6.4 KiB
Go
184 lines
6.4 KiB
Go
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 <id-or-name> -- <command> [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 <id-or-name> -- <command> [args...]")
|
|
}
|
|
vmRef = args[0]
|
|
command = args[1:]
|
|
case dash == 0 || len(args[dash:]) == 0:
|
|
return errors.New("usage: banger vm exec <id-or-name> -- <command> [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
|
|
}
|