vm run --rm: ephemeral sandboxes

New `--rm` flag deletes the VM once the ssh session or `-- cmd`
exits, making `vm run` one-shot. Exit code from command mode still
propagates correctly.

Semantics:
- Create fails → no VM to delete, nothing to do.
- SSH-wait timeout → VM intentionally kept alive so `vm logs <name>`
  shows why; the timeout error already pointed users at that. Even
  with --rm, this path skips delete — a wedged sshd is exactly when
  you want post-mortem access.
- Session/command ends (any exit code, any reason) → VM is deleted
  via `vm.delete` RPC. Uses a fresh 10s context so Ctrl-C during the
  session doesn't abort the cleanup.

New vmDeleteFunc seam at the top of banger.go alongside the other
RPC seams. Two tests cover the happy path (session ends cleanly →
delete fires with correct ref) and the skip-on-timeout path (ssh
wait errors → delete does NOT fire).

README updated with an ephemeral example and a note about the
timeout-skip behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thales Maciel 2026-04-18 16:06:46 -03:00
parent 3aa64a63c1
commit b33f24865c
No known key found for this signature in database
GPG key ID: 33112E6833C34679
3 changed files with 142 additions and 3 deletions

View file

@ -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{