diff --git a/README.md b/README.md index 2f25623..21c880d 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ One command, three modes: banger vm run # bare sandbox — drops into ssh banger vm run ./repo # workspace at /root/repo — drops into ssh banger vm run ./repo -- make test # workspace + run command, exit with its status +banger vm run --rm -- script.sh # ephemeral: VM is deleted on exit ``` - Bare mode gives you a clean shell. @@ -54,10 +55,14 @@ banger vm run ./repo -- make test # workspace + run command, exit with its propagates through `banger`. Disconnecting from an interactive session leaves the VM running. Use -`vm stop` / `vm delete` to clean up. +`vm stop` / `vm delete` to clean up — or pass `--rm` so the VM +auto-deletes once the session / command exits. `--branch` and `--from` apply only to workspace mode. +`--rm` delete is skipped when the initial ssh wait times out, so a +wedged sshd leaves the VM alive for `banger vm logs` inspection. + ## Image catalog `banger image pull ` resolves `` in the embedded catalog diff --git a/internal/cli/banger.go b/internal/cli/banger.go index e5e2e5a..a55e934 100644 --- a/internal/cli/banger.go +++ b/internal/cli/banger.go @@ -76,6 +76,10 @@ var ( vmSSHFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMSSHResult, error) { return rpc.Call[api.VMSSHResult](ctx, socketPath, "vm.ssh", api.VMRefParams{IDOrName: idOrName}) } + vmDeleteFunc = func(ctx context.Context, socketPath, idOrName string) error { + _, err := rpc.Call[api.VMShowResult](ctx, socketPath, "vm.delete", api.VMRefParams{IDOrName: idOrName}) + return err + } daemonPingFunc = func(ctx context.Context, socketPath string) (api.PingResult, error) { return rpc.Call[api.PingResult](ctx, socketPath, "ping", api.Empty{}) } @@ -753,6 +757,7 @@ func newVMRunCommand() *cobra.Command { natEnabled bool branchName string fromRef = "HEAD" + removeOnExit bool ) cmd := &cobra.Command{ Use: "run [path] [-- command args...]", @@ -829,7 +834,7 @@ Three modes: if err != nil { return err } - return runVMRun(cmd.Context(), layout.SocketPath, cfg, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), params, specPtr, commandArgs) + return runVMRun(cmd.Context(), layout.SocketPath, cfg, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), params, specPtr, commandArgs, removeOnExit) }, } cmd.Flags().StringVar(&name, "name", "", "vm name") @@ -841,6 +846,7 @@ Three modes: cmd.Flags().BoolVar(&natEnabled, "nat", false, "enable NAT") cmd.Flags().StringVar(&branchName, "branch", "", "create and switch to a new guest branch") cmd.Flags().StringVar(&fromRef, "from", "HEAD", "base ref for --branch") + cmd.Flags().BoolVar(&removeOnExit, "rm", false, "delete the VM after the ssh session / command exits") return cmd } @@ -2794,7 +2800,7 @@ func (e ExitCodeError) Error() string { return fmt.Sprintf("exit status %d", 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 { +func runVMRun(ctx context.Context, socketPath string, cfg model.DaemonConfig, stdin io.Reader, stdout, stderr io.Writer, params api.VMCreateParams, spec *vmRunRepoSpec, command []string, removeOnExit bool) error { progress := newVMRunProgressRenderer(stderr) vm, err := runVMCreate(ctx, socketPath, stderr, params) if err != nil { @@ -2804,6 +2810,25 @@ func runVMRun(ctx context.Context, socketPath string, cfg model.DaemonConfig, st if vmRef == "" { vmRef = shortID(vm.ID) } + // --rm cleanup is wired AFTER ssh is confirmed. An ssh-wait + // timeout leaves the VM alive for `vm logs` inspection (our + // error message tells the user that); the cleanup only fires + // once the session phase runs. + shouldRemove := false + if removeOnExit { + defer func() { + if !shouldRemove { + return + } + // Use a fresh context so Ctrl-C during the session + // doesn't abort the delete RPC. + cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := vmDeleteFunc(cleanupCtx, socketPath, vmRef); err != nil { + printVMRunWarning(stderr, fmt.Sprintf("--rm cleanup failed: %v (leaked vm %q; delete manually)", err, vmRef)) + } + }() + } sshAddress := net.JoinHostPort(vm.Runtime.GuestIP, "22") progress.render("waiting for guest ssh") sshCtx, cancelSSH := context.WithTimeout(ctx, vmRunSSHTimeout) @@ -2825,6 +2850,7 @@ func runVMRun(ctx context.Context, socketPath string, cfg model.DaemonConfig, st ) } cancelSSH() + shouldRemove = removeOnExit if spec != nil { progress.render("preparing guest workspace") if _, err := vmWorkspacePrepareFunc(ctx, socketPath, api.VMWorkspacePrepareParams{ diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index f2f08cc..3711320 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -1358,6 +1358,7 @@ func TestRunVMRunWorkspacePreparesAndAttaches(t *testing.T) { api.VMCreateParams{Name: "devbox"}, &spec, nil, + false, ) if err != nil { t.Fatalf("runVMRun: %v", err) @@ -1450,6 +1451,7 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) { api.VMCreateParams{Name: "devbox"}, &spec, nil, + false, ) if err != nil { t.Fatalf("runVMRun: %v", err) @@ -1541,6 +1543,7 @@ func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) { api.VMCreateParams{Name: "devbox"}, &spec, nil, + false, ) if err != nil { t.Fatalf("runVMRun: %v", err) @@ -1604,6 +1607,7 @@ func TestRunVMRunBareModeSkipsWorkspaceAndTooling(t *testing.T) { api.VMCreateParams{Name: "bare"}, nil, nil, + false, ) if err != nil { t.Fatalf("runVMRun: %v", err) @@ -1616,6 +1620,108 @@ func TestRunVMRunBareModeSkipsWorkspaceAndTooling(t *testing.T) { } } +func TestRunVMRunRMDeletesAfterSessionExits(t *testing.T) { + origBegin := vmCreateBeginFunc + origWaitForSSH := guestWaitForSSHFunc + origSSHExec := sshExecFunc + origHealth := vmHealthFunc + origDelete := vmDeleteFunc + t.Cleanup(func() { + vmCreateBeginFunc = origBegin + guestWaitForSSHFunc = origWaitForSSH + sshExecFunc = origSSHExec + vmHealthFunc = origHealth + vmDeleteFunc = origDelete + }) + + vm := model.VMRecord{ + ID: "vm-id", Name: "tmpbox", + 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 } + 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{Healthy: false}, nil + } + deletedRef := "" + vmDeleteFunc = func(_ context.Context, _, idOrName string) error { + deletedRef = idOrName + return 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: "tmpbox"}, + nil, + nil, + true, // --rm + ) + if err != nil { + t.Fatalf("runVMRun: %v", err) + } + if deletedRef != "tmpbox" { + t.Fatalf("deletedRef = %q, want tmpbox", deletedRef) + } +} + +func TestRunVMRunRMSkipsDeleteOnSSHWaitTimeout(t *testing.T) { + origBegin := vmCreateBeginFunc + origWaitForSSH := guestWaitForSSHFunc + origDelete := vmDeleteFunc + origTimeout := vmRunSSHTimeout + vmRunSSHTimeout = 50 * time.Millisecond + t.Cleanup(func() { + vmCreateBeginFunc = origBegin + guestWaitForSSHFunc = origWaitForSSH + vmDeleteFunc = origDelete + vmRunSSHTimeout = origTimeout + }) + + vm := model.VMRecord{ + ID: "vm-id", Name: "slowvm", + 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(ctx context.Context, _, _ string, _ time.Duration) error { + <-ctx.Done() + return ctx.Err() + } + deleteCalled := false + vmDeleteFunc = func(context.Context, string, string) error { + deleteCalled = true + return 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: "slowvm"}, + nil, + nil, + true, // --rm + ) + if err == nil { + t.Fatal("want timeout error") + } + if deleteCalled { + t.Fatal("VM should NOT be deleted on ssh-wait timeout even with --rm (keep for debugging)") + } +} + func TestRunVMRunSSHTimeoutReturnsActionableError(t *testing.T) { origBegin := vmCreateBeginFunc origWaitForSSH := guestWaitForSSHFunc @@ -1651,6 +1757,7 @@ func TestRunVMRunSSHTimeoutReturnsActionableError(t *testing.T) { api.VMCreateParams{Name: "slowvm"}, nil, nil, + false, ) if err == nil { t.Fatal("want timeout error") @@ -1708,6 +1815,7 @@ func TestRunVMRunCommandModePropagatesExitCode(t *testing.T) { api.VMCreateParams{Name: "cmdbox"}, nil, []string{"false"}, + false, ) var exitErr ExitCodeError if !errors.As(err, &exitErr) || exitErr.Code != 7 {