From feb679a30117e60a64a4030afadb25e41f9409d2 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Fri, 17 Apr 2026 14:00:45 -0300 Subject: [PATCH] vm run redesign: one command, three modes `vm run` now covers bare sandbox (no args), workspace sandbox (path), and workspace+command (path -- cmd) in a single entry point. Replaces the old print-next-steps-and-exit behaviour: bare and workspace modes drop into interactive ssh, command mode execs via ssh and propagates the remote exit code through banger's own exit status. - path argument is optional; --branch / --from still require a path. - workspace prep and mise tooling bootstrap only run when a path is given; command mode skips the bootstrap. - remote command exit status is wrapped as exitCodeError so main() can propagate it instead of collapsing every failure to 1. - README: promote vm run with three-mode examples; demote vm create to a scripting primitive. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 36 ++-- cmd/banger/main.go | 5 + internal/cli/banger.go | 167 +++++++++++------ internal/cli/cli_test.go | 393 +++++++++++++++++++++++++-------------- 4 files changed, 376 insertions(+), 225 deletions(-) diff --git a/README.md b/README.md index 619c828..7a72c06 100644 --- a/README.md +++ b/README.md @@ -132,35 +132,29 @@ Promote an unmanaged image into daemon-owned managed artifacts: ./build/bin/banger image promote base ``` -Create and use a VM: +Spin up a sandbox VM and drop straight into it: ```bash -./build/bin/banger vm create --image devbox --name testbox +./build/bin/banger vm run # bare sandbox, interactive ssh +./build/bin/banger vm run ../some-repo # workspace at /root/repo, interactive ssh +./build/bin/banger vm run ../some-repo -- make test # workspace, run command, exit with its status +``` + +`vm run` creates a VM, prepares a workspace if you pass a path, and then either drops you into an interactive ssh session or runs the `--`-delimited command to completion. The command's exit code propagates through `banger`. Disconnecting from the interactive session leaves the VM running; use `vm stop` / `vm delete` to clean up. + +When you pass a path, `vm run` copies a git checkout plus tracked and untracked non-ignored files into `/root/repo`, then kicks off a best-effort `mise` tooling bootstrap that runs asynchronously inside the guest (log at `/root/.cache/banger/vm-run-tooling-.log`). The bootstrap is skipped in bare and command modes. Flags like `--branch` and `--from` require a path. + +For scripting or lower-level control, `vm create` remains available as a primitive (use `--no-start` when you just want to provision): + +```bash +./build/bin/banger vm create --image devbox --name testbox --no-start +./build/bin/banger vm start testbox ./build/bin/banger vm ssh testbox ./build/bin/banger vm stop testbox ``` `vm create` stays synchronous by default, but on a TTY it now shows live progress until the VM is fully ready. -Start a repo-backed VM session: - -```bash -./build/bin/banger vm run -./build/bin/banger vm run ../some-repo --branch feature/alpine --from HEAD -``` - -`vm run` resolves the enclosing git repository, creates a VM, copies a git checkout plus current tracked and untracked non-ignored files into `/root/repo`, starts a best-effort guest tooling bootstrap that only uses `mise`, prints next-step commands, and exits. It does not auto-attach `opencode` anymore. The bootstrap runs asynchronously and logs its output inside the guest. - -After `vm run`, use one of: - -```bash -./build/bin/banger vm ssh -opencode attach http://.vm:4096 --dir /root/repo -./build/bin/banger vm acp -./build/bin/banger vm ssh -- "cd /root/repo && claude" -./build/bin/banger vm ssh -- "cd /root/repo && pi" -``` - For ACP-aware host tools, `./build/bin/banger vm acp ` bridges stdio to guest `opencode acp` over SSH. It uses `/root/repo` when that checkout exists, otherwise `/root`, and `--cwd` lets you override the guest working directory explicitly. If you want reusable orchestration primitives instead of the `vm run` convenience flow, use the daemon-backed workspace and session commands directly: diff --git a/cmd/banger/main.go b/cmd/banger/main.go index f7a616f..bee0caa 100644 --- a/cmd/banger/main.go +++ b/cmd/banger/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "fmt" "os" "os/signal" @@ -16,6 +17,10 @@ func main() { cmd := cli.NewBangerCommand() if err := cmd.ExecuteContext(ctx); err != nil { + var exitErr interface{ ExitCode() int } + if errors.As(err, &exitErr) { + os.Exit(exitErr.ExitCode()) + } fmt.Fprintf(os.Stderr, "banger: %v\n", err) os.Exit(1) } diff --git a/internal/cli/banger.go b/internal/cli/banger.go index a022108..53edbd6 100644 --- a/internal/cli/banger.go +++ b/internal/cli/banger.go @@ -496,13 +496,22 @@ func newVMRunCommand() *cobra.Command { fromRef = "HEAD" ) cmd := &cobra.Command{ - Use: "run [path]", - Short: "Create repo-backed VM and print next steps", - Long: "Create a VM for a local git repository, prepare /root/repo inside the guest, start best-effort mise tooling bootstrap, and print manual access commands.", - Args: maxArgsUsage(1, "usage: banger vm run [path]"), + Use: "run [path] [-- command args...]", + Short: "Create and enter a sandbox VM", + Long: strings.TrimSpace(` +Create a sandbox VM and either drop into an interactive shell or run a command. + +Three modes: + banger vm run bare sandbox, drops into ssh + banger vm run ./repo workspace sandbox, drops into ssh at /root/repo + banger vm run ./repo -- make test workspace, runs command, exits with its status +`), + Args: cobra.ArbitraryArgs, Example: strings.TrimSpace(` banger vm run banger vm run ../repo --name agent-box --branch feature/demo + banger vm run ../repo -- make test + banger vm run -- uname -a `), RunE: func(cmd *cobra.Command, args []string) error { if cmd.Flags().Changed("branch") && strings.TrimSpace(branchName) == "" { @@ -512,13 +521,25 @@ func newVMRunCommand() *cobra.Command { return errors.New("--from requires --branch") } - sourcePath := "" - if len(args) == 1 { - sourcePath = args[0] + pathArgs, commandArgs := splitVMRunArgs(cmd, args) + if len(pathArgs) > 1 { + return errors.New("usage: banger vm run [path] [-- command args...]") } - spec, err := inspectVMRunRepo(cmd.Context(), sourcePath, branchName, fromRef) - if err != nil { - return err + sourcePath := "" + if len(pathArgs) == 1 { + sourcePath = pathArgs[0] + } + if sourcePath == "" && strings.TrimSpace(branchName) != "" { + return errors.New("--branch requires a path argument") + } + + var specPtr *vmRunRepoSpec + if sourcePath != "" { + spec, err := inspectVMRunRepo(cmd.Context(), sourcePath, branchName, fromRef) + if err != nil { + return err + } + specPtr = &spec } layout, err := paths.Resolve() @@ -529,8 +550,14 @@ func newVMRunCommand() *cobra.Command { if err != nil { return err } - if err := validateVMRunPrereqs(cfg); err != nil { - return err + if specPtr != nil { + if err := validateVMRunPrereqs(cfg); err != nil { + return err + } + } else { + if err := validateSSHPrereqs(cfg); err != nil { + return err + } } params, err := vmCreateParamsFromFlags(cmd, name, imageName, vcpu, memory, systemOverlaySize, workDiskSize, natEnabled, false) if err != nil { @@ -543,7 +570,7 @@ func newVMRunCommand() *cobra.Command { if err != nil { return err } - return runVMRun(cmd.Context(), layout.SocketPath, cfg, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), params, spec) + return runVMRun(cmd.Context(), layout.SocketPath, cfg, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), params, specPtr, commandArgs) }, } cmd.Flags().StringVar(&name, "name", "", "vm name") @@ -2502,7 +2529,35 @@ func parseNullSeparatedOutput(output []byte) []string { return values } -func runVMRun(ctx context.Context, socketPath string, cfg model.DaemonConfig, stdin io.Reader, stdout, stderr io.Writer, params api.VMCreateParams, spec vmRunRepoSpec) error { +// splitVMRunArgs partitions cobra positional args into the optional path +// argument and the trailing command (everything after a `--` separator). +// The path slice may contain 0..1 entries; the command slice may be empty. +func splitVMRunArgs(cmd *cobra.Command, args []string) (pathArgs, commandArgs []string) { + dash := cmd.ArgsLenAtDash() + if dash < 0 { + return args, nil + } + if dash > len(args) { + dash = len(args) + } + return args[:dash], args[dash:] +} + +// exitCodeError wraps a remote command's exit status so the CLI's main() +// can propagate it verbatim. Setup errors and other failures stay as +// regular errors. +type exitCodeError struct { + Code int +} + +func (e exitCodeError) Error() string { + return fmt.Sprintf("exit status %d", e.Code) +} + +// ExitCode exposes the code for callers using errors.As. +func (e exitCodeError) ExitCode() int { return e.Code } + +func runVMRun(ctx context.Context, socketPath string, cfg model.DaemonConfig, stdin io.Reader, stdout, stderr io.Writer, params api.VMCreateParams, spec *vmRunRepoSpec, command []string) error { progress := newVMRunProgressRenderer(stderr) vm, err := runVMCreate(ctx, socketPath, stderr, params) if err != nil { @@ -2512,34 +2567,51 @@ func runVMRun(ctx context.Context, socketPath string, cfg model.DaemonConfig, st if vmRef == "" { vmRef = shortID(vm.ID) } - progress.render("preparing guest workspace") - if _, err := vmWorkspacePrepareFunc(ctx, socketPath, api.VMWorkspacePrepareParams{ - IDOrName: vmRef, - SourcePath: spec.SourcePath, - GuestPath: vmRunGuestDir(), - Branch: spec.BranchName, - From: spec.FromRef, - Mode: string(model.WorkspacePrepareModeShallowOverlay), - }); err != nil { - return fmt.Errorf("vm %q is running but workspace prepare failed: %w", vmRef, err) - } sshAddress := net.JoinHostPort(vm.Runtime.GuestIP, "22") progress.render("waiting for guest ssh") if err := guestWaitForSSHFunc(ctx, sshAddress, cfg.SSHKeyPath, 250*time.Millisecond); err != nil { return fmt.Errorf("vm %q is running but guest ssh is unavailable: %w", vmRef, err) } - client, err := guestDialFunc(ctx, sshAddress, cfg.SSHKeyPath) + if spec != nil { + progress.render("preparing guest workspace") + if _, err := vmWorkspacePrepareFunc(ctx, socketPath, api.VMWorkspacePrepareParams{ + IDOrName: vmRef, + SourcePath: spec.SourcePath, + GuestPath: vmRunGuestDir(), + Branch: spec.BranchName, + From: spec.FromRef, + Mode: string(model.WorkspacePrepareModeShallowOverlay), + }); err != nil { + return fmt.Errorf("vm %q is running but workspace prepare failed: %w", vmRef, err) + } + if len(command) == 0 { + client, err := guestDialFunc(ctx, sshAddress, cfg.SSHKeyPath) + if err != nil { + return fmt.Errorf("vm %q is running but guest ssh is unavailable: %w", vmRef, err) + } + if err := startVMRunToolingHarness(ctx, client, *spec, progress); err != nil { + printVMRunWarning(stderr, fmt.Sprintf("guest tooling bootstrap start failed: %v", err)) + } + _ = client.Close() + } + } + sshArgs, err := sshCommandArgs(cfg, vm.Runtime.GuestIP, command) if err != nil { - return fmt.Errorf("vm %q is running but guest ssh is unavailable: %w", vmRef, err) + return fmt.Errorf("vm %q is running but ssh args could not be built: %w", vmRef, err) } - defer client.Close() - if err := startVMRunToolingHarness(ctx, client, spec, progress); err != nil { - printVMRunWarning(stderr, fmt.Sprintf("guest tooling bootstrap start failed: %v", err)) + if len(command) > 0 { + progress.render("running command in guest") + if err := sshExecFunc(ctx, stdin, stdout, stderr, sshArgs); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return exitCodeError{Code: exitErr.ExitCode()} + } + return err + } + return nil } - if progress != nil { - progress.render("printing next steps") - } - return printVMRunNextSteps(stdout, vm) + progress.render("attaching to guest") + return runSSHSession(ctx, socketPath, vmRef, stdin, stdout, stderr, sshArgs) } func importVMRunRepoToGuest(ctx context.Context, client vmRunGuestClient, spec vmRunRepoSpec, progress *vmRunProgressRenderer) error { @@ -2774,33 +2846,6 @@ func vmRunToolingHarnessLaunchScript(spec vmRunRepoSpec) string { return script.String() } -func printVMRunNextSteps(out io.Writer, vm model.VMRecord) error { - if out == nil { - return nil - } - vmRef := strings.TrimSpace(vm.Name) - if vmRef == "" { - vmRef = shortID(vm.ID) - } - hostRef := strings.TrimSpace(vm.Runtime.DNSName) - if hostRef == "" { - hostRef = strings.TrimSpace(vm.Runtime.GuestIP) - } - guestDir := vmRunGuestDir() - _, err := fmt.Fprintf(out, `VM ready. -Name: %s -Host: %s -Repo: %s -Next: - banger vm ssh %s - opencode attach http://%s:4096 --dir %s - banger vm acp %s - banger vm ssh %s -- "cd %s && claude" - banger vm ssh %s -- "cd %s && pi" -`, vmRef, hostRef, guestDir, vmRef, hostRef, guestDir, vmRef, vmRef, guestDir, vmRef, guestDir) - return err -} - func formatVMRunStepError(action string, err error, log string) error { log = strings.TrimSpace(log) if log == "" { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 8a7329c..9ce8947 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -1285,27 +1285,28 @@ func TestInspectVMRunRepoRejectsSubmodules(t *testing.T) { } } -func TestRunVMRunCreatesImportsAndPrintsNextSteps(t *testing.T) { +func TestRunVMRunWorkspacePreparesAndAttaches(t *testing.T) { repoRoot := t.TempDir() - repoCopyDir := filepath.Join(t.TempDir(), "repo-copy") origBegin := vmCreateBeginFunc origStatus := vmCreateStatusFunc origCancel := vmCreateCancelFunc origWaitForSSH := guestWaitForSSHFunc origGuestDial := guestDialFunc - origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc origBuildVMRunToolingPlan := buildVMRunToolingPlanFunc origVMWorkspacePrepare := vmWorkspacePrepareFunc + origSSHExec := sshExecFunc + origHealth := vmHealthFunc t.Cleanup(func() { vmCreateBeginFunc = origBegin vmCreateStatusFunc = origStatus vmCreateCancelFunc = origCancel guestWaitForSSHFunc = origWaitForSSH guestDialFunc = origGuestDial - prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy buildVMRunToolingPlanFunc = origBuildVMRunToolingPlan vmWorkspacePrepareFunc = origVMWorkspacePrepare + sshExecFunc = origSSHExec + vmHealthFunc = origHealth }) vm := model.VMRecord{ @@ -1320,12 +1321,8 @@ func TestRunVMRunCreatesImportsAndPrintsNextSteps(t *testing.T) { vmCreateBeginFunc = func(context.Context, string, api.VMCreateParams) (api.VMCreateBeginResult, error) { return api.VMCreateBeginResult{ Operation: api.VMCreateOperation{ - ID: "op-1", - Stage: "ready", - Detail: "vm is ready", - Done: true, - Success: true, - VM: &vm, + ID: "op-1", Stage: "ready", Detail: "vm is ready", + Done: true, Success: true, VM: &vm, }, }, nil } @@ -1339,28 +1336,12 @@ func TestRunVMRunCreatesImportsAndPrintsNextSteps(t *testing.T) { } fakeClient := &testVMRunGuestClient{} - waitAddress := "" - waitKeyPath := "" - waitInterval := time.Duration(0) guestWaitForSSHFunc = func(ctx context.Context, address, privateKeyPath string, interval time.Duration) error { - waitAddress = address - waitKeyPath = privateKeyPath - waitInterval = interval return nil } - dialAddress := "" - dialKeyPath := "" guestDialFunc = func(ctx context.Context, address, privateKeyPath string) (vmRunGuestClient, error) { - dialAddress = address - dialKeyPath = privateKeyPath return fakeClient, nil } - prepareVMRunRepoCopyFunc = func(ctx context.Context, spec vmRunRepoSpec) (string, func(), error) { - if spec.RepoRoot != repoRoot { - t.Fatalf("spec.RepoRoot = %q, want %q", spec.RepoRoot, repoRoot) - } - return repoCopyDir, func() {}, nil - } var workspaceParams api.VMWorkspacePrepareParams vmWorkspacePrepareFunc = func(ctx context.Context, socketPath string, params api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error) { workspaceParams = params @@ -1370,110 +1351,49 @@ func TestRunVMRunCreatesImportsAndPrintsNextSteps(t *testing.T) { return toolingplan.Plan{ RepoManagedTools: []string{"go"}, Steps: []toolingplan.InstallStep{{Tool: "go", Version: "1.25.0", Source: "go.mod"}}, - Skips: []toolingplan.SkipNote{{Target: "python", Reason: "no .python-version"}}, } } + var sshArgsSeen []string + sshExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error { + sshArgsSeen = args + return nil + } + vmHealthFunc = func(context.Context, string, string) (api.VMHealthResult, error) { + return api.VMHealthResult{Name: "devbox", Healthy: false}, nil + } spec := vmRunRepoSpec{ - SourcePath: repoRoot, - RepoRoot: repoRoot, - RepoName: "repo", - HeadCommit: "deadbeef", - CurrentBranch: "main", - BranchName: "feature", - BaseCommit: "cafebabe", - GitUserName: "Repo User", - GitUserEmail: "repo@example.com", - OverlayPaths: []string{"tracked.txt", "nested/keep.txt"}, + SourcePath: repoRoot, RepoRoot: repoRoot, RepoName: "repo", + HeadCommit: "deadbeef", CurrentBranch: "main", } - var stdout bytes.Buffer - var stderr bytes.Buffer + var stdout, stderr bytes.Buffer err := runVMRun( context.Background(), "/tmp/bangerd.sock", model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"}, strings.NewReader(""), - &stdout, - &stderr, + &stdout, &stderr, api.VMCreateParams{Name: "devbox"}, - spec, + &spec, + nil, ) if err != nil { t.Fatalf("runVMRun: %v", err) } - - if waitAddress != "172.16.0.2:22" { - t.Fatalf("waitAddress = %q, want 172.16.0.2:22", waitAddress) - } - if waitKeyPath != "/tmp/id_ed25519" { - t.Fatalf("waitKeyPath = %q, want /tmp/id_ed25519", waitKeyPath) - } - if waitInterval <= 0 { - t.Fatalf("waitInterval = %s, want positive interval", waitInterval) - } - if dialAddress != waitAddress { - t.Fatalf("dialAddress = %q, want %q", dialAddress, waitAddress) - } - if dialKeyPath != waitKeyPath { - t.Fatalf("dialKeyPath = %q, want %q", dialKeyPath, waitKeyPath) - } - if workspaceParams.IDOrName != "devbox" { - t.Fatalf("workspaceParams.IDOrName = %q, want devbox", workspaceParams.IDOrName) - } - if workspaceParams.SourcePath != repoRoot { - t.Fatalf("workspaceParams.SourcePath = %q, want %q", workspaceParams.SourcePath, repoRoot) - } - if workspaceParams.GuestPath != "/root/repo" { - t.Fatalf("workspaceParams.GuestPath = %q, want /root/repo", workspaceParams.GuestPath) - } - if workspaceParams.Mode != string(model.WorkspacePrepareModeShallowOverlay) { - t.Fatalf("workspaceParams.Mode = %q", workspaceParams.Mode) + if workspaceParams.IDOrName != "devbox" || workspaceParams.SourcePath != repoRoot { + t.Fatalf("workspaceParams = %+v", workspaceParams) } if len(fakeClient.uploads) != 1 { - t.Fatalf("uploads = %d, want 1", len(fakeClient.uploads)) - } - if fakeClient.uploadPath != vmRunToolingHarnessPath("repo") { - t.Fatalf("uploadPath = %q, want %q", fakeClient.uploadPath, vmRunToolingHarnessPath("repo")) - } - if fakeClient.uploadMode != 0o755 { - t.Fatalf("uploadMode = %v, want 0755", fakeClient.uploadMode) - } - if !strings.Contains(string(fakeClient.uploadData), `repo-managed mise tools: go`) { - t.Fatalf("uploadData = %q, want repo-managed tool log", string(fakeClient.uploadData)) - } - if !strings.Contains(string(fakeClient.uploadData), `run_best_effort "$MISE_BIN" install`) { - t.Fatalf("uploadData = %q, want repo mise install step", string(fakeClient.uploadData)) - } - if !strings.Contains(string(fakeClient.uploadData), `run_bounded_best_effort "$INSTALL_TIMEOUT_SECS" "$MISE_BIN" use -g --pin 'go@1.25.0'`) { - t.Fatalf("uploadData = %q, want deterministic go install step", string(fakeClient.uploadData)) - } - if strings.Contains(string(fakeClient.uploadData), `opencode run`) { - t.Fatalf("uploadData = %q, want no opencode harness run", string(fakeClient.uploadData)) - } - if !strings.Contains(fakeClient.launchScript, `nohup bash "$HELPER" >"$LOG" 2>&1