cli + daemon: move test seams off package globals onto injected structs

CLI: introduce internal/cli.deps which owns every RPC/SSH/host-command
seam the tree used to reach through mutable package vars. Command
builders, orchestrators, and the completion helpers become methods on
*deps. Tests construct their own deps per case, so fakes no longer leak
across cases and tests are free to run in parallel.

Daemon: move workspaceInspectRepoFunc + workspaceImportFunc onto the
Daemon struct (workspaceInspectRepo / workspaceImport), mirroring the
existing guestWaitForSSH / guestDial pattern. Workspace-prepare tests
drop t.Parallel() guards now that they no longer mutate process-wide
state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thales Maciel 2026-04-19 19:03:55 -03:00
parent d38f580e00
commit c42fcbe012
No known key found for this signature in database
GPG key ID: 33112E6833C34679
19 changed files with 664 additions and 733 deletions

View file

@ -22,35 +22,35 @@ import (
"github.com/spf13/cobra"
)
func newVMCommand() *cobra.Command {
func (d *deps) newVMCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "vm",
Short: "Manage virtual machines",
RunE: helpNoArgs,
}
cmd.AddCommand(
newVMCreateCommand(),
newVMRunCommand(),
newVMListCommand(),
newVMShowCommand(),
newVMActionCommand("start", "Start a VM", "vm.start"),
newVMActionCommand("stop", "Stop a VM", "vm.stop"),
newVMKillCommand(),
newVMActionCommand("restart", "Restart a VM", "vm.restart"),
newVMActionCommand("delete", "Delete a VM", "vm.delete", "rm"),
newVMPruneCommand(),
newVMSetCommand(),
newVMSSHCommand(),
newVMWorkspaceCommand(),
newVMSessionCommand(),
newVMLogsCommand(),
newVMStatsCommand(),
newVMPortsCommand(),
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.newVMSessionCommand(),
d.newVMLogsCommand(),
d.newVMStatsCommand(),
d.newVMPortsCommand(),
)
return cmd
}
func newVMRunCommand() *cobra.Command {
func (d *deps) newVMRunCommand() *cobra.Command {
defaults := effectiveVMDefaults()
var (
name string
@ -104,7 +104,7 @@ Three modes:
var repoPtr *vmRunRepo
if sourcePath != "" {
resolved, err := vmRunPreflightRepo(cmd.Context(), sourcePath)
resolved, err := d.vmRunPreflightRepo(cmd.Context(), sourcePath)
if err != nil {
return err
}
@ -135,11 +135,11 @@ Three modes:
if err := system.EnsureSudo(cmd.Context()); err != nil {
return err
}
layout, cfg, err = ensureDaemon(cmd.Context())
layout, cfg, err = d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
return runVMRun(cmd.Context(), layout.SocketPath, cfg, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), params, repoPtr, commandArgs, removeOnExit)
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")
@ -152,22 +152,22 @@ Three modes:
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", completeImageNames)
_ = cmd.RegisterFlagCompletionFunc("image", d.completeImageNames)
return cmd
}
func newVMKillCommand() *cobra.Command {
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: completeVMNames,
ValidArgsFunction: d.completeVMNames,
RunE: func(cmd *cobra.Command, args []string) error {
if err := system.EnsureSudo(cmd.Context()); err != nil {
return err
}
layout, _, err := ensureDaemon(cmd.Context())
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
@ -201,7 +201,7 @@ func newVMKillCommand() *cobra.Command {
return cmd
}
func newVMPruneCommand() *cobra.Command {
func (d *deps) newVMPruneCommand() *cobra.Command {
var force bool
cmd := &cobra.Command{
Use: "prune",
@ -212,23 +212,23 @@ func newVMPruneCommand() *cobra.Command {
if err := system.EnsureSudo(cmd.Context()); err != nil {
return err
}
layout, _, err := ensureDaemon(cmd.Context())
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
return runVMPrune(cmd, layout.SocketPath, force)
return d.runVMPrune(cmd, layout.SocketPath, force)
},
}
cmd.Flags().BoolVarP(&force, "force", "f", false, "skip the confirmation prompt")
return cmd
}
func runVMPrune(cmd *cobra.Command, socketPath string, force bool) error {
func (d *deps) runVMPrune(cmd *cobra.Command, socketPath string, force bool) error {
ctx := cmd.Context()
stdout := cmd.OutOrStdout()
stderr := cmd.ErrOrStderr()
list, err := vmListFunc(ctx, socketPath)
list, err := d.vmList(ctx, socketPath)
if err != nil {
return err
}
@ -270,7 +270,7 @@ func runVMPrune(cmd *cobra.Command, socketPath string, force bool) error {
if ref == "" {
ref = shortID(vm.ID)
}
if err := vmDeleteFunc(ctx, socketPath, vm.ID); err != nil {
if err := d.vmDelete(ctx, socketPath, vm.ID); err != nil {
fmt.Fprintf(stderr, "delete %s: %v\n", ref, err)
failed++
continue
@ -299,7 +299,7 @@ func promptYesNo(in io.Reader, out io.Writer, prompt string) (bool, error) {
return answer == "y" || answer == "yes", nil
}
func newVMCreateCommand() *cobra.Command {
func (d *deps) newVMCreateCommand() *cobra.Command {
defaults := effectiveVMDefaults()
var (
name string
@ -323,11 +323,11 @@ func newVMCreateCommand() *cobra.Command {
if err := system.EnsureSudo(cmd.Context()); err != nil {
return err
}
layout, _, err := ensureDaemon(cmd.Context())
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
vm, err := runVMCreate(cmd.Context(), layout.SocketPath, cmd.ErrOrStderr(), params)
vm, err := d.runVMCreate(cmd.Context(), layout.SocketPath, cmd.ErrOrStderr(), params)
if err != nil {
return err
}
@ -342,7 +342,7 @@ func newVMCreateCommand() *cobra.Command {
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", completeImageNames)
_ = cmd.RegisterFlagCompletionFunc("image", d.completeImageNames)
return cmd
}
@ -352,15 +352,15 @@ type vmListOptions struct {
quiet bool
}
func newPSCommand() *cobra.Command {
return newVMListLikeCommand("ps", nil, "usage: banger ps")
func (d *deps) newPSCommand() *cobra.Command {
return d.newVMListLikeCommand("ps", nil, "usage: banger ps")
}
func newVMListCommand() *cobra.Command {
return newVMListLikeCommand("list", []string{"ls", "ps"}, "usage: banger vm list")
func (d *deps) newVMListCommand() *cobra.Command {
return d.newVMListLikeCommand("list", []string{"ls", "ps"}, "usage: banger vm list")
}
func newVMListLikeCommand(use string, aliases []string, usage string) *cobra.Command {
func (d *deps) newVMListLikeCommand(use string, aliases []string, usage string) *cobra.Command {
var opts vmListOptions
cmd := &cobra.Command{
Use: use,
@ -368,7 +368,7 @@ func newVMListLikeCommand(use string, aliases []string, usage string) *cobra.Com
Short: "List VMs",
Args: noArgsUsage(usage),
RunE: func(cmd *cobra.Command, args []string) error {
return runVMList(cmd, opts)
return d.runVMList(cmd, opts)
},
}
cmd.Flags().BoolVarP(&opts.showAll, "all", "a", false, "show all VMs")
@ -377,8 +377,8 @@ func newVMListLikeCommand(use string, aliases []string, usage string) *cobra.Com
return cmd
}
func runVMList(cmd *cobra.Command, opts vmListOptions) error {
layout, _, err := ensureDaemon(cmd.Context())
func (d *deps) runVMList(cmd *cobra.Command, opts vmListOptions) error {
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
@ -421,14 +421,14 @@ func selectVMListVMs(vms []model.VMRecord, showAll, latest bool) []model.VMRecor
return []model.VMRecord{latestVM}
}
func newVMShowCommand() *cobra.Command {
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: completeVMNameOnlyAtPos0,
ValidArgsFunction: d.completeVMNameOnlyAtPos0,
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := ensureDaemon(cmd.Context())
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
@ -441,18 +441,18 @@ func newVMShowCommand() *cobra.Command {
}
}
func newVMActionCommand(use, short, method string, aliases ...string) *cobra.Command {
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: completeVMNames,
ValidArgsFunction: d.completeVMNames,
RunE: func(cmd *cobra.Command, args []string) error {
if err := system.EnsureSudo(cmd.Context()); err != nil {
return err
}
layout, _, err := ensureDaemon(cmd.Context())
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
@ -474,7 +474,7 @@ func newVMActionCommand(use, short, method string, aliases ...string) *cobra.Com
}
}
func newVMSetCommand() *cobra.Command {
func (d *deps) newVMSetCommand() *cobra.Command {
var (
vcpu int
memory int
@ -486,7 +486,7 @@ func newVMSetCommand() *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: completeVMNames,
ValidArgsFunction: d.completeVMNames,
RunE: func(cmd *cobra.Command, args []string) error {
params, err := vmSetParamsFromFlags(args[0], vcpu, memory, diskSize, nat, noNat)
if err != nil {
@ -495,7 +495,7 @@ func newVMSetCommand() *cobra.Command {
if err := system.EnsureSudo(cmd.Context()); err != nil {
return err
}
layout, _, err := ensureDaemon(cmd.Context())
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
@ -525,21 +525,21 @@ func newVMSetCommand() *cobra.Command {
return cmd
}
func newVMSSHCommand() *cobra.Command {
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: completeVMNameOnlyAtPos0,
ValidArgsFunction: d.completeVMNameOnlyAtPos0,
RunE: func(cmd *cobra.Command, args []string) error {
layout, cfg, err := ensureDaemon(cmd.Context())
layout, cfg, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
if err := validateSSHPrereqs(cfg); err != nil {
return err
}
result, err := vmSSHFunc(cmd.Context(), layout.SocketPath, args[0])
result, err := d.vmSSH(cmd.Context(), layout.SocketPath, args[0])
if err != nil {
return err
}
@ -547,25 +547,25 @@ func newVMSSHCommand() *cobra.Command {
if err != nil {
return err
}
return runSSHSession(cmd.Context(), layout.SocketPath, result.Name, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), sshArgs, false)
return d.runSSHSession(cmd.Context(), layout.SocketPath, result.Name, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), sshArgs, false)
},
}
}
func newVMWorkspaceCommand() *cobra.Command {
func (d *deps) newVMWorkspaceCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "workspace",
Short: "Manage repository workspaces inside a running VM",
RunE: helpNoArgs,
}
cmd.AddCommand(
newVMWorkspacePrepareCommand(),
newVMWorkspaceExportCommand(),
d.newVMWorkspacePrepareCommand(),
d.newVMWorkspaceExportCommand(),
)
return cmd
}
func newVMWorkspacePrepareCommand() *cobra.Command {
func (d *deps) newVMWorkspacePrepareCommand() *cobra.Command {
var guestPath string
var branchName string
var fromRef string
@ -576,14 +576,14 @@ func newVMWorkspacePrepareCommand() *cobra.Command {
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: completeVMNameOnlyAtPos0,
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 := ensureDaemon(cmd.Context())
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
@ -592,7 +592,7 @@ func newVMWorkspacePrepareCommand() *cobra.Command {
sourcePath = args[1]
}
if strings.TrimSpace(sourcePath) == "" {
wd, err := cwdFunc()
wd, err := d.cwd()
if err != nil {
return err
}
@ -606,7 +606,7 @@ func newVMWorkspacePrepareCommand() *cobra.Command {
if strings.TrimSpace(branchName) != "" {
prepareFrom = fromRef
}
result, err := vmWorkspacePrepareFunc(cmd.Context(), layout.SocketPath, api.VMWorkspacePrepareParams{
result, err := d.vmWorkspacePrepare(cmd.Context(), layout.SocketPath, api.VMWorkspacePrepareParams{
IDOrName: args[0],
SourcePath: resolvedPath,
GuestPath: guestPath,
@ -629,7 +629,7 @@ func newVMWorkspacePrepareCommand() *cobra.Command {
return cmd
}
func newVMWorkspaceExportCommand() *cobra.Command {
func (d *deps) newVMWorkspaceExportCommand() *cobra.Command {
var guestPath string
var outputPath string
var baseCommit string
@ -638,7 +638,7 @@ func newVMWorkspaceExportCommand() *cobra.Command {
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: completeVMNameOnlyAtPos0,
ValidArgsFunction: d.completeVMNameOnlyAtPos0,
Example: strings.TrimSpace(`
banger vm workspace export devbox | git apply
banger vm workspace export devbox --base-commit abc1234 | git apply
@ -646,11 +646,11 @@ func newVMWorkspaceExportCommand() *cobra.Command {
banger vm workspace export devbox --guest-path /root/project --output changes.diff
`),
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := ensureDaemon(cmd.Context())
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := vmWorkspaceExportFunc(cmd.Context(), layout.SocketPath, api.WorkspaceExportParams{
result, err := d.vmWorkspaceExport(cmd.Context(), layout.SocketPath, api.WorkspaceExportParams{
IDOrName: args[0],
GuestPath: guestPath,
BaseCommit: baseCommit,
@ -680,15 +680,15 @@ func newVMWorkspaceExportCommand() *cobra.Command {
return cmd
}
func newVMLogsCommand() *cobra.Command {
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: completeVMNameOnlyAtPos0,
ValidArgsFunction: d.completeVMNameOnlyAtPos0,
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := ensureDaemon(cmd.Context())
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
@ -706,14 +706,14 @@ func newVMLogsCommand() *cobra.Command {
return cmd
}
func newVMStatsCommand() *cobra.Command {
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: completeVMNameOnlyAtPos0,
ValidArgsFunction: d.completeVMNameOnlyAtPos0,
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := ensureDaemon(cmd.Context())
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
@ -726,18 +726,18 @@ func newVMStatsCommand() *cobra.Command {
}
}
func newVMPortsCommand() *cobra.Command {
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: completeVMNameOnlyAtPos0,
ValidArgsFunction: d.completeVMNameOnlyAtPos0,
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := ensureDaemon(cmd.Context())
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := vmPortsFunc(cmd.Context(), layout.SocketPath, args[0])
result, err := d.vmPorts(cmd.Context(), layout.SocketPath, args[0])
if err != nil {
return err
}