banger/internal/cli/commands_vm.go
Thales Maciel aaf49fc1b1
vm run: add -d/--detach + transparent tooling bootstrap
The mise tooling bootstrap was failing silently when --nat wasn't
set: the VM came up, the user landed in ssh, and tools were missing
with no obvious cause. Two coupled fixes:

* `-d`/`--detach`: create + prep + bootstrap, exit without attaching
  to ssh. Reconnect later with `banger vm ssh <name>`. Rejects the
  ambiguous combos `-d --rm` and `-d -- <cmd>`.

* NAT precondition: when the workspace has a .mise.toml or
  .tool-versions, vm run now refuses before VM creation if --nat
  isn't set. Error message points at --nat or --no-bootstrap.

* `--no-bootstrap`: explicit opt-out for users who want a vanilla
  VM with their workspace and no tooling install.

Detached bootstrap runs synchronously (foreground tee'd to the log
file) so the CLI only returns once installs finish. Interactive
mode keeps today's nohup'd background behaviour so the ssh session
starts promptly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:51:16 -03:00

1117 lines
38 KiB
Go

package cli
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"strings"
"sync"
"text/tabwriter"
"banger/internal/api"
"banger/internal/config"
"banger/internal/daemon/workspace"
"banger/internal/model"
"banger/internal/paths"
"banger/internal/rpc"
"banger/internal/system"
"github.com/spf13/cobra"
)
func (d *deps) newVMCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "vm",
Short: "Manage Firecracker microVM sandboxes",
Long: strings.TrimSpace(`
Lifecycle commands for banger's microVMs.
For most cases you want 'banger vm run' — it creates, starts,
provisions ssh, and drops you into the guest in one command. Use
'vm create' / 'vm start' / 'vm ssh' separately when you want a
longer-lived VM you'll come back to.
Quick reference:
banger vm run ephemeral sandbox; --rm to delete on exit
banger vm run ./repo -- make test ship a repo, run a command, exit
banger vm create --name dev persistent VM; pair with 'vm ssh'
banger vm ssh <name> open a shell in a running VM
banger vm exec <name> -- make test run a command in the workspace with mise toolchain
banger vm stop <name> | vm restart graceful lifecycle
banger vm kill <name> force-kill if stop hangs
banger vm delete <name> stop + remove disks
banger ps / banger vm list running / all VMs (use --all)
banger vm logs <name> guest console + daemon log
banger vm workspace prepare/export ship a repo in, pull diffs back
`),
Example: strings.TrimSpace(`
banger vm run -- uname -a
banger vm run ./project -- npm test
banger vm create --name dev && banger vm workspace prepare dev . && banger vm exec dev -- make test
`),
RunE: helpNoArgs,
}
cmd.AddCommand(
d.newVMCreateCommand(),
d.newVMRunCommand(),
d.newVMListCommand(),
d.newVMShowCommand(),
d.newVMActionCommand("start", "Start a stopped VM", "vm.start"),
d.newVMActionCommand("stop", "Stop a running VM gracefully", "vm.stop"),
d.newVMKillCommand(),
d.newVMActionCommand("restart", "Stop then start a VM", "vm.restart"),
d.newVMDeleteCommand(),
d.newVMPruneCommand(),
d.newVMSetCommand(),
d.newVMSSHCommand(),
d.newVMExecCommand(),
d.newVMWorkspaceCommand(),
d.newVMLogsCommand(),
d.newVMStatsCommand(),
d.newVMPortsCommand(),
)
return cmd
}
func (d *deps) newVMRunCommand() *cobra.Command {
defaults := effectiveVMDefaults()
var (
name string
imageName string
vcpu = defaults.VCPUCount
memory = defaults.MemoryMiB
systemOverlaySize = model.FormatSizeBytes(defaults.SystemOverlaySizeByte)
workDiskSize = model.FormatSizeBytes(defaults.WorkDiskSizeBytes)
natEnabled bool
branchName string
fromRef = "HEAD"
removeOnExit bool
includeUntracked bool
dryRun bool
detach bool
skipBootstrap bool
)
cmd := &cobra.Command{
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.
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
banger vm run -d ./repo workspace + bootstrap, exit (no ssh attach)
Tooling bootstrap (workspace mode):
When the workspace contains a .mise.toml or .tool-versions, vm run
installs the listed tools via mise on first boot. The bootstrap
needs internet, so --nat must be set. Pass --no-bootstrap to skip
it entirely (no NAT requirement).
`),
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 -d ../repo --nat
banger vm run -- uname -a
`),
RunE: func(cmd *cobra.Command, args []string) error {
if cmd.Flags().Changed("branch") && strings.TrimSpace(branchName) == "" {
return errors.New("--branch requires a branch name")
}
if cmd.Flags().Changed("from") && strings.TrimSpace(branchName) == "" {
return errors.New("--from requires --branch")
}
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")
}
if detach && removeOnExit {
return errors.New("cannot combine --detach with --rm")
}
if detach && len(commandArgs) > 0 {
return errors.New("cannot combine --detach with a guest command")
}
var repoPtr *vmRunRepo
if sourcePath != "" {
resolved, err := d.vmRunPreflightRepo(cmd.Context(), sourcePath)
if err != nil {
return err
}
repoPtr = &vmRunRepo{sourcePath: resolved, branchName: branchName, fromRef: fromRef, includeUntracked: includeUntracked}
}
if dryRun {
if repoPtr == nil {
return errors.New("--dry-run requires a workspace path")
}
dryFromRef := ""
if strings.TrimSpace(repoPtr.branchName) != "" {
dryFromRef = repoPtr.fromRef
}
return d.runWorkspaceDryRun(cmd.Context(), cmd.OutOrStdout(), repoPtr.sourcePath, repoPtr.branchName, dryFromRef, repoPtr.includeUntracked)
}
layout, err := paths.Resolve()
if err != nil {
return err
}
cfg, err := config.Load(layout)
if err != nil {
return err
}
if repoPtr != 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
}
layout, cfg, err = d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
return d.runVMRun(cmd.Context(), layout.SocketPath, cfg, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), params, repoPtr, commandArgs, removeOnExit, detach, skipBootstrap)
},
}
cmd.Flags().StringVar(&name, "name", "", "vm name")
cmd.Flags().StringVar(&imageName, "image", "", "image name or id (defaults to config's default_image_name; auto-pulled from imagecat if missing)")
cmd.Flags().IntVar(&vcpu, "vcpu", defaults.VCPUCount, "vcpu count")
cmd.Flags().IntVar(&memory, "memory", defaults.MemoryMiB, "memory in MiB")
cmd.Flags().StringVar(&systemOverlaySize, "system-overlay-size", model.FormatSizeBytes(defaults.SystemOverlaySizeByte), "system overlay size")
cmd.Flags().StringVar(&workDiskSize, "disk-size", model.FormatSizeBytes(defaults.WorkDiskSizeBytes), "work disk size")
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", "git ref to branch from when --branch is set (default: HEAD)")
cmd.Flags().BoolVar(&removeOnExit, "rm", false, "delete the VM after the ssh session / command exits")
cmd.Flags().BoolVar(&includeUntracked, "include-untracked", false, "also copy untracked non-ignored files into the guest workspace (default: tracked files only)")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "list the files that would be copied into the guest workspace and exit without creating a VM")
cmd.Flags().BoolVarP(&detach, "detach", "d", false, "create the VM, prep workspace + bootstrap, exit without attaching to ssh")
cmd.Flags().BoolVar(&skipBootstrap, "no-bootstrap", false, "skip the mise tooling bootstrap (no --nat requirement)")
_ = cmd.RegisterFlagCompletionFunc("image", d.completeImageNames)
return cmd
}
func (d *deps) newVMKillCommand() *cobra.Command {
var signal string
cmd := &cobra.Command{
Use: "kill <id-or-name>...",
Short: "Force-kill a VM (use when 'vm stop' hangs)",
Long: strings.TrimSpace(`
Send a signal directly to the firecracker process. Default is
SIGTERM; pass --signal SIGKILL when the VM is stuck and a graceful
'vm stop' has already failed.
This skips the normal stop sequence (no flush, no clean shutdown).
Prefer 'banger vm stop' for routine teardown.
`),
Args: minArgsUsage(1, "usage: banger vm kill [--signal SIGTERM|SIGKILL|...] <id-or-name>..."),
ValidArgsFunction: d.completeVMNames,
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
if len(args) > 1 {
return runVMBatchAction(cmd, layout.SocketPath, args, func(ctx context.Context, id string) (model.VMRecord, error) {
result, err := rpc.Call[api.VMShowResult](
ctx,
layout.SocketPath,
"vm.kill",
api.VMKillParams{IDOrName: id, Signal: signal},
)
if err != nil {
return model.VMRecord{}, err
}
return result.VM, nil
})
}
result, err := rpc.Call[api.VMShowResult](
cmd.Context(),
layout.SocketPath,
"vm.kill",
api.VMKillParams{IDOrName: args[0], Signal: signal},
)
if err != nil {
return err
}
return printVMSummary(cmd.OutOrStdout(), result.VM)
},
}
cmd.Flags().StringVar(&signal, "signal", "TERM", "signal name to send")
return cmd
}
func (d *deps) newVMPruneCommand() *cobra.Command {
var force bool
cmd := &cobra.Command{
Use: "prune",
Short: "Delete every VM that isn't running",
Long: "Scan for VMs in state other than 'running' (stopped, created, error) and delete them after confirmation. Use -f to skip the prompt.",
Args: noArgsUsage("usage: banger vm prune [-f|--force]"),
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
return d.runVMPrune(cmd, layout.SocketPath, force)
},
}
cmd.Flags().BoolVarP(&force, "force", "f", false, "skip the confirmation prompt")
return cmd
}
func (d *deps) runVMPrune(cmd *cobra.Command, socketPath string, force bool) error {
ctx := cmd.Context()
stdout := cmd.OutOrStdout()
stderr := cmd.ErrOrStderr()
list, err := d.vmList(ctx, socketPath)
if err != nil {
return err
}
var victims []model.VMRecord
for _, vm := range list.VMs {
if vm.State != model.VMStateRunning {
victims = append(victims, vm)
}
}
if len(victims) == 0 {
_, err := fmt.Fprintln(stdout, "no non-running VMs to prune")
return err
}
fmt.Fprintf(stdout, "The following %d VM(s) will be deleted:\n", len(victims))
w := tabwriter.NewWriter(stdout, 0, 8, 2, ' ', 0)
fmt.Fprintln(w, " ID\tNAME\tSTATE")
for _, vm := range victims {
fmt.Fprintf(w, " %s\t%s\t%s\n", shortID(vm.ID), vm.Name, vm.State)
}
if err := w.Flush(); err != nil {
return err
}
if !force {
ok, err := promptYesNo(cmd.InOrStdin(), stdout, "Delete these VMs? [y/N] ")
if err != nil {
return err
}
if !ok {
_, err := fmt.Fprintln(stdout, "aborted")
return err
}
}
var failed int
for _, vm := range victims {
ref := vm.Name
if ref == "" {
ref = shortID(vm.ID)
}
if err := d.vmDelete(ctx, socketPath, vm.ID); err != nil {
fmt.Fprintf(stderr, "delete %s: %v\n", ref, err)
failed++
continue
}
if err := removeUserKnownHosts(vm); err != nil {
fmt.Fprintf(stderr, "known_hosts cleanup %s: %v\n", ref, err)
}
fmt.Fprintln(stdout, "deleted", ref)
}
if failed > 0 {
return fmt.Errorf("%d VM(s) failed to delete", failed)
}
return nil
}
// promptYesNo reads a line from in and returns true iff the trimmed
// lowercase answer is "y" or "yes". EOF is "no"; other read errors
// surface to the caller.
func promptYesNo(in io.Reader, out io.Writer, prompt string) (bool, error) {
if _, err := fmt.Fprint(out, prompt); err != nil {
return false, err
}
reader := bufio.NewReader(in)
line, err := reader.ReadString('\n')
if err != nil && err != io.EOF {
return false, err
}
answer := strings.ToLower(strings.TrimSpace(line))
return answer == "y" || answer == "yes", nil
}
func (d *deps) newVMCreateCommand() *cobra.Command {
defaults := effectiveVMDefaults()
var (
name string
imageName string
vcpu = defaults.VCPUCount
memory = defaults.MemoryMiB
systemOverlaySize = model.FormatSizeBytes(defaults.SystemOverlaySizeByte)
workDiskSize = model.FormatSizeBytes(defaults.WorkDiskSizeBytes)
natEnabled bool
noStart bool
)
cmd := &cobra.Command{
Use: "create",
Short: "Create a VM (without entering it)",
Long: strings.TrimSpace(`
Create a microVM in the 'running' state and return its summary.
Unlike 'banger vm run', this does NOT open an ssh session — pair it
with 'banger vm ssh <name>' when you want to attach.
Use 'vm create' for a longer-lived VM you'll come back to. Use
'vm run' for one-shot sandboxes (especially with --rm).
`),
Example: strings.TrimSpace(`
banger vm create --name agent
banger vm create --name big --vcpu 8 --memory 16384
banger vm create --no-start --name spare # provision but leave stopped
`),
Args: noArgsUsage("usage: banger vm create"),
RunE: func(cmd *cobra.Command, args []string) error {
params, err := vmCreateParamsFromFlags(cmd, name, imageName, vcpu, memory, systemOverlaySize, workDiskSize, natEnabled, noStart)
if err != nil {
return err
}
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
vm, err := d.runVMCreate(cmd.Context(), layout.SocketPath, cmd.ErrOrStderr(), params)
if err != nil {
return err
}
return printVMSummary(cmd.OutOrStdout(), vm)
},
}
cmd.Flags().StringVar(&name, "name", "", "vm name")
cmd.Flags().StringVar(&imageName, "image", "", "image name or id (defaults to config's default_image_name; auto-pulled from imagecat if missing)")
cmd.Flags().IntVar(&vcpu, "vcpu", defaults.VCPUCount, "vcpu count")
cmd.Flags().IntVar(&memory, "memory", defaults.MemoryMiB, "memory in MiB")
cmd.Flags().StringVar(&systemOverlaySize, "system-overlay-size", model.FormatSizeBytes(defaults.SystemOverlaySizeByte), "system overlay size")
cmd.Flags().StringVar(&workDiskSize, "disk-size", model.FormatSizeBytes(defaults.WorkDiskSizeBytes), "work disk size")
cmd.Flags().BoolVar(&natEnabled, "nat", false, "enable NAT")
cmd.Flags().BoolVar(&noStart, "no-start", false, "create without starting")
_ = cmd.RegisterFlagCompletionFunc("image", d.completeImageNames)
return cmd
}
type vmListOptions struct {
showAll bool
latest bool
quiet bool
}
func (d *deps) newPSCommand() *cobra.Command {
return d.newVMListLikeCommand("ps", nil, "usage: banger ps")
}
func (d *deps) newVMListCommand() *cobra.Command {
return d.newVMListLikeCommand("list", []string{"ls", "ps"}, "usage: banger vm list")
}
func (d *deps) newVMListLikeCommand(use string, aliases []string, usage string) *cobra.Command {
var opts vmListOptions
cmd := &cobra.Command{
Use: use,
Aliases: aliases,
Short: "List VMs",
Args: noArgsUsage(usage),
RunE: func(cmd *cobra.Command, args []string) error {
return d.runVMList(cmd, opts)
},
}
cmd.Flags().BoolVarP(&opts.showAll, "all", "a", false, "show all VMs")
cmd.Flags().BoolVarP(&opts.latest, "latest", "l", false, "show only the latest VM")
cmd.Flags().BoolVarP(&opts.quiet, "quiet", "q", false, "only show VM IDs")
return cmd
}
func (d *deps) runVMList(cmd *cobra.Command, opts vmListOptions) error {
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := rpc.Call[api.VMListResult](cmd.Context(), layout.SocketPath, "vm.list", api.Empty{})
if err != nil {
return err
}
vms := selectVMListVMs(result.VMs, opts.showAll, opts.latest)
if opts.quiet {
return printVMIDList(cmd.OutOrStdout(), vms)
}
images, err := rpc.Call[api.ImageListResult](cmd.Context(), layout.SocketPath, "image.list", api.Empty{})
if err != nil {
return err
}
return printVMListTable(cmd.OutOrStdout(), vms, imageNameIndex(images.Images))
}
func selectVMListVMs(vms []model.VMRecord, showAll, latest bool) []model.VMRecord {
filtered := make([]model.VMRecord, 0, len(vms))
for _, vm := range vms {
if !showAll && vm.State != model.VMStateRunning {
continue
}
filtered = append(filtered, vm)
}
if !latest || len(filtered) <= 1 {
return filtered
}
latestVM := filtered[0]
for _, vm := range filtered[1:] {
if vm.CreatedAt.After(latestVM.CreatedAt) {
latestVM = vm
continue
}
if vm.CreatedAt.Equal(latestVM.CreatedAt) && vm.UpdatedAt.After(latestVM.UpdatedAt) {
latestVM = vm
}
}
return []model.VMRecord{latestVM}
}
func (d *deps) newVMShowCommand() *cobra.Command {
return &cobra.Command{
Use: "show <id-or-name>",
Short: "Print full VM record as JSON",
Long: strings.TrimSpace(`
Emit the complete VM record (spec, runtime state, image reference,
created/updated timestamps) as a single JSON object. Suitable for
piping into 'jq' or feeding into automation.
For human-readable summaries use 'banger ps' or 'banger vm stats'.
`),
Args: exactArgsUsage(1, "usage: banger vm show <id-or-name>"),
ValidArgsFunction: d.completeVMNameOnlyAtPos0,
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := rpc.Call[api.VMShowResult](cmd.Context(), layout.SocketPath, "vm.show", api.VMRefParams{IDOrName: args[0]})
if err != nil {
return err
}
return printJSON(cmd.OutOrStdout(), result.VM)
},
}
}
func (d *deps) newVMActionCommand(use, short, method string, aliases ...string) *cobra.Command {
return &cobra.Command{
Use: use + " <id-or-name>...",
Aliases: aliases,
Short: short,
Args: minArgsUsage(1, fmt.Sprintf("usage: banger vm %s <id-or-name>...", use)),
ValidArgsFunction: d.completeVMNames,
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
if len(args) > 1 {
return runVMBatchAction(cmd, layout.SocketPath, args, func(ctx context.Context, id string) (model.VMRecord, error) {
result, err := rpc.Call[api.VMShowResult](ctx, layout.SocketPath, method, api.VMRefParams{IDOrName: id})
if err != nil {
return model.VMRecord{}, err
}
return result.VM, nil
})
}
result, err := rpc.Call[api.VMShowResult](cmd.Context(), layout.SocketPath, method, api.VMRefParams{IDOrName: args[0]})
if err != nil {
return err
}
return printVMSummary(cmd.OutOrStdout(), result.VM)
},
}
}
func (d *deps) newVMDeleteCommand() *cobra.Command {
return &cobra.Command{
Use: "delete <id-or-name>...",
Aliases: []string{"rm"},
Short: "Stop a VM and remove its disks (irreversible)",
Long: strings.TrimSpace(`
Stop the VM if it's running, then remove its work disk, system
overlay, snapshot, and metadata. Frees host disk space. The
operation is irreversible — anything written inside the guest is
lost.
Use 'banger vm prune' to bulk-delete every VM that isn't running.
`),
Args: minArgsUsage(1, "usage: banger vm delete <id-or-name>..."),
ValidArgsFunction: d.completeVMNames,
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
deleteOne := func(ctx context.Context, id string) (model.VMRecord, error) {
result, err := rpc.Call[api.VMShowResult](ctx, layout.SocketPath, "vm.delete", api.VMRefParams{IDOrName: id})
if err != nil {
return model.VMRecord{}, err
}
if err := removeUserKnownHosts(result.VM); err != nil {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "known_hosts cleanup for %s: %v\n", id, err)
}
return result.VM, nil
}
if len(args) > 1 {
return runVMBatchAction(cmd, layout.SocketPath, args, deleteOne)
}
vm, err := deleteOne(cmd.Context(), args[0])
if err != nil {
return err
}
return printVMSummary(cmd.OutOrStdout(), vm)
},
}
}
func (d *deps) newVMSetCommand() *cobra.Command {
var (
vcpu int
memory int
diskSize string
nat bool
noNat bool
)
cmd := &cobra.Command{
Use: "set <id-or-name>...",
Short: "Update stopped VM settings",
Long: strings.TrimSpace(`
Reconfigure one or more stopped VMs. The VM must be stopped before
reconfiguring — start it again with 'banger vm start' to apply the new settings.
`),
Example: strings.TrimSpace(`
banger vm set dev --vcpu 4 --memory 8192
`),
Args: minArgsUsage(1, "usage: banger vm set [--vcpu N] [--memory MiB] [--disk-size SIZE] [--nat|--no-nat] <id-or-name>..."),
ValidArgsFunction: d.completeVMNames,
RunE: func(cmd *cobra.Command, args []string) error {
params, err := vmSetParamsFromFlags(args[0], vcpu, memory, diskSize, nat, noNat)
if err != nil {
return err
}
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
if len(args) > 1 {
return runVMBatchAction(cmd, layout.SocketPath, args, func(ctx context.Context, id string) (model.VMRecord, error) {
batchParams := params
batchParams.IDOrName = id
result, err := rpc.Call[api.VMShowResult](ctx, layout.SocketPath, "vm.set", batchParams)
if err != nil {
return model.VMRecord{}, err
}
return result.VM, nil
})
}
result, err := rpc.Call[api.VMShowResult](cmd.Context(), layout.SocketPath, "vm.set", params)
if err != nil {
return err
}
return printVMSummary(cmd.OutOrStdout(), result.VM)
},
}
cmd.Flags().IntVar(&vcpu, "vcpu", -1, "vcpu count")
cmd.Flags().IntVar(&memory, "memory", -1, "memory in MiB")
cmd.Flags().StringVar(&diskSize, "disk-size", "", "new work disk size")
cmd.Flags().BoolVar(&nat, "nat", false, "enable NAT")
cmd.Flags().BoolVar(&noNat, "no-nat", false, "disable NAT")
return cmd
}
func (d *deps) newVMSSHCommand() *cobra.Command {
return &cobra.Command{
Use: "ssh <id-or-name> [ssh args...]",
Short: "Open an interactive ssh session to a running VM",
Long: strings.TrimSpace(`
Connect to a running VM as root over the host bridge. Trailing
arguments are passed through to the underlying 'ssh' command, so
'-- -L 8080:localhost:8080' forwards a port and '-- echo hi' runs
a single command and exits.
To run a one-shot command without holding a session, prefer
'banger vm run --rm -- <command>' over 'vm ssh -- <command>'.
`),
Example: strings.TrimSpace(`
banger vm ssh agent
banger vm ssh agent -- uname -a
banger vm ssh agent -- -L 8080:localhost:8080 -N
`),
Args: minArgsUsage(1, "usage: banger vm ssh <id-or-name> [ssh args...]"),
ValidArgsFunction: d.completeVMNameOnlyAtPos0,
RunE: func(cmd *cobra.Command, args []string) error {
layout, cfg, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
if err := validateSSHPrereqs(cfg); err != nil {
return err
}
result, err := d.vmSSH(cmd.Context(), layout.SocketPath, args[0])
if err != nil {
return err
}
sshArgs, err := sshCommandArgs(cfg, result.GuestIP, args[1:])
if err != nil {
return err
}
return d.runSSHSession(cmd.Context(), layout.SocketPath, result.Name, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), sshArgs, false)
},
}
}
func (d *deps) newVMWorkspaceCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "workspace",
Short: "Ship a host repo into a guest, pull diffs back",
Long: strings.TrimSpace(`
Two-step pattern for round-tripping a working tree through a guest
VM:
prepare Copy a local git repo into the guest at /root/repo
(or any path you choose). Default ships tracked files
only; pass --include-untracked to ship the rest.
export Capture every change inside the guest workspace as a
host-readable patch. Non-mutating: the guest's working
tree is left untouched.
This is the supported flow for AI agents and CI runners that want
to evaluate code changes inside a sandbox without touching the
host checkout. 'banger vm run ./repo -- <cmd>' is shorthand for
prepare + run + delete.
`),
Example: strings.TrimSpace(`
banger vm workspace prepare agent ../repo
banger vm ssh agent -- bash -lc 'cd /root/repo && make test'
banger vm workspace export agent --base-commit <commit> > out.patch
`),
RunE: helpNoArgs,
}
cmd.AddCommand(
d.newVMWorkspacePrepareCommand(),
d.newVMWorkspaceExportCommand(),
)
return cmd
}
func (d *deps) newVMWorkspacePrepareCommand() *cobra.Command {
var guestPath string
var branchName string
var fromRef string
var mode string
var includeUntracked bool
var dryRun bool
cmd := &cobra.Command{
Use: "prepare <id-or-name> [path]",
Short: "Copy a local repo into a running VM",
Long: "Prepare a repository workspace from a local git checkout into a running VM. The default guest path is /root/repo and the default mode is shallow_overlay. Repositories with git submodules must use --mode full_copy.",
Args: minArgsUsage(1, "usage: banger vm workspace prepare <id-or-name> [path]"),
ValidArgsFunction: d.completeVMNameOnlyAtPos0,
Example: strings.TrimSpace(`
banger vm workspace prepare devbox
banger vm workspace prepare devbox ../repo --guest-path /root/repo
banger vm workspace prepare devbox ../repo --mode full_copy
`),
RunE: func(cmd *cobra.Command, args []string) error {
sourcePath := ""
if len(args) > 1 {
sourcePath = args[1]
}
if strings.TrimSpace(sourcePath) == "" {
wd, err := d.cwd()
if err != nil {
return err
}
sourcePath = wd
}
resolvedPath, err := workspace.ResolveSourcePath(sourcePath)
if err != nil {
return err
}
prepareFrom := ""
if strings.TrimSpace(branchName) != "" {
prepareFrom = fromRef
}
if dryRun {
return d.runWorkspaceDryRun(cmd.Context(), cmd.OutOrStdout(), resolvedPath, branchName, prepareFrom, includeUntracked)
}
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
if !includeUntracked {
d.noteUntrackedSkipped(cmd.Context(), cmd.ErrOrStderr(), resolvedPath)
}
result, err := d.vmWorkspacePrepare(cmd.Context(), layout.SocketPath, api.VMWorkspacePrepareParams{
IDOrName: args[0],
SourcePath: resolvedPath,
GuestPath: guestPath,
Branch: branchName,
From: prepareFrom,
Mode: mode,
IncludeUntracked: includeUntracked,
})
if err != nil {
return err
}
return printJSON(cmd.OutOrStdout(), result.Workspace)
},
}
cmd.Flags().StringVar(&guestPath, "guest-path", "/root/repo", "guest workspace path")
cmd.Flags().StringVar(&branchName, "branch", "", "create and switch to a new guest branch")
cmd.Flags().StringVar(&fromRef, "from", "HEAD", "git ref to branch from when --branch is set (default: HEAD)")
cmd.Flags().StringVar(&mode, "mode", string(model.WorkspacePrepareModeShallowOverlay), "workspace mode: shallow_overlay, full_copy, metadata_only")
cmd.Flags().BoolVar(&includeUntracked, "include-untracked", false, "also copy untracked non-ignored files into the guest workspace (default: tracked files only)")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "list the files that would be copied and exit without touching the guest")
return cmd
}
func (d *deps) newVMWorkspaceExportCommand() *cobra.Command {
var guestPath string
var outputPath string
var baseCommit string
cmd := &cobra.Command{
Use: "export <id-or-name>",
Short: "Pull changes from a guest workspace back to the host as a patch",
Long: "Emit a binary-safe unified diff of every change inside the guest workspace (committed since base + uncommitted + untracked, minus .gitignore). Non-mutating — the guest's index and working tree are untouched. Pass --base-commit with the head_commit from workspace prepare to capture changes even when the worker ran git commit inside the VM. Without --base-commit the diff is against the current guest HEAD, which misses committed changes.",
Args: exactArgsUsage(1, "usage: banger vm workspace export <id-or-name>"),
ValidArgsFunction: d.completeVMNameOnlyAtPos0,
Example: strings.TrimSpace(`
banger vm workspace export devbox | git apply
banger vm workspace export devbox --base-commit abc1234 | git apply
banger vm workspace export devbox --output worker.diff
banger vm workspace export devbox --guest-path /root/project --output changes.diff
`),
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := d.vmWorkspaceExport(cmd.Context(), layout.SocketPath, api.WorkspaceExportParams{
IDOrName: args[0],
GuestPath: guestPath,
BaseCommit: baseCommit,
})
if err != nil {
return err
}
if !result.HasChanges {
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "no changes")
return nil
}
if outputPath != "" {
if err := os.WriteFile(outputPath, result.Patch, 0o644); err != nil {
return fmt.Errorf("write patch: %w", err)
}
_, err = fmt.Fprintf(cmd.ErrOrStderr(), "patch written to %s (%d bytes, %d files)\n",
outputPath, len(result.Patch), len(result.ChangedFiles))
return err
}
_, err = cmd.OutOrStdout().Write(result.Patch)
return err
},
}
cmd.Flags().StringVar(&guestPath, "guest-path", "/root/repo", "guest workspace path")
cmd.Flags().StringVar(&outputPath, "output", "", "write patch to this file instead of stdout")
cmd.Flags().StringVar(&baseCommit, "base-commit", "", "diff from this commit (use head_commit from workspace prepare to capture worker git commits)")
return cmd
}
func (d *deps) newVMLogsCommand() *cobra.Command {
var follow bool
cmd := &cobra.Command{
Use: "logs <id-or-name>",
Short: "Show guest console + per-VM daemon log",
Long: strings.TrimSpace(`
Print the firecracker console log (kernel + early init output) and
the per-VM daemon log (lifecycle stages, errors). Pass -f to follow
new lines as they arrive — useful while a VM is starting up or
hanging on boot.
`),
Example: strings.TrimSpace(`
banger vm logs agent
banger vm logs agent -f
`),
Args: exactArgsUsage(1, "usage: banger vm logs [-f] <id-or-name>"),
ValidArgsFunction: d.completeVMNameOnlyAtPos0,
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := rpc.Call[api.VMLogsResult](cmd.Context(), layout.SocketPath, "vm.logs", api.VMRefParams{IDOrName: args[0]})
if err != nil {
return err
}
if result.LogPath == "" {
return errors.New("vm has no log path")
}
return system.CopyStream(cmd.OutOrStdout(), system.TailCommand(result.LogPath, follow))
},
}
cmd.Flags().BoolVarP(&follow, "follow", "f", false, "follow logs")
return cmd
}
func (d *deps) newVMStatsCommand() *cobra.Command {
return &cobra.Command{
Use: "stats <id-or-name>",
Short: "Show VM stats",
Long: strings.TrimSpace(`
Print real-time resource statistics for a running VM as a JSON object,
including CPU usage, memory balloon metrics, and disk I/O counters.
Pipe into 'jq' for quick field extraction, e.g. banger vm stats dev | jq .mem.
`),
Example: strings.TrimSpace(`
banger vm stats dev
banger vm stats dev | jq .
`),
Args: exactArgsUsage(1, "usage: banger vm stats <id-or-name>"),
ValidArgsFunction: d.completeVMNameOnlyAtPos0,
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := rpc.Call[api.VMStatsResult](cmd.Context(), layout.SocketPath, "vm.stats", api.VMRefParams{IDOrName: args[0]})
if err != nil {
return err
}
return printJSON(cmd.OutOrStdout(), result)
},
}
}
func (d *deps) newVMPortsCommand() *cobra.Command {
var jsonOut bool
cmd := &cobra.Command{
Use: "ports <id-or-name>",
Short: "Show host-reachable listening guest ports",
Args: exactArgsUsage(1, "usage: banger vm ports <id-or-name>"),
ValidArgsFunction: d.completeVMNameOnlyAtPos0,
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := d.vmPorts(cmd.Context(), layout.SocketPath, args[0])
if err != nil {
return err
}
if jsonOut {
return printJSON(cmd.OutOrStdout(), result)
}
return printVMPortsTable(cmd.OutOrStdout(), result)
},
}
cmd.Flags().BoolVar(&jsonOut, "json", false, "print ports as JSON instead of a table")
return cmd
}
type resolvedVMTarget struct {
Index int
Ref string
VM model.VMRecord
}
type vmRefResolutionError struct {
Index int
Ref string
Err error
}
type vmBatchActionResult struct {
Target resolvedVMTarget
VM model.VMRecord
Err error
}
func runVMBatchAction(cmd *cobra.Command, socketPath string, refs []string, action func(context.Context, string) (model.VMRecord, error)) error {
listResult, err := rpc.Call[api.VMListResult](cmd.Context(), socketPath, "vm.list", api.Empty{})
if err != nil {
return err
}
targets, resolutionErrs := resolveVMTargets(listResult.VMs, refs)
results := executeVMActionBatch(cmd.Context(), targets, action)
failed := false
for _, resolutionErr := range resolutionErrs {
if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "%s: %v\n", resolutionErr.Ref, resolutionErr.Err); err != nil {
return err
}
failed = true
}
for _, result := range results {
if result.Err != nil {
if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "%s: %v\n", result.Target.Ref, result.Err); err != nil {
return err
}
failed = true
continue
}
if err := printVMSummary(cmd.OutOrStdout(), result.VM); err != nil {
return err
}
}
if failed {
return errors.New("one or more VM operations failed")
}
return nil
}
func resolveVMTargets(vms []model.VMRecord, refs []string) ([]resolvedVMTarget, []vmRefResolutionError) {
targets := make([]resolvedVMTarget, 0, len(refs))
resolutionErrs := make([]vmRefResolutionError, 0)
seen := make(map[string]struct{}, len(refs))
for index, ref := range refs {
vm, err := resolveVMRef(vms, ref)
if err != nil {
resolutionErrs = append(resolutionErrs, vmRefResolutionError{Index: index, Ref: ref, Err: err})
continue
}
if _, ok := seen[vm.ID]; ok {
continue
}
seen[vm.ID] = struct{}{}
targets = append(targets, resolvedVMTarget{Index: index, Ref: ref, VM: vm})
}
return targets, resolutionErrs
}
func resolveVMRef(vms []model.VMRecord, ref string) (model.VMRecord, error) {
ref = strings.TrimSpace(ref)
if ref == "" {
return model.VMRecord{}, errors.New("vm id or name is required")
}
exactMatches := make([]model.VMRecord, 0, 1)
for _, vm := range vms {
if vm.ID == ref || vm.Name == ref {
exactMatches = append(exactMatches, vm)
}
}
switch len(exactMatches) {
case 1:
return exactMatches[0], nil
case 0:
default:
return model.VMRecord{}, fmt.Errorf("multiple VMs match %q", ref)
}
prefixMatches := make([]model.VMRecord, 0, 1)
for _, vm := range vms {
if strings.HasPrefix(vm.ID, ref) || strings.HasPrefix(vm.Name, ref) {
prefixMatches = append(prefixMatches, vm)
}
}
switch len(prefixMatches) {
case 1:
return prefixMatches[0], nil
case 0:
return model.VMRecord{}, fmt.Errorf("vm %q not found", ref)
default:
return model.VMRecord{}, fmt.Errorf("multiple VMs match %q", ref)
}
}
func executeVMActionBatch(ctx context.Context, targets []resolvedVMTarget, action func(context.Context, string) (model.VMRecord, error)) []vmBatchActionResult {
results := make([]vmBatchActionResult, len(targets))
var wg sync.WaitGroup
wg.Add(len(targets))
for index, target := range targets {
index := index
target := target
go func() {
defer wg.Done()
vm, err := action(ctx, target.VM.ID)
results[index] = vmBatchActionResult{
Target: target,
VM: vm,
Err: err,
}
}()
}
wg.Wait()
return results
}
func vmSetParamsFromFlags(idOrName string, vcpu, memory int, diskSize string, nat, noNat bool) (api.VMSetParams, error) {
if nat && noNat {
return api.VMSetParams{}, errors.New("use only one of --nat or --no-nat")
}
params := api.VMSetParams{IDOrName: idOrName, WorkDiskSize: diskSize}
if vcpu >= 0 {
if err := validatePositiveSetting("vcpu", vcpu); err != nil {
return api.VMSetParams{}, err
}
params.VCPUCount = &vcpu
}
if memory >= 0 {
if err := validatePositiveSetting("memory", memory); err != nil {
return api.VMSetParams{}, err
}
params.MemoryMiB = &memory
}
if nat || noNat {
value := nat && !noNat
params.NATEnabled = &value
}
if params.VCPUCount == nil && params.MemoryMiB == nil && params.WorkDiskSize == "" && params.NATEnabled == nil {
return api.VMSetParams{}, errors.New("no VM settings changed")
}
return params, nil
}
func vmCreateParamsFromFlags(cmd *cobra.Command, name, imageName string, vcpu, memory int, systemOverlaySize, workDiskSize string, natEnabled, noStart bool) (api.VMCreateParams, error) {
// Flag defaults were resolved from config + host heuristics at
// command-build time, so we always forward the flag values. The CLI
// becomes the single source of truth for effective defaults and the
// progress renderer shows the exact sizing.
if strings.TrimSpace(name) != "" {
if err := model.ValidateVMName(name); err != nil {
return api.VMCreateParams{}, err
}
}
if err := validatePositiveSetting("vcpu", vcpu); err != nil {
return api.VMCreateParams{}, err
}
if err := validatePositiveSetting("memory", memory); err != nil {
return api.VMCreateParams{}, err
}
params := api.VMCreateParams{
Name: name,
ImageName: imageName,
NATEnabled: natEnabled,
NoStart: noStart,
VCPUCount: &vcpu,
MemoryMiB: &memory,
SystemOverlaySize: systemOverlaySize,
WorkDiskSize: workDiskSize,
}
return params, nil
}