Cuts the daemon-managed guest-session machinery (start/list/show/
logs/stop/kill/attach/send). The feature shipped aimed at agent-
orchestration workflows (programmatic stdin piping into a long-lived
guest process) that aren't driving any concrete user today, and the
~2.3K LOC of daemon surface area — attach bridge, FIFO keepalive,
controller registry, sessionstream framing, SQLite persistence — was
locking in an API we'd have to keep through v0.1.0.
Anything session-flavoured that people actually need today can be
done with `vm ssh + tmux` or `vm run -- cmd`.
Deleted:
- internal/cli/commands_vm_session.go
- internal/daemon/{guest_sessions,session_lifecycle,session_attach,session_stream,session_controller}.go
- internal/daemon/session/ (guest-session helpers package)
- internal/sessionstream/ (framing package)
- internal/daemon/guest_sessions_test.go
- internal/store/guest_session_test.go
- GuestSession* types from internal/{api,model}
- Store UpsertGuestSession/GetGuestSession/ListGuestSessionsByVM/DeleteGuestSession + scanner helpers
- guest.session.* RPC dispatch entries
- 5 CLI session tests, 2 completion tests, 2 printer tests
Extracted:
- ShellQuote + FormatStepError lifted to internal/daemon/workspace/util.go
(only non-session consumer); workspace package now self-contained
- internal/daemon/guest_ssh.go keeps guestSSHClient + dialGuest +
waitForGuestSSH — still used by workspace prepare/export
- internal/daemon/fake_firecracker_test.go preserves the test helper
that used to live in guest_sessions_test.go
Store schema: CREATE TABLE guest_sessions and its column migrations
removed. Existing dev DBs keep an orphan table (harmless, pre-v0.1.0).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
923 lines
29 KiB
Go
923 lines
29 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 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
|
|
)
|
|
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}
|
|
}
|
|
|
|
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.RegisterFlagCompletionFunc("image", d.completeImageNames)
|
|
return cmd
|
|
}
|
|
|
|
func (d *deps) newVMKillCommand() *cobra.Command {
|
|
var signal string
|
|
cmd := &cobra.Command{
|
|
Use: "kill <id-or-name>...",
|
|
Short: "Send a signal to a VM process",
|
|
Args: minArgsUsage(1, "usage: banger vm kill [--signal SIGTERM|SIGKILL|...] <id-or-name>..."),
|
|
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 <id-or-name>",
|
|
Short: "Show VM details",
|
|
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 {
|
|
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 <id-or-name>...",
|
|
Short: "Update stopped VM settings",
|
|
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
|
|
}
|
|
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 <id-or-name> [ssh args...]",
|
|
Short: "SSH into a running VM",
|
|
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: "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
|
|
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 --readonly
|
|
banger vm workspace prepare devbox ../repo --mode full_copy
|
|
`),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
layout, _, err := d.ensureDaemon(cmd.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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
|
|
}
|
|
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,
|
|
})
|
|
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")
|
|
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 VM logs",
|
|
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",
|
|
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 {
|
|
return &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
|
|
}
|
|
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
|
|
}
|