package cli import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net" "os" "os/exec" "path/filepath" "sort" "strings" "sync" "syscall" "text/tabwriter" "time" "banger/internal/api" "banger/internal/config" "banger/internal/daemon" "banger/internal/guest" "banger/internal/hostnat" "banger/internal/imagepreset" "banger/internal/model" "banger/internal/paths" "banger/internal/rpc" "banger/internal/system" "banger/internal/vmdns" "banger/internal/vsockagent" "github.com/spf13/cobra" ) var ( bangerdPathFunc = paths.BangerdPath daemonExePath = func(pid int) string { return filepath.Join("/proc", fmt.Sprintf("%d", pid), "exe") } doctorFunc = daemon.Doctor sshExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error { sshCmd := exec.CommandContext(ctx, "ssh", args...) sshCmd.Stdout = stdout sshCmd.Stderr = stderr sshCmd.Stdin = stdin return sshCmd.Run() } opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error { opencodeCmd := exec.CommandContext(ctx, "opencode", args...) opencodeCmd.Stdout = stdout opencodeCmd.Stderr = stderr opencodeCmd.Stdin = stdin return opencodeCmd.Run() } hostCommandOutputFunc = func(ctx context.Context, name string, args ...string) ([]byte, error) { cmd := exec.CommandContext(ctx, name, args...) output, err := cmd.CombinedOutput() if err == nil { return output, nil } command := strings.TrimSpace(strings.Join(append([]string{name}, args...), " ")) detail := strings.TrimSpace(string(output)) if detail == "" { return output, fmt.Errorf("%s: %w", command, err) } return output, fmt.Errorf("%s: %w: %s", command, err, detail) } vmHealthFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error) { return rpc.Call[api.VMHealthResult](ctx, socketPath, "vm.health", api.VMRefParams{IDOrName: idOrName}) } vmCreateBeginFunc = func(ctx context.Context, socketPath string, params api.VMCreateParams) (api.VMCreateBeginResult, error) { return rpc.Call[api.VMCreateBeginResult](ctx, socketPath, "vm.create.begin", params) } vmCreateStatusFunc = func(ctx context.Context, socketPath, operationID string) (api.VMCreateStatusResult, error) { return rpc.Call[api.VMCreateStatusResult](ctx, socketPath, "vm.create.status", api.VMCreateStatusParams{ID: operationID}) } vmCreateCancelFunc = func(ctx context.Context, socketPath, operationID string) error { _, err := rpc.Call[api.Empty](ctx, socketPath, "vm.create.cancel", api.VMCreateStatusParams{ID: operationID}) return err } vmPortsFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMPortsResult, error) { return rpc.Call[api.VMPortsResult](ctx, socketPath, "vm.ports", api.VMRefParams{IDOrName: idOrName}) } guestWaitForSSHFunc = func(ctx context.Context, address, privateKeyPath string, interval time.Duration) error { return guest.WaitForSSH(ctx, address, privateKeyPath, interval) } guestDialFunc = func(ctx context.Context, address, privateKeyPath string) (vmRunGuestClient, error) { return guest.Dial(ctx, address, privateKeyPath) } cwdFunc = os.Getwd ) type vmRunGuestClient interface { Close() error UploadFile(ctx context.Context, remotePath string, mode os.FileMode, data []byte, logWriter io.Writer) error RunScript(ctx context.Context, script string, logWriter io.Writer) error StreamTarEntries(ctx context.Context, sourceDir string, entries []string, remoteCommand string, logWriter io.Writer) error } type vmRunRepoSpec struct { SourcePath string RepoRoot string RepoName string HeadCommit string CurrentBranch string BranchName string BaseCommit string OverlayPaths []string } const vmRunGuestBundlePath = "/tmp/banger-vm-run.bundle" func NewBangerCommand() *cobra.Command { root := &cobra.Command{ Use: "banger", Short: "Manage development VMs and images", SilenceUsage: true, SilenceErrors: true, RunE: helpNoArgs, } root.CompletionOptions.DisableDefaultCmd = true root.AddCommand(newDaemonCommand(), newDoctorCommand(), newVMCommand(), newImageCommand(), newInternalCommand()) return root } func newDoctorCommand() *cobra.Command { return &cobra.Command{ Use: "doctor", Short: "Check host and runtime readiness", Args: noArgsUsage("usage: banger doctor"), RunE: func(cmd *cobra.Command, args []string) error { report, err := doctorFunc(cmd.Context()) if err != nil { return err } if err := printDoctorReport(cmd.OutOrStdout(), report); err != nil { return err } if report.HasFailures() { return errors.New("doctor found failing checks") } return nil }, } } func newInternalCommand() *cobra.Command { cmd := &cobra.Command{ Use: "internal", Hidden: true, RunE: helpNoArgs, } cmd.AddCommand( newInternalNATCommand(), newInternalWorkSeedCommand(), newInternalSSHKeyPathCommand(), newInternalFirecrackerPathCommand(), newInternalVSockAgentPathCommand(), newInternalPackagesCommand(), ) return cmd } func newInternalSSHKeyPathCommand() *cobra.Command { return &cobra.Command{ Use: "ssh-key-path", Hidden: true, Args: noArgsUsage("usage: banger internal ssh-key-path"), RunE: func(cmd *cobra.Command, args []string) error { layout, err := paths.Resolve() if err != nil { return err } cfg, err := config.Load(layout) if err != nil { return err } _, err = fmt.Fprintln(cmd.OutOrStdout(), cfg.SSHKeyPath) return err }, } } func newInternalFirecrackerPathCommand() *cobra.Command { return &cobra.Command{ Use: "firecracker-path", Hidden: true, Args: noArgsUsage("usage: banger internal firecracker-path"), RunE: func(cmd *cobra.Command, args []string) error { layout, err := paths.Resolve() if err != nil { return err } cfg, err := config.Load(layout) if err != nil { return err } if strings.TrimSpace(cfg.FirecrackerBin) == "" { return errors.New("firecracker binary not configured; install firecracker or set firecracker_bin") } _, err = fmt.Fprintln(cmd.OutOrStdout(), cfg.FirecrackerBin) return err }, } } func newInternalVSockAgentPathCommand() *cobra.Command { return &cobra.Command{ Use: "vsock-agent-path", Hidden: true, Args: noArgsUsage("usage: banger internal vsock-agent-path"), RunE: func(cmd *cobra.Command, args []string) error { path, err := paths.CompanionBinaryPath("banger-vsock-agent") if err != nil { return err } _, err = fmt.Fprintln(cmd.OutOrStdout(), path) return err }, } } func newInternalPackagesCommand() *cobra.Command { var docker bool cmd := &cobra.Command{ Use: "packages ", Hidden: true, Args: exactArgsUsage(1, "usage: banger internal packages [--docker]"), RunE: func(cmd *cobra.Command, args []string) error { var packages []string switch strings.TrimSpace(args[0]) { case "debian": packages = imagepreset.DebianBasePackages() if docker { packages = append(packages, "docker.io") } case "void": packages = imagepreset.VoidBasePackages() case "alpine": packages = imagepreset.AlpineBasePackages() default: return fmt.Errorf("unknown package preset %q", args[0]) } for _, pkg := range packages { if _, err := fmt.Fprintln(cmd.OutOrStdout(), pkg); err != nil { return err } } return nil }, } cmd.Flags().BoolVar(&docker, "docker", false, "include docker-specific additions") return cmd } func newInternalWorkSeedCommand() *cobra.Command { var rootfsPath string var outPath string cmd := &cobra.Command{ Use: "work-seed", Hidden: true, Args: noArgsUsage("usage: banger internal work-seed --rootfs [--out ]"), RunE: func(cmd *cobra.Command, args []string) error { rootfsPath = strings.TrimSpace(rootfsPath) outPath = strings.TrimSpace(outPath) if rootfsPath == "" { return errors.New("rootfs path is required") } if outPath == "" { outPath = system.WorkSeedPath(rootfsPath) } if err := system.EnsureSudo(cmd.Context()); err != nil { return err } return system.BuildWorkSeedImage(cmd.Context(), system.NewRunner(), rootfsPath, outPath) }, } cmd.Flags().StringVar(&rootfsPath, "rootfs", "", "rootfs image path") cmd.Flags().StringVar(&outPath, "out", "", "output work-seed image path") return cmd } func newInternalNATCommand() *cobra.Command { cmd := &cobra.Command{ Use: "nat", Hidden: true, RunE: helpNoArgs, } cmd.AddCommand( newInternalNATActionCommand("up", true), newInternalNATActionCommand("down", false), ) return cmd } func newInternalNATActionCommand(use string, enable bool) *cobra.Command { var guestIP string var tapDevice string cmd := &cobra.Command{ Use: use, Hidden: true, Args: noArgsUsage("usage: banger internal nat " + use + " --guest-ip --tap "), RunE: func(cmd *cobra.Command, args []string) error { guestIP = strings.TrimSpace(guestIP) tapDevice = strings.TrimSpace(tapDevice) if guestIP == "" { return errors.New("guest IP is required") } if tapDevice == "" { return errors.New("tap device is required") } if err := system.EnsureSudo(cmd.Context()); err != nil { return err } return hostnat.Ensure(cmd.Context(), system.NewRunner(), guestIP, tapDevice, enable) }, } cmd.Flags().StringVar(&guestIP, "guest-ip", "", "guest IPv4 address") cmd.Flags().StringVar(&tapDevice, "tap", "", "tap device name") return cmd } func newDaemonCommand() *cobra.Command { cmd := &cobra.Command{ Use: "daemon", Short: "Manage the banger daemon", RunE: helpNoArgs, } cmd.AddCommand( &cobra.Command{ Use: "status", Short: "Show daemon status", Args: noArgsUsage("usage: banger daemon status"), RunE: func(cmd *cobra.Command, args []string) error { layout, err := paths.Resolve() if err != nil { return err } cfg, err := config.Load(layout) if err != nil { return err } ping, pingErr := rpc.Call[api.PingResult](cmd.Context(), layout.SocketPath, "ping", api.Empty{}) if pingErr != nil { if strings.TrimSpace(cfg.WebListenAddr) != "" { _, err = fmt.Fprintf(cmd.OutOrStdout(), "stopped\nsocket: %s\nlog: %s\ndns: %s\nweb: http://%s\n", layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr, cfg.WebListenAddr) return err } _, err = fmt.Fprintf(cmd.OutOrStdout(), "stopped\nsocket: %s\nlog: %s\ndns: %s\n", layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr) return err } if strings.TrimSpace(ping.WebURL) != "" { _, err = fmt.Fprintf(cmd.OutOrStdout(), "running\npid: %d\nsocket: %s\nlog: %s\ndns: %s\nweb: %s\n", ping.PID, layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr, ping.WebURL) return err } _, err = fmt.Fprintf(cmd.OutOrStdout(), "running\npid: %d\nsocket: %s\nlog: %s\ndns: %s\n", ping.PID, layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr) return err }, }, &cobra.Command{ Use: "stop", Short: "Stop the daemon", Args: noArgsUsage("usage: banger daemon stop"), RunE: func(cmd *cobra.Command, args []string) error { if err := system.EnsureSudo(cmd.Context()); err != nil { return err } layout, err := paths.Resolve() if err != nil { return err } _, err = rpc.Call[api.ShutdownResult](cmd.Context(), layout.SocketPath, "shutdown", api.Empty{}) if err != nil { if os.IsNotExist(err) || strings.Contains(err.Error(), "connect") { _, writeErr := fmt.Fprintln(cmd.OutOrStdout(), "daemon not running") return writeErr } return err } _, err = fmt.Fprintln(cmd.OutOrStdout(), "stopping") return err }, }, &cobra.Command{ Use: "socket", Short: "Print the daemon socket path", Args: noArgsUsage("usage: banger daemon socket"), RunE: func(cmd *cobra.Command, args []string) error { layout, err := paths.Resolve() if err != nil { return err } _, err = fmt.Fprintln(cmd.OutOrStdout(), layout.SocketPath) return err }, }, ) return cmd } func 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"), newVMSetCommand(), newVMSSHCommand(), newVMLogsCommand(), newVMStatsCommand(), newVMPortsCommand(), ) return cmd } func newVMRunCommand() *cobra.Command { var ( name string imageName string vcpu = model.DefaultVCPUCount memory = model.DefaultMemoryMiB systemOverlaySize = model.FormatSizeBytes(model.DefaultSystemOverlaySize) workDiskSize = model.FormatSizeBytes(model.DefaultWorkDiskSize) natEnabled bool branchName string fromRef = "HEAD" ) cmd := &cobra.Command{ Use: "run [path]", Short: "Create a repo-backed VM session and attach opencode", Args: maxArgsUsage(1, "usage: banger vm run [path]"), 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") } sourcePath := "" if len(args) == 1 { sourcePath = args[0] } spec, err := inspectVMRunRepo(cmd.Context(), sourcePath, branchName, fromRef) if err != nil { return err } layout, err := paths.Resolve() if err != nil { return err } cfg, err := config.Load(layout) if err != nil { return err } if err := validateVMRunPrereqs(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 = ensureDaemon(cmd.Context()) if err != nil { return err } return runVMRun(cmd.Context(), layout.SocketPath, cfg, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), params, spec) }, } cmd.Flags().StringVar(&name, "name", "", "vm name") cmd.Flags().StringVar(&imageName, "image", "", "image name or id") cmd.Flags().IntVar(&vcpu, "vcpu", model.DefaultVCPUCount, "vcpu count") cmd.Flags().IntVar(&memory, "memory", model.DefaultMemoryMiB, "memory in MiB") cmd.Flags().StringVar(&systemOverlaySize, "system-overlay-size", model.FormatSizeBytes(model.DefaultSystemOverlaySize), "system overlay size") cmd.Flags().StringVar(&workDiskSize, "disk-size", model.FormatSizeBytes(model.DefaultWorkDiskSize), "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") return cmd } func 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|...] ..."), RunE: func(cmd *cobra.Command, args []string) error { if err := system.EnsureSudo(cmd.Context()); err != nil { return err } layout, _, err := 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 newVMCreateCommand() *cobra.Command { var ( name string imageName string vcpu = model.DefaultVCPUCount memory = model.DefaultMemoryMiB systemOverlaySize = model.FormatSizeBytes(model.DefaultSystemOverlaySize) workDiskSize = model.FormatSizeBytes(model.DefaultWorkDiskSize) 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 := ensureDaemon(cmd.Context()) if err != nil { return err } vm, err := 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") cmd.Flags().IntVar(&vcpu, "vcpu", model.DefaultVCPUCount, "vcpu count") cmd.Flags().IntVar(&memory, "memory", model.DefaultMemoryMiB, "memory in MiB") cmd.Flags().StringVar(&systemOverlaySize, "system-overlay-size", model.FormatSizeBytes(model.DefaultSystemOverlaySize), "system overlay size") cmd.Flags().StringVar(&workDiskSize, "disk-size", model.FormatSizeBytes(model.DefaultWorkDiskSize), "work disk size") cmd.Flags().BoolVar(&natEnabled, "nat", false, "enable NAT") cmd.Flags().BoolVar(&noStart, "no-start", false, "create without starting") return cmd } func newVMListCommand() *cobra.Command { return &cobra.Command{ Use: "list", Short: "List VMs", Args: noArgsUsage("usage: banger vm list"), RunE: func(cmd *cobra.Command, args []string) error { layout, _, err := 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 } images, err := rpc.Call[api.ImageListResult](cmd.Context(), layout.SocketPath, "image.list", api.Empty{}) if err != nil { return err } return printVMListTable(cmd.OutOrStdout(), result.VMs, imageNameIndex(images.Images)) }, } } func newVMShowCommand() *cobra.Command { return &cobra.Command{ Use: "show ", Short: "Show VM details", Args: exactArgsUsage(1, "usage: banger vm show "), RunE: func(cmd *cobra.Command, args []string) error { layout, _, err := 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 newVMActionCommand(use, short, method string) *cobra.Command { return &cobra.Command{ Use: use + " ...", Short: short, Args: minArgsUsage(1, fmt.Sprintf("usage: banger vm %s ...", use)), RunE: func(cmd *cobra.Command, args []string) error { if err := system.EnsureSudo(cmd.Context()); err != nil { return err } layout, _, err := 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 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] ..."), 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 := 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 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...]"), RunE: func(cmd *cobra.Command, args []string) error { layout, cfg, err := ensureDaemon(cmd.Context()) if err != nil { return err } if err := validateSSHPrereqs(cfg); err != nil { return err } result, err := rpc.Call[api.VMSSHResult](cmd.Context(), layout.SocketPath, "vm.ssh", api.VMRefParams{IDOrName: args[0]}) if err != nil { return err } sshArgs, err := sshCommandArgs(cfg, result.GuestIP, args[1:]) if err != nil { return err } return runSSHSession(cmd.Context(), layout.SocketPath, result.Name, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), sshArgs) }, } } func newVMLogsCommand() *cobra.Command { var follow bool cmd := &cobra.Command{ Use: "logs ", Short: "Show VM logs", Args: exactArgsUsage(1, "usage: banger vm logs [-f] "), RunE: func(cmd *cobra.Command, args []string) error { layout, _, err := 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 newVMStatsCommand() *cobra.Command { return &cobra.Command{ Use: "stats ", Short: "Show VM stats", Args: exactArgsUsage(1, "usage: banger vm stats "), RunE: func(cmd *cobra.Command, args []string) error { layout, _, err := 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 newVMPortsCommand() *cobra.Command { return &cobra.Command{ Use: "ports ", Short: "Show host-reachable listening guest ports", Args: exactArgsUsage(1, "usage: banger vm ports "), RunE: func(cmd *cobra.Command, args []string) error { layout, _, err := ensureDaemon(cmd.Context()) if err != nil { return err } result, err := vmPortsFunc(cmd.Context(), layout.SocketPath, args[0]) if err != nil { return err } return printVMPortsTable(cmd.OutOrStdout(), result) }, } } func newImageCommand() *cobra.Command { cmd := &cobra.Command{ Use: "image", Short: "Manage images", RunE: helpNoArgs, } cmd.AddCommand( newImageBuildCommand(), newImageRegisterCommand(), newImagePromoteCommand(), newImageListCommand(), newImageShowCommand(), newImageDeleteCommand(), ) return cmd } func newImageBuildCommand() *cobra.Command { var params api.ImageBuildParams cmd := &cobra.Command{ Use: "build", Short: "Build an image", Args: noArgsUsage("usage: banger image build"), RunE: func(cmd *cobra.Command, args []string) error { if err := absolutizeImageBuildPaths(¶ms); err != nil { return err } if err := system.EnsureSudo(cmd.Context()); err != nil { return err } layout, _, err := ensureDaemon(cmd.Context()) if err != nil { return err } result, err := rpc.Call[api.ImageShowResult](cmd.Context(), layout.SocketPath, "image.build", params) if err != nil { return err } return printImageSummary(cmd.OutOrStdout(), result.Image) }, } cmd.Flags().StringVar(¶ms.Name, "name", "", "image name") cmd.Flags().StringVar(¶ms.FromImage, "from-image", "", "registered base image id or name") cmd.Flags().StringVar(¶ms.Size, "size", "", "output image size") cmd.Flags().StringVar(¶ms.KernelPath, "kernel", "", "kernel path") cmd.Flags().StringVar(¶ms.InitrdPath, "initrd", "", "initrd path") cmd.Flags().StringVar(¶ms.ModulesDir, "modules", "", "modules dir") cmd.Flags().BoolVar(¶ms.Docker, "docker", false, "install docker") return cmd } func newImageRegisterCommand() *cobra.Command { var params api.ImageRegisterParams cmd := &cobra.Command{ Use: "register", Short: "Register or update an unmanaged image", Args: noArgsUsage("usage: banger image register --name --rootfs [--work-seed ] --kernel [--initrd ] [--modules ]"), RunE: func(cmd *cobra.Command, args []string) error { if err := absolutizeImageRegisterPaths(¶ms); err != nil { return err } if err := system.EnsureSudo(cmd.Context()); err != nil { return err } layout, _, err := ensureDaemon(cmd.Context()) if err != nil { return err } result, err := rpc.Call[api.ImageShowResult](cmd.Context(), layout.SocketPath, "image.register", params) if err != nil { return err } return printImageSummary(cmd.OutOrStdout(), result.Image) }, } cmd.Flags().StringVar(¶ms.Name, "name", "", "image name") cmd.Flags().StringVar(¶ms.RootfsPath, "rootfs", "", "rootfs path") cmd.Flags().StringVar(¶ms.WorkSeedPath, "work-seed", "", "work-seed path") cmd.Flags().StringVar(¶ms.KernelPath, "kernel", "", "kernel path") cmd.Flags().StringVar(¶ms.InitrdPath, "initrd", "", "initrd path") cmd.Flags().StringVar(¶ms.ModulesDir, "modules", "", "modules dir") cmd.Flags().BoolVar(¶ms.Docker, "docker", false, "mark image as docker-prepared") return cmd } func newImagePromoteCommand() *cobra.Command { return &cobra.Command{ Use: "promote ", Short: "Promote an unmanaged image to a managed artifact", Args: exactArgsUsage(1, "usage: banger image promote "), RunE: func(cmd *cobra.Command, args []string) error { if err := system.EnsureSudo(cmd.Context()); err != nil { return err } layout, _, err := ensureDaemon(cmd.Context()) if err != nil { return err } result, err := rpc.Call[api.ImageShowResult](cmd.Context(), layout.SocketPath, "image.promote", api.ImageRefParams{IDOrName: args[0]}) if err != nil { return err } return printImageSummary(cmd.OutOrStdout(), result.Image) }, } } func newImageListCommand() *cobra.Command { return &cobra.Command{ Use: "list", Short: "List images", Args: noArgsUsage("usage: banger image list"), RunE: func(cmd *cobra.Command, args []string) error { layout, _, err := ensureDaemon(cmd.Context()) if err != nil { return err } result, err := rpc.Call[api.ImageListResult](cmd.Context(), layout.SocketPath, "image.list", api.Empty{}) if err != nil { return err } return printImageListTable(cmd.OutOrStdout(), result.Images) }, } } func newImageShowCommand() *cobra.Command { return &cobra.Command{ Use: "show ", Short: "Show image details", Args: exactArgsUsage(1, "usage: banger image show "), RunE: func(cmd *cobra.Command, args []string) error { layout, _, err := ensureDaemon(cmd.Context()) if err != nil { return err } result, err := rpc.Call[api.ImageShowResult](cmd.Context(), layout.SocketPath, "image.show", api.ImageRefParams{IDOrName: args[0]}) if err != nil { return err } return printJSON(cmd.OutOrStdout(), result.Image) }, } } func newImageDeleteCommand() *cobra.Command { return &cobra.Command{ Use: "delete ", Short: "Delete an image", Args: exactArgsUsage(1, "usage: banger image delete "), RunE: func(cmd *cobra.Command, args []string) error { if err := system.EnsureSudo(cmd.Context()); err != nil { return err } layout, _, err := ensureDaemon(cmd.Context()) if err != nil { return err } result, err := rpc.Call[api.ImageShowResult](cmd.Context(), layout.SocketPath, "image.delete", api.ImageRefParams{IDOrName: args[0]}) if err != nil { return err } return printImageSummary(cmd.OutOrStdout(), result.Image) }, } } func helpNoArgs(cmd *cobra.Command, args []string) error { if len(args) != 0 { return fmt.Errorf("unknown arguments: %s", strings.Join(args, " ")) } return cmd.Help() } func noArgsUsage(usage string) cobra.PositionalArgs { return func(cmd *cobra.Command, args []string) error { if len(args) != 0 { return errors.New(usage) } return nil } } func exactArgsUsage(n int, usage string) cobra.PositionalArgs { return func(cmd *cobra.Command, args []string) error { if len(args) != n { return errors.New(usage) } return nil } } func minArgsUsage(n int, usage string) cobra.PositionalArgs { return func(cmd *cobra.Command, args []string) error { if len(args) < n { return errors.New(usage) } return nil } } func maxArgsUsage(n int, usage string) cobra.PositionalArgs { return func(cmd *cobra.Command, args []string) error { if len(args) > n { return errors.New(usage) } return nil } } 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 ensureDaemon(ctx context.Context) (paths.Layout, model.DaemonConfig, error) { layout, err := paths.Resolve() if err != nil { return paths.Layout{}, model.DaemonConfig{}, err } cfg, err := config.Load(layout) if err != nil { return paths.Layout{}, model.DaemonConfig{}, err } if ping, err := rpc.Call[api.PingResult](ctx, layout.SocketPath, "ping", api.Empty{}); err == nil { if daemonOutdated(ping.PID) { if err := restartDaemon(ctx, layout, ping.PID); err != nil { return paths.Layout{}, model.DaemonConfig{}, err } return layout, cfg, nil } return layout, cfg, nil } if err := startDaemon(ctx, layout); err != nil { return paths.Layout{}, model.DaemonConfig{}, err } return layout, cfg, nil } func daemonOutdated(pid int) bool { if pid <= 0 { return false } daemonBin, err := bangerdPathFunc() if err != nil { return false } currentInfo, err := os.Stat(daemonBin) if err != nil { return false } runningInfo, err := os.Stat(daemonExePath(pid)) if err != nil { return false } return !os.SameFile(currentInfo, runningInfo) } func restartDaemon(ctx context.Context, layout paths.Layout, pid int) error { stopCtx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() _, _ = rpc.Call[api.ShutdownResult](stopCtx, layout.SocketPath, "shutdown", api.Empty{}) if waitForPIDExit(pid, 2*time.Second) { return startDaemon(ctx, layout) } if proc, err := os.FindProcess(pid); err == nil { _ = proc.Signal(syscall.SIGTERM) } if !waitForPIDExit(pid, 2*time.Second) { return fmt.Errorf("timed out restarting stale daemon pid %d", pid) } return startDaemon(ctx, layout) } func waitForPIDExit(pid int, timeout time.Duration) bool { deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { if !pidRunning(pid) { return true } time.Sleep(50 * time.Millisecond) } return !pidRunning(pid) } func pidRunning(pid int) bool { if pid <= 0 { return false } proc, err := os.FindProcess(pid) if err != nil { return false } return proc.Signal(syscall.Signal(0)) == nil } func startDaemon(ctx context.Context, layout paths.Layout) error { if err := paths.Ensure(layout); err != nil { return err } logFile, err := os.OpenFile(layout.DaemonLog, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { return err } defer logFile.Close() daemonBin, err := paths.BangerdPath() if err != nil { return err } cmd := buildDaemonCommand(daemonBin) cmd.Stdout = logFile cmd.Stderr = logFile cmd.Stdin = nil cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} if err := cmd.Start(); err != nil { return err } if err := rpc.WaitForSocket(layout.SocketPath, 5*time.Second); err != nil { return fmt.Errorf("daemon failed to start; inspect %s: %w", layout.DaemonLog, err) } return nil } func buildDaemonCommand(daemonBin string) *exec.Cmd { return exec.Command(daemonBin) } 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) { params := api.VMCreateParams{ Name: name, ImageName: imageName, NATEnabled: natEnabled, NoStart: noStart, } if cmd.Flags().Changed("vcpu") { if err := validatePositiveSetting("vcpu", vcpu); err != nil { return api.VMCreateParams{}, err } params.VCPUCount = &vcpu } if cmd.Flags().Changed("memory") { if err := validatePositiveSetting("memory", memory); err != nil { return api.VMCreateParams{}, err } params.MemoryMiB = &memory } if cmd.Flags().Changed("system-overlay-size") { params.SystemOverlaySize = systemOverlaySize } if cmd.Flags().Changed("disk-size") { params.WorkDiskSize = workDiskSize } return params, nil } func validatePositiveSetting(label string, value int) error { if value <= 0 { return fmt.Errorf("%s must be a positive integer", label) } return nil } func runSSHSession(ctx context.Context, socketPath, vmRef string, stdin io.Reader, stdout, stderr io.Writer, sshArgs []string) error { sshErr := sshExecFunc(ctx, stdin, stdout, stderr, sshArgs) if !shouldCheckSSHReminder(sshErr) || ctx.Err() != nil { return sshErr } pingCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() health, err := vmHealthFunc(pingCtx, socketPath, vmRef) if err != nil { _, _ = fmt.Fprintln(stderr, vsockagent.WarningMessage(vmRef, err)) return sshErr } if health.Healthy { name := health.Name if strings.TrimSpace(name) == "" { name = vmRef } _, _ = fmt.Fprintln(stderr, vsockagent.ReminderMessage(name)) } return sshErr } func shouldCheckSSHReminder(err error) bool { if err == nil { return true } var exitErr *exec.ExitError if !errors.As(err, &exitErr) { return false } return exitErr.ExitCode() != 255 } func sshCommandArgs(cfg model.DaemonConfig, guestIP string, extra []string) ([]string, error) { if guestIP == "" { return nil, errors.New("vm has no guest IP") } args := []string{} args = append(args, "-F", "/dev/null") if cfg.SSHKeyPath != "" { args = append(args, "-i", cfg.SSHKeyPath) } args = append( args, "-o", "IdentitiesOnly=yes", "-o", "BatchMode=yes", "-o", "PreferredAuthentications=publickey", "-o", "PasswordAuthentication=no", "-o", "KbdInteractiveAuthentication=no", "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "root@"+guestIP, ) args = append(args, extra...) return args, nil } func validateSSHPrereqs(cfg model.DaemonConfig) error { checks := system.NewPreflight() checks.RequireCommand("ssh", "install openssh-client") if strings.TrimSpace(cfg.SSHKeyPath) != "" { checks.RequireFile(cfg.SSHKeyPath, "ssh private key", `set "ssh_key_path" or let banger create its default key`) } return checks.Err("ssh preflight failed") } func validateVMRunPrereqs(cfg model.DaemonConfig) error { checks := system.NewPreflight() checks.RequireCommand("git", "install git") checks.RequireCommand("opencode", "install opencode") if strings.TrimSpace(cfg.SSHKeyPath) != "" { checks.RequireFile(cfg.SSHKeyPath, "ssh private key", `set "ssh_key_path" or let banger create its default key`) } return checks.Err("vm run preflight failed") } func inspectVMRunRepo(ctx context.Context, rawPath, branchName, fromRef string) (vmRunRepoSpec, error) { sourcePath, err := resolveVMRunSourcePath(rawPath) if err != nil { return vmRunRepoSpec{}, err } repoRoot, err := gitTrimmedOutput(ctx, sourcePath, "rev-parse", "--show-toplevel") if err != nil { return vmRunRepoSpec{}, fmt.Errorf("%s is not inside a git repository", sourcePath) } isBare, err := gitTrimmedOutput(ctx, repoRoot, "rev-parse", "--is-bare-repository") if err != nil { return vmRunRepoSpec{}, fmt.Errorf("inspect git repository %s: %w", repoRoot, err) } if isBare == "true" { return vmRunRepoSpec{}, fmt.Errorf("vm run requires a non-bare git repository: %s", repoRoot) } if err := ensureVMRunRepoHasNoSubmodules(ctx, repoRoot); err != nil { return vmRunRepoSpec{}, err } headCommit, err := gitTrimmedOutput(ctx, repoRoot, "rev-parse", "HEAD^{commit}") if err != nil { return vmRunRepoSpec{}, fmt.Errorf("git repository %s must have at least one commit", repoRoot) } currentBranch, err := gitTrimmedOutput(ctx, repoRoot, "branch", "--show-current") if err != nil { return vmRunRepoSpec{}, fmt.Errorf("resolve current branch for %s: %w", repoRoot, err) } baseCommit := headCommit branchName = strings.TrimSpace(branchName) if branchName != "" { fromRef = strings.TrimSpace(fromRef) if fromRef == "" { return vmRunRepoSpec{}, errors.New("--from cannot be empty") } baseCommit, err = gitTrimmedOutput(ctx, repoRoot, "rev-parse", fromRef+"^{commit}") if err != nil { return vmRunRepoSpec{}, fmt.Errorf("resolve --from %q: %w", fromRef, err) } } overlayPaths, err := listVMRunOverlayPaths(ctx, repoRoot) if err != nil { return vmRunRepoSpec{}, err } return vmRunRepoSpec{ SourcePath: sourcePath, RepoRoot: repoRoot, RepoName: filepath.Base(repoRoot), HeadCommit: headCommit, CurrentBranch: currentBranch, BranchName: branchName, BaseCommit: baseCommit, OverlayPaths: overlayPaths, }, nil } func resolveVMRunSourcePath(rawPath string) (string, error) { if strings.TrimSpace(rawPath) == "" { wd, err := cwdFunc() if err != nil { return "", err } rawPath = wd } absPath, err := filepath.Abs(rawPath) if err != nil { return "", err } info, err := os.Stat(absPath) if err != nil { return "", err } if !info.IsDir() { return "", fmt.Errorf("%s is not a directory", absPath) } return absPath, nil } func ensureVMRunRepoHasNoSubmodules(ctx context.Context, repoRoot string) error { output, err := gitOutput(ctx, repoRoot, "ls-files", "--stage", "-z") if err != nil { return fmt.Errorf("inspect git index for %s: %w", repoRoot, err) } for _, record := range parseNullSeparatedOutput(output) { if strings.HasPrefix(record, "160000 ") { return fmt.Errorf("vm run does not yet support git submodules: %s", repoRoot) } } return nil } func listVMRunOverlayPaths(ctx context.Context, repoRoot string) ([]string, error) { trackedOutput, err := gitOutput(ctx, repoRoot, "ls-files", "-z") if err != nil { return nil, fmt.Errorf("list tracked files for %s: %w", repoRoot, err) } untrackedOutput, err := gitOutput(ctx, repoRoot, "ls-files", "--others", "--exclude-standard", "-z") if err != nil { return nil, fmt.Errorf("list untracked files for %s: %w", repoRoot, err) } paths := make([]string, 0) seen := make(map[string]struct{}) for _, relPath := range parseNullSeparatedOutput(trackedOutput) { if relPath == "" { continue } if _, err := os.Lstat(filepath.Join(repoRoot, relPath)); err != nil { if os.IsNotExist(err) { continue } return nil, err } seen[relPath] = struct{}{} paths = append(paths, relPath) } for _, relPath := range parseNullSeparatedOutput(untrackedOutput) { if relPath == "" { continue } if _, ok := seen[relPath]; ok { continue } seen[relPath] = struct{}{} paths = append(paths, relPath) } sort.Strings(paths) return paths, nil } func gitOutput(ctx context.Context, dir string, args ...string) ([]byte, error) { fullArgs := make([]string, 0, len(args)+2) if strings.TrimSpace(dir) != "" { fullArgs = append(fullArgs, "-C", dir) } fullArgs = append(fullArgs, args...) return hostCommandOutputFunc(ctx, "git", fullArgs...) } func gitTrimmedOutput(ctx context.Context, dir string, args ...string) (string, error) { output, err := gitOutput(ctx, dir, args...) if err != nil { return "", err } return strings.TrimSpace(string(output)), nil } func parseNullSeparatedOutput(output []byte) []string { chunks := bytes.Split(output, []byte{0}) values := make([]string, 0, len(chunks)) for _, chunk := range chunks { value := strings.TrimSpace(string(chunk)) if value == "" { continue } values = append(values, value) } return values } func runVMRun(ctx context.Context, socketPath string, cfg model.DaemonConfig, stdin io.Reader, stdout, stderr io.Writer, params api.VMCreateParams, spec vmRunRepoSpec) error { vm, err := runVMCreate(ctx, socketPath, stderr, params) if err != nil { return err } vmRef := strings.TrimSpace(vm.Name) if vmRef == "" { vmRef = shortID(vm.ID) } sshAddress := net.JoinHostPort(vm.Runtime.GuestIP, "22") if err := guestWaitForSSHFunc(ctx, sshAddress, cfg.SSHKeyPath, 250*time.Millisecond); err != nil { return fmt.Errorf("vm %q is running but guest ssh is unavailable: %w", vmRef, err) } client, err := guestDialFunc(ctx, sshAddress, cfg.SSHKeyPath) if err != nil { return fmt.Errorf("vm %q is running but guest ssh is unavailable: %w", vmRef, err) } defer client.Close() if err := importVMRunRepoToGuest(ctx, client, spec); err != nil { return fmt.Errorf("vm %q is running but repo import failed: %w", vmRef, err) } if err := runVMRunAttach(ctx, stdin, stdout, stderr, vm.Runtime.GuestIP, vmRunGuestDir(spec.RepoName)); err != nil { return fmt.Errorf("vm %q is running but opencode attach failed: %w", vmRef, err) } return nil } func importVMRunRepoToGuest(ctx context.Context, client vmRunGuestClient, spec vmRunRepoSpec) error { bundleData, err := createVMRunBundle(ctx, spec) if err != nil { return err } var uploadLog bytes.Buffer if err := client.UploadFile(ctx, vmRunGuestBundlePath, 0o600, bundleData, &uploadLog); err != nil { return formatVMRunStepError("upload git bundle", err, uploadLog.String()) } var scriptLog bytes.Buffer if err := client.RunScript(ctx, vmRunCloneScript(spec), &scriptLog); err != nil { return formatVMRunStepError("prepare guest checkout", err, scriptLog.String()) } var overlayLog bytes.Buffer remoteCommand := fmt.Sprintf("tar -o -C %s --strip-components=1 -xf -", shellQuote(vmRunGuestDir(spec.RepoName))) if err := client.StreamTarEntries(ctx, spec.RepoRoot, spec.OverlayPaths, remoteCommand, &overlayLog); err != nil { return formatVMRunStepError("overlay host working tree", err, overlayLog.String()) } return nil } func createVMRunBundle(ctx context.Context, spec vmRunRepoSpec) ([]byte, error) { tempFile, err := os.CreateTemp("", "banger-vm-run-*.bundle") if err != nil { return nil, err } tempPath := tempFile.Name() if err := tempFile.Close(); err != nil { _ = os.Remove(tempPath) return nil, err } defer os.Remove(tempPath) args := []string{"-C", spec.RepoRoot, "bundle", "create", tempPath, "--all"} for _, rev := range uniqueNonEmptyStrings(spec.HeadCommit, spec.BaseCommit) { args = append(args, rev) } if _, err := hostCommandOutputFunc(ctx, "git", args...); err != nil { return nil, fmt.Errorf("create git bundle: %w", err) } data, err := os.ReadFile(tempPath) if err != nil { return nil, fmt.Errorf("read git bundle: %w", err) } return data, nil } func vmRunCloneScript(spec vmRunRepoSpec) string { guestDir := vmRunGuestDir(spec.RepoName) var script strings.Builder script.WriteString("set -euo pipefail\n") fmt.Fprintf(&script, "DIR=%s\n", shellQuote(guestDir)) fmt.Fprintf(&script, "BUNDLE=%s\n", shellQuote(vmRunGuestBundlePath)) script.WriteString("rm -rf \"$DIR\"\n") script.WriteString("git clone \"$BUNDLE\" \"$DIR\"\n") script.WriteString("rm -f \"$BUNDLE\"\n") switch { case strings.TrimSpace(spec.BranchName) != "": fmt.Fprintf(&script, "git -C \"$DIR\" checkout -B %s %s\n", shellQuote(spec.BranchName), shellQuote(spec.BaseCommit)) case strings.TrimSpace(spec.CurrentBranch) != "": fmt.Fprintf(&script, "git -C \"$DIR\" checkout -B %s %s\n", shellQuote(spec.CurrentBranch), shellQuote(spec.HeadCommit)) default: fmt.Fprintf(&script, "git -C \"$DIR\" checkout --detach %s\n", shellQuote(spec.HeadCommit)) } script.WriteString("find \"$DIR\" -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} +\n") script.WriteString("git config --global --add safe.directory \"$DIR\"\n") return script.String() } func vmRunGuestDir(repoName string) string { return filepath.ToSlash(filepath.Join("/root", repoName)) } func runVMRunAttach(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, guestIP, guestDir string) error { guestIP = strings.TrimSpace(guestIP) if guestIP == "" { return errors.New("vm has no guest IP") } return opencodeExecFunc(ctx, stdin, stdout, stderr, []string{ "attach", "--dir", guestDir, "http://" + net.JoinHostPort(guestIP, "4096"), }) } func formatVMRunStepError(action string, err error, log string) error { log = strings.TrimSpace(log) if log == "" { return fmt.Errorf("%s: %w", action, err) } return fmt.Errorf("%s: %w: %s", action, err, log) } func uniqueNonEmptyStrings(values ...string) []string { unique := make([]string, 0, len(values)) seen := make(map[string]struct{}, len(values)) for _, value := range values { value = strings.TrimSpace(value) if value == "" { continue } if _, ok := seen[value]; ok { continue } seen[value] = struct{}{} unique = append(unique, value) } return unique } func shellQuote(value string) string { return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'" } func absolutizeImageBuildPaths(params *api.ImageBuildParams) error { return absolutizePaths(¶ms.KernelPath, ¶ms.InitrdPath, ¶ms.ModulesDir) } func absolutizeImageRegisterPaths(params *api.ImageRegisterParams) error { return absolutizePaths( ¶ms.RootfsPath, ¶ms.WorkSeedPath, ¶ms.KernelPath, ¶ms.InitrdPath, ¶ms.ModulesDir, ) } func absolutizePaths(values ...*string) error { var err error for _, value := range values { if *value == "" || filepath.IsAbs(*value) { continue } *value, err = filepath.Abs(*value) if err != nil { return err } } return nil } func printJSON(out anyWriter, v any) error { data, err := json.MarshalIndent(v, "", " ") if err != nil { return err } _, err = fmt.Fprintln(out, string(data)) return err } func printVMSummary(out anyWriter, vm model.VMRecord) error { _, err := fmt.Fprintf( out, "%s\t%s\t%s\t%s\t%s\t%s\n", shortID(vm.ID), vm.Name, vm.State, vm.Runtime.GuestIP, model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes), vm.Runtime.DNSName, ) return err } func printVMListTable(out anyWriter, vms []model.VMRecord, imageNames map[string]string) error { w := tabwriter.NewWriter(out, 0, 8, 2, ' ', 0) if _, err := fmt.Fprintln(w, "ID\tNAME\tSTATE\tIMAGE\tIP\tVCPU\tMEM\tDISK\tCREATED"); err != nil { return err } for _, vm := range vms { if _, err := fmt.Fprintf( w, "%s\t%s\t%s\t%s\t%s\t%d\t%d MiB\t%s\t%s\n", shortID(vm.ID), vm.Name, vm.State, vmImageLabel(vm.ImageID, imageNames), vm.Runtime.GuestIP, vm.Spec.VCPUCount, vm.Spec.MemoryMiB, model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes), relativeTime(vm.CreatedAt), ); err != nil { return err } } return w.Flush() } func printImageSummary(out anyWriter, image model.Image) error { _, err := fmt.Fprintf(out, "%s\t%s\t%t\t%s\n", shortID(image.ID), image.Name, image.Managed, image.RootfsPath) return err } func imageNameIndex(images []model.Image) map[string]string { index := make(map[string]string, len(images)) for _, image := range images { index[image.ID] = image.Name } return index } func vmImageLabel(imageID string, imageNames map[string]string) string { if name := strings.TrimSpace(imageNames[imageID]); name != "" { return name } return shortID(imageID) } func printImageListTable(out anyWriter, images []model.Image) error { w := tabwriter.NewWriter(out, 0, 8, 2, ' ', 0) if _, err := fmt.Fprintln(w, "ID\tNAME\tMANAGED\tROOTFS SIZE\tCREATED"); err != nil { return err } for _, image := range images { if _, err := fmt.Fprintf( w, "%s\t%s\t%t\t%s\t%s\n", shortID(image.ID), image.Name, image.Managed, rootfsSizeLabel(image.RootfsPath), relativeTime(image.CreatedAt), ); err != nil { return err } } return w.Flush() } func rootfsSizeLabel(path string) string { info, err := os.Stat(path) if err != nil { return "-" } if info.Size() <= 0 { return "0" } return model.FormatSizeBytes(info.Size()) } func printVMPortsTable(out anyWriter, result api.VMPortsResult) error { type portRow struct { Proto string Endpoint string Process string Command string Port int } rows := make([]portRow, 0, len(result.Ports)) for _, port := range result.Ports { rows = append(rows, portRow{ Proto: port.Proto, Endpoint: port.Endpoint, Process: port.Process, Command: port.Command, Port: port.Port, }) } sort.Slice(rows, func(i, j int) bool { if rows[i].Proto != rows[j].Proto { return rows[i].Proto < rows[j].Proto } if rows[i].Port != rows[j].Port { return rows[i].Port < rows[j].Port } if rows[i].Process != rows[j].Process { return rows[i].Process < rows[j].Process } return rows[i].Command < rows[j].Command }) if len(rows) == 0 { return nil } w := tabwriter.NewWriter(out, 0, 8, 2, ' ', 0) if _, err := fmt.Fprintln(w, "PROTO\tENDPOINT\tPROCESS\tCOMMAND"); err != nil { return err } for _, row := range rows { if _, err := fmt.Fprintf( w, "%s\t%s\t%s\t%s\n", row.Proto, emptyDash(row.Endpoint), emptyDash(row.Process), emptyDash(row.Command), ); err != nil { return err } } return w.Flush() } func printDoctorReport(out anyWriter, report system.Report) error { for _, check := range report.Checks { status := strings.ToUpper(string(check.Status)) if _, err := fmt.Fprintf(out, "%s\t%s\n", status, check.Name); err != nil { return err } for _, detail := range check.Details { if _, err := fmt.Fprintf(out, " - %s\n", detail); err != nil { return err } } } return nil } func emptyDash(value string) string { value = strings.TrimSpace(value) if value == "" { return "-" } return value } type anyWriter interface { Write(p []byte) (n int, err error) } func runVMCreate(ctx context.Context, socketPath string, stderr io.Writer, params api.VMCreateParams) (model.VMRecord, error) { begin, err := vmCreateBeginFunc(ctx, socketPath, params) if err != nil { return model.VMRecord{}, err } renderer := newVMCreateProgressRenderer(stderr) renderer.render(begin.Operation) op := begin.Operation for { if op.Done { renderer.render(op) if op.Success && op.VM != nil { return *op.VM, nil } if strings.TrimSpace(op.Error) == "" { return model.VMRecord{}, errors.New("vm create failed") } return model.VMRecord{}, errors.New(op.Error) } select { case <-ctx.Done(): cancelCtx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() _ = vmCreateCancelFunc(cancelCtx, socketPath, op.ID) return model.VMRecord{}, ctx.Err() case <-time.After(200 * time.Millisecond): } status, err := vmCreateStatusFunc(ctx, socketPath, op.ID) if err != nil { if ctx.Err() != nil { cancelCtx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() _ = vmCreateCancelFunc(cancelCtx, socketPath, op.ID) return model.VMRecord{}, ctx.Err() } return model.VMRecord{}, err } op = status.Operation renderer.render(op) } } type vmCreateProgressRenderer struct { out io.Writer enabled bool lastLine string } func newVMCreateProgressRenderer(out io.Writer) *vmCreateProgressRenderer { return &vmCreateProgressRenderer{ out: out, enabled: writerSupportsProgress(out), } } func (r *vmCreateProgressRenderer) render(op api.VMCreateOperation) { if r == nil || !r.enabled { return } line := formatVMCreateProgress(op) if line == "" || line == r.lastLine { return } r.lastLine = line _, _ = fmt.Fprintln(r.out, line) } func writerSupportsProgress(out io.Writer) bool { file, ok := out.(*os.File) if !ok { return false } info, err := file.Stat() if err != nil { return false } return info.Mode()&os.ModeCharDevice != 0 } func formatVMCreateProgress(op api.VMCreateOperation) string { stage := strings.TrimSpace(op.Stage) detail := strings.TrimSpace(op.Detail) label := vmCreateStageLabel(stage) if label == "" && detail == "" { return "" } if label == "" { return "[vm create] " + detail } if detail == "" { return "[vm create] " + label } return "[vm create] " + label + ": " + detail } func vmCreateStageLabel(stage string) string { switch strings.TrimSpace(stage) { case "queued": return "queued" case "resolve_image": return "resolving image" case "reserve_vm": return "allocating vm" case "preflight": return "checking host prerequisites" case "prepare_rootfs": return "preparing root filesystem" case "prepare_host_features": return "preparing host features" case "prepare_work_disk": return "preparing work disk" case "boot_firecracker": return "starting firecracker" case "wait_vsock_agent": return "waiting for vsock agent" case "wait_guest_ready": return "waiting for guest services" case "wait_opencode": return "waiting for opencode" case "apply_dns": return "publishing dns" case "apply_nat": return "configuring nat" case "finalize": return "finalizing" case "ready": return "ready" default: return strings.ReplaceAll(stage, "_", " ") } } func shortID(id string) string { if len(id) <= 12 { return id } return id[:12] } func relativeTime(t time.Time) string { if t.IsZero() { return "-" } delta := time.Since(t) switch { case delta < 30*time.Second: return "moments ago" case delta < time.Minute: return fmt.Sprintf("%d seconds ago", int(delta.Seconds())) case delta < 2*time.Minute: return "1 minute ago" case delta < time.Hour: return fmt.Sprintf("%d minutes ago", int(delta.Minutes())) case delta < 2*time.Hour: return "1 hour ago" case delta < 24*time.Hour: return fmt.Sprintf("%d hours ago", int(delta.Hours())) case delta < 48*time.Hour: return "1 day ago" case delta < 7*24*time.Hour: return fmt.Sprintf("%d days ago", int(delta.Hours()/24)) case delta < 14*24*time.Hour: return "1 week ago" default: return fmt.Sprintf("%d weeks ago", int(delta.Hours()/(24*7))) } }