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:
parent
3aa64a63c1
commit
b33f24865c
3 changed files with 142 additions and 3 deletions
|
|
@ -43,6 +43,7 @@ One command, three modes:
|
||||||
banger vm run # bare sandbox — drops into ssh
|
banger vm run # bare sandbox — drops into ssh
|
||||||
banger vm run ./repo # workspace at /root/repo — 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 ./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.
|
- 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`.
|
propagates through `banger`.
|
||||||
|
|
||||||
Disconnecting from an interactive session leaves the VM running. Use
|
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.
|
`--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
|
## Image catalog
|
||||||
|
|
||||||
`banger image pull <name>` resolves `<name>` in the embedded catalog
|
`banger image pull <name>` resolves `<name>` in the embedded catalog
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,10 @@ var (
|
||||||
vmSSHFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMSSHResult, error) {
|
vmSSHFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMSSHResult, error) {
|
||||||
return rpc.Call[api.VMSSHResult](ctx, socketPath, "vm.ssh", api.VMRefParams{IDOrName: idOrName})
|
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) {
|
daemonPingFunc = func(ctx context.Context, socketPath string) (api.PingResult, error) {
|
||||||
return rpc.Call[api.PingResult](ctx, socketPath, "ping", api.Empty{})
|
return rpc.Call[api.PingResult](ctx, socketPath, "ping", api.Empty{})
|
||||||
}
|
}
|
||||||
|
|
@ -753,6 +757,7 @@ func newVMRunCommand() *cobra.Command {
|
||||||
natEnabled bool
|
natEnabled bool
|
||||||
branchName string
|
branchName string
|
||||||
fromRef = "HEAD"
|
fromRef = "HEAD"
|
||||||
|
removeOnExit bool
|
||||||
)
|
)
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "run [path] [-- command args...]",
|
Use: "run [path] [-- command args...]",
|
||||||
|
|
@ -829,7 +834,7 @@ Three modes:
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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")
|
cmd.Flags().StringVar(&name, "name", "", "vm name")
|
||||||
|
|
@ -841,6 +846,7 @@ Three modes:
|
||||||
cmd.Flags().BoolVar(&natEnabled, "nat", false, "enable NAT")
|
cmd.Flags().BoolVar(&natEnabled, "nat", false, "enable NAT")
|
||||||
cmd.Flags().StringVar(&branchName, "branch", "", "create and switch to a new guest branch")
|
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().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
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2794,7 +2800,7 @@ func (e ExitCodeError) Error() string {
|
||||||
return fmt.Sprintf("exit status %d", e.Code)
|
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)
|
progress := newVMRunProgressRenderer(stderr)
|
||||||
vm, err := runVMCreate(ctx, socketPath, stderr, params)
|
vm, err := runVMCreate(ctx, socketPath, stderr, params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -2804,6 +2810,25 @@ func runVMRun(ctx context.Context, socketPath string, cfg model.DaemonConfig, st
|
||||||
if vmRef == "" {
|
if vmRef == "" {
|
||||||
vmRef = shortID(vm.ID)
|
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")
|
sshAddress := net.JoinHostPort(vm.Runtime.GuestIP, "22")
|
||||||
progress.render("waiting for guest ssh")
|
progress.render("waiting for guest ssh")
|
||||||
sshCtx, cancelSSH := context.WithTimeout(ctx, vmRunSSHTimeout)
|
sshCtx, cancelSSH := context.WithTimeout(ctx, vmRunSSHTimeout)
|
||||||
|
|
@ -2825,6 +2850,7 @@ func runVMRun(ctx context.Context, socketPath string, cfg model.DaemonConfig, st
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
cancelSSH()
|
cancelSSH()
|
||||||
|
shouldRemove = removeOnExit
|
||||||
if spec != nil {
|
if spec != nil {
|
||||||
progress.render("preparing guest workspace")
|
progress.render("preparing guest workspace")
|
||||||
if _, err := vmWorkspacePrepareFunc(ctx, socketPath, api.VMWorkspacePrepareParams{
|
if _, err := vmWorkspacePrepareFunc(ctx, socketPath, api.VMWorkspacePrepareParams{
|
||||||
|
|
|
||||||
|
|
@ -1358,6 +1358,7 @@ func TestRunVMRunWorkspacePreparesAndAttaches(t *testing.T) {
|
||||||
api.VMCreateParams{Name: "devbox"},
|
api.VMCreateParams{Name: "devbox"},
|
||||||
&spec,
|
&spec,
|
||||||
nil,
|
nil,
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("runVMRun: %v", err)
|
t.Fatalf("runVMRun: %v", err)
|
||||||
|
|
@ -1450,6 +1451,7 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
|
||||||
api.VMCreateParams{Name: "devbox"},
|
api.VMCreateParams{Name: "devbox"},
|
||||||
&spec,
|
&spec,
|
||||||
nil,
|
nil,
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("runVMRun: %v", err)
|
t.Fatalf("runVMRun: %v", err)
|
||||||
|
|
@ -1541,6 +1543,7 @@ func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) {
|
||||||
api.VMCreateParams{Name: "devbox"},
|
api.VMCreateParams{Name: "devbox"},
|
||||||
&spec,
|
&spec,
|
||||||
nil,
|
nil,
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("runVMRun: %v", err)
|
t.Fatalf("runVMRun: %v", err)
|
||||||
|
|
@ -1604,6 +1607,7 @@ func TestRunVMRunBareModeSkipsWorkspaceAndTooling(t *testing.T) {
|
||||||
api.VMCreateParams{Name: "bare"},
|
api.VMCreateParams{Name: "bare"},
|
||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("runVMRun: %v", err)
|
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) {
|
func TestRunVMRunSSHTimeoutReturnsActionableError(t *testing.T) {
|
||||||
origBegin := vmCreateBeginFunc
|
origBegin := vmCreateBeginFunc
|
||||||
origWaitForSSH := guestWaitForSSHFunc
|
origWaitForSSH := guestWaitForSSHFunc
|
||||||
|
|
@ -1651,6 +1757,7 @@ func TestRunVMRunSSHTimeoutReturnsActionableError(t *testing.T) {
|
||||||
api.VMCreateParams{Name: "slowvm"},
|
api.VMCreateParams{Name: "slowvm"},
|
||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("want timeout error")
|
t.Fatal("want timeout error")
|
||||||
|
|
@ -1708,6 +1815,7 @@ func TestRunVMRunCommandModePropagatesExitCode(t *testing.T) {
|
||||||
api.VMCreateParams{Name: "cmdbox"},
|
api.VMCreateParams{Name: "cmdbox"},
|
||||||
nil,
|
nil,
|
||||||
[]string{"false"},
|
[]string{"false"},
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
var exitErr ExitCodeError
|
var exitErr ExitCodeError
|
||||||
if !errors.As(err, &exitErr) || exitErr.Code != 7 {
|
if !errors.As(err, &exitErr) || exitErr.Code != 7 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue