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) <noreply@anthropic.com>
This commit is contained in:
Thales Maciel 2026-04-17 14:00:45 -03:00
parent 8f4be112c2
commit feb679a301
No known key found for this signature in database
GPG key ID: 33112E6833C34679
4 changed files with 376 additions and 225 deletions

View file

@ -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-<repo>.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 <vm-name>
opencode attach http://<vm-name>.vm:4096 --dir /root/repo
./build/bin/banger vm acp <vm-name>
./build/bin/banger vm ssh <vm-name> -- "cd /root/repo && claude"
./build/bin/banger vm ssh <vm-name> -- "cd /root/repo && pi"
```
For ACP-aware host tools, `./build/bin/banger vm acp <vm-name>` 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:

View file

@ -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)
}

View file

@ -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,14 +521,26 @@ 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...]")
}
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()
if err != nil {
@ -529,9 +550,15 @@ func newVMRunCommand() *cobra.Command {
if 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 {
return err
@ -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,6 +2567,12 @@ func runVMRun(ctx context.Context, socketPath string, cfg model.DaemonConfig, st
if vmRef == "" {
vmRef = shortID(vm.ID)
}
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)
}
if spec != nil {
progress.render("preparing guest workspace")
if _, err := vmWorkspacePrepareFunc(ctx, socketPath, api.VMWorkspacePrepareParams{
IDOrName: vmRef,
@ -2523,23 +2584,34 @@ func runVMRun(ctx context.Context, socketPath string, cfg model.DaemonConfig, st
}); 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)
}
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)
}
defer client.Close()
if err := startVMRunToolingHarness(ctx, client, spec, progress); err != nil {
if err := startVMRunToolingHarness(ctx, client, *spec, progress); err != nil {
printVMRunWarning(stderr, fmt.Sprintf("guest tooling bootstrap start failed: %v", err))
}
if progress != nil {
progress.render("printing next steps")
_ = client.Close()
}
return printVMRunNextSteps(stdout, vm)
}
sshArgs, err := sshCommandArgs(cfg, vm.Runtime.GuestIP, command)
if err != nil {
return fmt.Errorf("vm %q is running but ssh args could not be built: %w", vmRef, 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
}
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 == "" {

View file

@ -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 </dev/null &`) {
t.Fatalf("launchScript = %q, want nohup launcher", fakeClient.launchScript)
}
if !strings.Contains(fakeClient.launchScript, vmRunToolingHarnessLogPath("repo")) {
t.Fatalf("launchScript = %q, want tooling harness log path", fakeClient.launchScript)
}
output := stdout.String()
for _, want := range []string{
"VM ready.",
"Name: devbox",
"Host: devbox.vm",
"Repo: /root/repo",
"banger vm ssh devbox",
"opencode attach http://devbox.vm:4096 --dir /root/repo",
"banger vm acp devbox",
`banger vm ssh devbox -- "cd /root/repo && claude"`,
`banger vm ssh devbox -- "cd /root/repo && pi"`,
} {
if !strings.Contains(output, want) {
t.Fatalf("stdout = %q, want %q", output, want)
}
t.Fatalf("uploads = %d, want tooling harness upload", len(fakeClient.uploads))
}
if !fakeClient.closed {
t.Fatal("guest client should be closed")
t.Fatal("guest client should be closed after tooling bootstrap")
}
if len(sshArgsSeen) == 0 || sshArgsSeen[len(sshArgsSeen)-1] != "root@172.16.0.2" {
t.Fatalf("sshArgsSeen = %v, want interactive ssh to 172.16.0.2 (no trailing command)", sshArgsSeen)
}
if got := stdout.String(); strings.Contains(got, "VM ready.") {
t.Fatalf("stdout = %q, want no next-steps block", got)
}
}
@ -1483,16 +1403,18 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
origCancel := vmCreateCancelFunc
origWaitForSSH := guestWaitForSSHFunc
origGuestDial := guestDialFunc
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
origVMWorkspacePrepare := vmWorkspacePrepareFunc
origSSHExec := sshExecFunc
origHealth := vmHealthFunc
t.Cleanup(func() {
vmCreateBeginFunc = origBegin
vmCreateStatusFunc = origStatus
vmCreateCancelFunc = origCancel
guestWaitForSSHFunc = origWaitForSSH
guestDialFunc = origGuestDial
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
vmWorkspacePrepareFunc = origVMWorkspacePrepare
sshExecFunc = origSSHExec
vmHealthFunc = origHealth
})
vm := model.VMRecord{
@ -1506,12 +1428,8 @@ func TestVMRunPrintsPostCreateProgress(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
}
@ -1529,24 +1447,27 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
guestDialFunc = func(ctx context.Context, address, privateKeyPath string) (vmRunGuestClient, error) {
return &testVMRunGuestClient{}, nil
}
prepareVMRunRepoCopyFunc = func(ctx context.Context, spec vmRunRepoSpec) (string, func(), error) {
return t.TempDir(), func() {}, nil
}
vmWorkspacePrepareFunc = func(ctx context.Context, socketPath string, params api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error) {
return api.VMWorkspacePrepareResult{Workspace: model.WorkspacePrepareResult{VMID: vm.ID, GuestPath: "/root/repo"}}, nil
}
sshExecFunc = func(context.Context, io.Reader, io.Writer, io.Writer, []string) error {
return nil
}
vmHealthFunc = func(context.Context, string, string) (api.VMHealthResult, error) {
return api.VMHealthResult{Name: "devbox", Healthy: false}, nil
}
var stdout bytes.Buffer
var stderr bytes.Buffer
spec := vmRunRepoSpec{RepoRoot: t.TempDir(), RepoName: "repo", HeadCommit: "deadbeef"}
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"},
vmRunRepoSpec{RepoRoot: t.TempDir(), RepoName: "repo", HeadCommit: "deadbeef"},
&spec,
nil,
)
if err != nil {
t.Fatalf("runVMRun: %v", err)
@ -1554,18 +1475,18 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
output := stderr.String()
for _, want := range []string{
"[vm run] preparing guest workspace",
"[vm run] waiting for guest ssh",
"[vm run] preparing guest workspace",
"[vm run] starting guest tooling bootstrap",
"[vm run] guest tooling log: /root/.cache/banger/vm-run-tooling-repo.log",
"[vm run] printing next steps",
"[vm run] attaching to guest",
} {
if !strings.Contains(output, want) {
t.Fatalf("stderr = %q, want %q", output, want)
}
}
if strings.Contains(output, "[vm run] attaching opencode") {
t.Fatalf("stderr = %q, want no auto-attach progress", output)
if strings.Contains(output, "[vm run] printing next steps") {
t.Fatalf("stderr = %q, should not print next-steps progress", output)
}
}
@ -1575,16 +1496,18 @@ func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) {
origCancel := vmCreateCancelFunc
origWaitForSSH := guestWaitForSSHFunc
origGuestDial := guestDialFunc
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
origVMWorkspacePrepare := vmWorkspacePrepareFunc
origSSHExec := sshExecFunc
origHealth := vmHealthFunc
t.Cleanup(func() {
vmCreateBeginFunc = origBegin
vmCreateStatusFunc = origStatus
vmCreateCancelFunc = origCancel
guestWaitForSSHFunc = origWaitForSSH
guestDialFunc = origGuestDial
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
vmWorkspacePrepareFunc = origVMWorkspacePrepare
sshExecFunc = origSSHExec
vmHealthFunc = origHealth
})
vm := model.VMRecord{
@ -1613,24 +1536,29 @@ func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) {
guestDialFunc = func(ctx context.Context, address, privateKeyPath string) (vmRunGuestClient, error) {
return fakeClient, nil
}
prepareVMRunRepoCopyFunc = func(ctx context.Context, spec vmRunRepoSpec) (string, func(), error) {
return t.TempDir(), func() {}, nil
}
vmWorkspacePrepareFunc = func(ctx context.Context, socketPath string, params api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error) {
return api.VMWorkspacePrepareResult{Workspace: model.WorkspacePrepareResult{VMID: vm.ID, GuestPath: "/root/repo"}}, nil
}
sshExecCalls := 0
sshExecFunc = func(context.Context, io.Reader, io.Writer, io.Writer, []string) error {
sshExecCalls++
return nil
}
vmHealthFunc = func(context.Context, string, string) (api.VMHealthResult, error) {
return api.VMHealthResult{Healthy: false}, nil
}
var stdout bytes.Buffer
var stderr bytes.Buffer
spec := vmRunRepoSpec{RepoRoot: t.TempDir(), RepoName: "repo", HeadCommit: "deadbeef"}
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"},
vmRunRepoSpec{RepoRoot: t.TempDir(), RepoName: "repo", HeadCommit: "deadbeef"},
&spec,
nil,
)
if err != nil {
t.Fatalf("runVMRun: %v", err)
@ -1638,8 +1566,187 @@ func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) {
if !strings.Contains(stderr.String(), "[vm run] warning: guest tooling bootstrap start failed: launch guest tooling bootstrap") {
t.Fatalf("stderr = %q, want tooling bootstrap warning", stderr.String())
}
if !strings.Contains(stdout.String(), "VM ready.") {
t.Fatalf("stdout = %q, want next steps summary", stdout.String())
if sshExecCalls != 1 {
t.Fatalf("sshExec calls = %d, want 1 (interactive attach still runs)", sshExecCalls)
}
}
func TestRunVMRunBareModeSkipsWorkspaceAndTooling(t *testing.T) {
origBegin := vmCreateBeginFunc
origWaitForSSH := guestWaitForSSHFunc
origGuestDial := guestDialFunc
origVMWorkspacePrepare := vmWorkspacePrepareFunc
origSSHExec := sshExecFunc
origHealth := vmHealthFunc
t.Cleanup(func() {
vmCreateBeginFunc = origBegin
guestWaitForSSHFunc = origWaitForSSH
guestDialFunc = origGuestDial
vmWorkspacePrepareFunc = origVMWorkspacePrepare
sshExecFunc = origSSHExec
vmHealthFunc = origHealth
})
vm := model.VMRecord{
ID: "vm-id", Name: "bare",
Runtime: model.VMRuntime{State: model.VMStateRunning, GuestIP: "172.16.0.2"},
}
vmCreateBeginFunc = func(context.Context, string, api.VMCreateParams) (api.VMCreateBeginResult, error) {
return api.VMCreateBeginResult{Operation: api.VMCreateOperation{ID: "op-1", Stage: "ready", Done: true, Success: true, VM: &vm}}, nil
}
guestWaitForSSHFunc = func(context.Context, string, string, time.Duration) error { return nil }
guestDialFunc = func(context.Context, string, string) (vmRunGuestClient, error) {
t.Fatal("guestDialFunc should not be called in bare mode")
return nil, nil
}
vmWorkspacePrepareFunc = func(context.Context, string, api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error) {
t.Fatal("vmWorkspacePrepareFunc should not be called in bare mode")
return api.VMWorkspacePrepareResult{}, nil
}
sshExecCalls := 0
sshExecFunc = func(context.Context, io.Reader, io.Writer, io.Writer, []string) error {
sshExecCalls++
return nil
}
vmHealthFunc = func(context.Context, string, string) (api.VMHealthResult, error) {
return api.VMHealthResult{Healthy: false}, nil
}
var stdout, stderr bytes.Buffer
err := runVMRun(
context.Background(),
"/tmp/bangerd.sock",
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
strings.NewReader(""),
&stdout, &stderr,
api.VMCreateParams{Name: "bare"},
nil,
nil,
)
if err != nil {
t.Fatalf("runVMRun: %v", err)
}
if sshExecCalls != 1 {
t.Fatalf("sshExec calls = %d, want 1", sshExecCalls)
}
if !strings.Contains(stderr.String(), "[vm run] attaching to guest") {
t.Fatalf("stderr = %q, want attach progress", stderr.String())
}
}
func TestRunVMRunCommandModePropagatesExitCode(t *testing.T) {
origBegin := vmCreateBeginFunc
origWaitForSSH := guestWaitForSSHFunc
origVMWorkspacePrepare := vmWorkspacePrepareFunc
origSSHExec := sshExecFunc
t.Cleanup(func() {
vmCreateBeginFunc = origBegin
guestWaitForSSHFunc = origWaitForSSH
vmWorkspacePrepareFunc = origVMWorkspacePrepare
sshExecFunc = origSSHExec
})
vm := model.VMRecord{
ID: "vm-id", Name: "cmdbox",
Runtime: model.VMRuntime{State: model.VMStateRunning, GuestIP: "172.16.0.2"},
}
vmCreateBeginFunc = func(context.Context, string, api.VMCreateParams) (api.VMCreateBeginResult, error) {
return api.VMCreateBeginResult{Operation: api.VMCreateOperation{ID: "op-1", Stage: "ready", Done: true, Success: true, VM: &vm}}, nil
}
guestWaitForSSHFunc = func(context.Context, string, string, time.Duration) error { return nil }
vmWorkspacePrepareFunc = func(context.Context, string, api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error) {
t.Fatal("workspace prepare should not run without spec")
return api.VMWorkspacePrepareResult{}, nil
}
var sshArgsSeen []string
sshExecFunc = func(_ context.Context, _ io.Reader, _, _ io.Writer, args []string) error {
sshArgsSeen = args
return exitErrorWithCode(t, 7)
}
var stdout, stderr bytes.Buffer
err := runVMRun(
context.Background(),
"/tmp/bangerd.sock",
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
strings.NewReader(""),
&stdout, &stderr,
api.VMCreateParams{Name: "cmdbox"},
nil,
[]string{"false"},
)
var exitErr exitCodeError
if !errors.As(err, &exitErr) || exitErr.Code != 7 {
t.Fatalf("runVMRun error = %v, want exitCodeError{7}", err)
}
if len(sshArgsSeen) == 0 || sshArgsSeen[len(sshArgsSeen)-1] != "false" {
t.Fatalf("sshArgsSeen = %v, want trailing command 'false'", sshArgsSeen)
}
if !strings.Contains(stderr.String(), "[vm run] running command in guest") {
t.Fatalf("stderr = %q, want command progress", stderr.String())
}
}
func TestVMRunCommandRejectsBranchWithoutPath(t *testing.T) {
cmd := NewBangerCommand()
cmd.SetArgs([]string{"vm", "run", "--branch", "feat"})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "--branch requires a path") {
t.Fatalf("Execute() error = %v, want --branch requires a path", err)
}
}
func TestSplitVMRunArgsPartitionsOnDash(t *testing.T) {
cases := []struct {
name string
argv []string
wantPath []string
wantCmd []string
}{
{"empty", []string{}, []string{}, nil},
{"path only", []string{"./repo"}, []string{"./repo"}, nil},
{"cmd only", []string{"--", "make", "test"}, []string{}, []string{"make", "test"}},
{"path and cmd", []string{"./repo", "--", "ls"}, []string{"./repo"}, []string{"ls"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// Parse through cobra so ArgsLenAtDash is populated.
var seenPath, seenCmd []string
root := &cobra.Command{Use: "root"}
run := &cobra.Command{
Use: "run",
Args: cobra.ArbitraryArgs,
RunE: func(cmd *cobra.Command, args []string) error {
seenPath, seenCmd = splitVMRunArgs(cmd, args)
return nil
},
}
root.AddCommand(run)
root.SetArgs(append([]string{"run"}, tc.argv...))
root.SetOut(&bytes.Buffer{})
root.SetErr(&bytes.Buffer{})
if err := root.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
if len(seenPath) != len(tc.wantPath) {
t.Fatalf("path = %v, want %v", seenPath, tc.wantPath)
}
for i := range seenPath {
if seenPath[i] != tc.wantPath[i] {
t.Fatalf("path = %v, want %v", seenPath, tc.wantPath)
}
}
if len(seenCmd) != len(tc.wantCmd) {
t.Fatalf("cmd = %v, want %v", seenCmd, tc.wantCmd)
}
for i := range seenCmd {
if seenCmd[i] != tc.wantCmd[i] {
t.Fatalf("cmd = %v, want %v", seenCmd, tc.wantCmd)
}
}
})
}
}