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 virtual machines", RunE: helpNoArgs, } cmd.AddCommand( d.newVMCreateCommand(), d.newVMRunCommand(), d.newVMListCommand(), d.newVMShowCommand(), d.newVMActionCommand("start", "Start a VM", "vm.start"), d.newVMActionCommand("stop", "Stop a VM", "vm.stop"), d.newVMKillCommand(), d.newVMActionCommand("restart", "Restart a VM", "vm.restart"), d.newVMActionCommand("delete", "Delete a VM", "vm.delete", "rm"), d.newVMPruneCommand(), d.newVMSetCommand(), d.newVMSSHCommand(), 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 ) 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. Three 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 `), 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 -- 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") } 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 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 } if err := system.EnsureSudo(cmd.Context()); 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) }, } 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", "base ref for --branch") 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.RegisterFlagCompletionFunc("image", d.completeImageNames) return cmd } func (d *deps) newVMKillCommand() *cobra.Command { var signal string cmd := &cobra.Command{ Use: "kill ...", Short: "Send a signal to a VM process", Args: minArgsUsage(1, "usage: banger vm kill [--signal SIGTERM|SIGKILL|...] ..."), ValidArgsFunction: d.completeVMNames, RunE: func(cmd *cobra.Command, args []string) error { if err := system.EnsureSudo(cmd.Context()); 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) { 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 { if err := system.EnsureSudo(cmd.Context()); err != nil { return err } 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, 0, 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 } 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", 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 } if err := system.EnsureSudo(cmd.Context()); 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 ", Short: "Show VM details", Args: exactArgsUsage(1, "usage: banger vm show "), 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 + " ...", Aliases: aliases, Short: short, Args: minArgsUsage(1, fmt.Sprintf("usage: banger vm %s ...", use)), ValidArgsFunction: d.completeVMNames, RunE: func(cmd *cobra.Command, args []string) error { if err := system.EnsureSudo(cmd.Context()); 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) { 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) newVMSetCommand() *cobra.Command { var ( vcpu int memory int diskSize string nat bool noNat bool ) cmd := &cobra.Command{ Use: "set ...", Short: "Update stopped VM settings", Args: minArgsUsage(1, "usage: banger vm set [--vcpu N] [--memory MiB] [--disk-size SIZE] [--nat|--no-nat] ..."), 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 } if err := system.EnsureSudo(cmd.Context()); 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 [ssh args...]", Short: "SSH into a running VM", Args: minArgsUsage(1, "usage: banger vm ssh [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: "Manage repository workspaces inside a running VM", 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 readOnly bool var includeUntracked bool var dryRun bool cmd := &cobra.Command{ Use: "prepare [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 [path]"), ValidArgsFunction: d.completeVMNameOnlyAtPos0, Example: strings.TrimSpace(` banger vm workspace prepare devbox banger vm workspace prepare devbox ../repo --guest-path /root/repo --readonly 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 runWorkspaceDryRun(cmd.Context(), cmd.OutOrStdout(), resolvedPath, branchName, prepareFrom, includeUntracked) } layout, _, err := d.ensureDaemon(cmd.Context()) if err != nil { return err } if !includeUntracked { if err := noteUntrackedSkipped(cmd.Context(), cmd.ErrOrStderr(), resolvedPath); err != nil { return err } } result, err := d.vmWorkspacePrepare(cmd.Context(), layout.SocketPath, api.VMWorkspacePrepareParams{ IDOrName: args[0], SourcePath: resolvedPath, GuestPath: guestPath, Branch: branchName, From: prepareFrom, Mode: mode, ReadOnly: readOnly, 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", "base ref for --branch") cmd.Flags().StringVar(&mode, "mode", string(model.WorkspacePrepareModeShallowOverlay), "workspace mode: shallow_overlay, full_copy, metadata_only") cmd.Flags().BoolVar(&readOnly, "readonly", false, "make the prepared workspace read-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 ", 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 "), 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 ", Short: "Show VM logs", Args: exactArgsUsage(1, "usage: banger vm logs [-f] "), 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 ", Short: "Show VM stats", Args: exactArgsUsage(1, "usage: banger vm stats "), 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 { return &cobra.Command{ Use: "ports ", Short: "Show host-reachable listening guest ports", Args: exactArgsUsage(1, "usage: banger vm ports "), 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 } return printVMPortsTable(cmd.OutOrStdout(), result) }, } } 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 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 }