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>
1117 lines
38 KiB
Go
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
|
|
}
|