Move the supported systemd path to two services: an owner-user bangerd for orchestration and a narrow root helper for bridge/tap, NAT/resolver, dm/loop, and Firecracker ownership. This removes repeated sudo from daily vm and image flows without leaving the general daemon running as root. Add install metadata, system install/status/restart/uninstall commands, and a system-owned runtime layout. Keep user SSH/config material in the owner home, lock file_sync to the owner home, and move daemon known_hosts handling out of the old root-owned control path. Route privileged lifecycle steps through typed privilegedOps calls, harden the two systemd units, and rewrite smoke plus docs around the supported service model. Verified with make build, make test, make lint, and make smoke on the supported systemd host path.
969 lines
31 KiB
Go
969 lines
31 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.newVMDeleteCommand(),
|
|
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 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)
|
|
},
|
|
}
|
|
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 <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 {
|
|
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, 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
|
|
}
|
|
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",
|
|
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: "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 {
|
|
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: "Delete a VM",
|
|
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",
|
|
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: "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 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", "base ref for --branch")
|
|
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 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 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
|
|
}
|