banger/internal/cli/banger.go
Thales Maciel 4813e844e2
Bootstrap vm run tooling before attach
Speed up first use of repo backed VMs by bootstrapping obvious tools before
the best effort LLM harness runs.

Add a host side tooling plan for pinned Go, Node, Python, and Rust versions,
summarize that plan in the uploaded prompt, and run repo mise install plus
guest global mise use -g --pin steps before the bounded opencode inspection.

Keep the harness non fatal, prefer host opencode attach when the client
supports it, fall back to guest opencode over SSH for older clients, and
cover the new flow with CLI plus planner tests.

Validation:
- go test ./internal/cli ./internal/toolingplan
- GOCACHE=/tmp/banger-gocache go test ./...
- make build
2026-03-29 11:38:05 -03:00

2387 lines
74 KiB
Go

package cli
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/url"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"syscall"
"text/tabwriter"
"time"
"banger/internal/api"
"banger/internal/buildinfo"
"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/toolingplan"
"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()
}
hostOpencodeAttachSupportedFunc = hostOpencodeAttachSupported
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})
}
daemonPingFunc = func(ctx context.Context, socketPath string) (api.PingResult, error) {
return rpc.Call[api.PingResult](ctx, socketPath, "ping", api.Empty{})
}
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)
}
prepareVMRunRepoCopyFunc = prepareVMRunRepoCopy
buildVMRunToolingPlanFunc = toolingplan.Build
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
StreamTar(ctx context.Context, sourceDir, remoteCommand 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
OriginURL string
GitUserName string
GitUserEmail string
OverlayPaths []string
}
const vmRunShallowFetchDepth = 10
const vmRunToolingHarnessModel = "opencode/mimo-v2-pro-free"
const vmRunToolingHarnessTimeoutSeconds = 45
const vmRunToolingInstallTimeoutSeconds = 120
const vmRunToolingHarnessPrompt = `You are preparing a development VM for this repository.
Inspect the repository for developer tools and binaries that are clearly needed to work on it. Look at files like .mise.toml, .tool-versions, README/setup docs, CI config, task runners, scripts, and build manifests.
Rules:
- Use mise only for installs.
- Do not edit repository files.
- Prefer repo-declared versions first.
- If a tool is clearly required but not pinned, you may install a conservative guest-global tool with mise.
- Skip ambiguous installs instead of guessing.
- End with a short summary of what you installed and what you skipped.`
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(), newImageCommand(), newInternalCommand(), newVersionCommand(), newVMCommand())
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 newVersionCommand() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Show banger build information",
Args: noArgsUsage("usage: banger version"),
RunE: func(cmd *cobra.Command, args []string) error {
_, err := fmt.Fprint(cmd.OutOrStdout(), formatBuildInfoBlock(buildinfo.Current()))
return err
},
}
}
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 <debian|void|alpine>",
Hidden: true,
Args: exactArgsUsage(1, "usage: banger internal packages <debian|void|alpine> [--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 <path> [--out <path>]"),
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 <ip> --tap <tap-device>"),
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 := daemonPingFunc(cmd.Context(), layout.SocketPath)
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
}
info := buildinfo.Normalize(ping.Version, ping.Commit, ping.BuiltAt)
if strings.TrimSpace(ping.WebURL) != "" {
_, err = fmt.Fprintf(cmd.OutOrStdout(), "running\npid: %d\n%ssocket: %s\nlog: %s\ndns: %s\nweb: %s\n", ping.PID, formatBuildInfoBlock(info), layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr, ping.WebURL)
return err
}
_, err = fmt.Fprintf(cmd.OutOrStdout(), "running\npid: %d\n%ssocket: %s\nlog: %s\ndns: %s\n", ping.PID, formatBuildInfoBlock(info), 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 <id-or-name>...",
Short: "Send a signal to a VM process",
Args: minArgsUsage(1, "usage: banger vm kill [--signal SIGTERM|SIGKILL|...] <id-or-name>..."),
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 <id-or-name>",
Short: "Show VM details",
Args: exactArgsUsage(1, "usage: banger vm show <id-or-name>"),
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 + " <id-or-name>...",
Short: short,
Args: minArgsUsage(1, fmt.Sprintf("usage: banger vm %s <id-or-name>...", 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 <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>..."),
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 <id-or-name> [ssh args...]",
Short: "SSH into a running VM",
Args: minArgsUsage(1, "usage: banger vm ssh <id-or-name> [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 <id-or-name>",
Short: "Show VM logs",
Args: exactArgsUsage(1, "usage: banger vm logs [-f] <id-or-name>"),
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 <id-or-name>",
Short: "Show VM stats",
Args: exactArgsUsage(1, "usage: banger vm stats <id-or-name>"),
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 <id-or-name>",
Short: "Show host-reachable listening guest ports",
Args: exactArgsUsage(1, "usage: banger vm ports <id-or-name>"),
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(&params); 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(&params.Name, "name", "", "image name")
cmd.Flags().StringVar(&params.FromImage, "from-image", "", "registered base image id or name")
cmd.Flags().StringVar(&params.Size, "size", "", "output image size")
cmd.Flags().StringVar(&params.KernelPath, "kernel", "", "kernel path")
cmd.Flags().StringVar(&params.InitrdPath, "initrd", "", "initrd path")
cmd.Flags().StringVar(&params.ModulesDir, "modules", "", "modules dir")
cmd.Flags().BoolVar(&params.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 <name> --rootfs <path> [--work-seed <path>] --kernel <path> [--initrd <path>] [--modules <dir>]"),
RunE: func(cmd *cobra.Command, args []string) error {
if err := absolutizeImageRegisterPaths(&params); 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(&params.Name, "name", "", "image name")
cmd.Flags().StringVar(&params.RootfsPath, "rootfs", "", "rootfs path")
cmd.Flags().StringVar(&params.WorkSeedPath, "work-seed", "", "work-seed path")
cmd.Flags().StringVar(&params.KernelPath, "kernel", "", "kernel path")
cmd.Flags().StringVar(&params.InitrdPath, "initrd", "", "initrd path")
cmd.Flags().StringVar(&params.ModulesDir, "modules", "", "modules dir")
cmd.Flags().BoolVar(&params.Docker, "docker", false, "mark image as docker-prepared")
return cmd
}
func newImagePromoteCommand() *cobra.Command {
return &cobra.Command{
Use: "promote <id-or-name>",
Short: "Promote an unmanaged image to a managed artifact",
Args: exactArgsUsage(1, "usage: banger image promote <id-or-name>"),
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 <id-or-name>",
Short: "Show image details",
Args: exactArgsUsage(1, "usage: banger image show <id-or-name>"),
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 <id-or-name>",
Short: "Delete an image",
Args: exactArgsUsage(1, "usage: banger image delete <id-or-name>"),
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 := daemonPingFunc(ctx, layout.SocketPath); 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)
}
}
gitUserName, err := gitResolvedConfigValue(ctx, repoRoot, "user.name")
if err != nil {
return vmRunRepoSpec{}, fmt.Errorf("resolve git user.name for %s: %w", repoRoot, err)
}
gitUserEmail, err := gitResolvedConfigValue(ctx, repoRoot, "user.email")
if err != nil {
return vmRunRepoSpec{}, fmt.Errorf("resolve git user.email for %s: %w", repoRoot, err)
}
originURL, err := gitResolvedConfigValue(ctx, repoRoot, "remote.origin.url")
if err != nil {
return vmRunRepoSpec{}, fmt.Errorf("resolve origin url for %s: %w", repoRoot, 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,
OriginURL: originURL,
GitUserName: gitUserName,
GitUserEmail: gitUserEmail,
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 gitResolvedConfigValue(ctx context.Context, dir, key string) (string, error) {
return gitTrimmedOutput(ctx, dir, "config", "--default", "", "--get", key)
}
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 {
progress := newVMRunProgressRenderer(stderr)
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")
progress.render("waiting for guest ssh")
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, progress); err != nil {
return fmt.Errorf("vm %q is running but repo import failed: %w", vmRef, err)
}
if err := startVMRunToolingHarness(ctx, client, spec, progress); err != nil {
printVMRunWarning(stderr, fmt.Sprintf("tooling harness start failed: %v", err))
}
if err := runVMRunAttach(ctx, socketPath, vmRef, cfg, stdin, stdout, stderr, vm.Runtime.GuestIP, vmRunGuestDir(spec.RepoName), progress); 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, progress *vmRunProgressRenderer) error {
if progress != nil {
progress.render("preparing shallow repo")
}
repoCopyDir, cleanup, err := prepareVMRunRepoCopyFunc(ctx, spec)
if err != nil {
return err
}
defer cleanup()
if progress != nil {
progress.render("copying repo metadata to guest")
}
var copyLog bytes.Buffer
remoteCommand := fmt.Sprintf("rm -rf %s && mkdir -p %s && tar -o -C %s --strip-components=1 -xf -", shellQuote(vmRunGuestDir(spec.RepoName)), shellQuote(vmRunGuestDir(spec.RepoName)), shellQuote(vmRunGuestDir(spec.RepoName)))
if err := client.StreamTar(ctx, repoCopyDir, remoteCommand, &copyLog); err != nil {
return formatVMRunStepError("copy guest git metadata", err, copyLog.String())
}
if progress != nil {
progress.render("preparing guest checkout")
}
var scriptLog bytes.Buffer
if err := client.RunScript(ctx, vmRunCheckoutScript(spec), &scriptLog); err != nil {
return formatVMRunStepError("prepare guest checkout", err, scriptLog.String())
}
if progress != nil {
progress.render("overlaying host working tree")
}
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 prepareVMRunRepoCopy(ctx context.Context, spec vmRunRepoSpec) (string, func(), error) {
tempRoot, err := os.MkdirTemp("", "banger-vm-run-*")
if err != nil {
return "", nil, err
}
cleanup := func() {
_ = os.RemoveAll(tempRoot)
}
repoCopyDir := filepath.Join(tempRoot, spec.RepoName)
cloneArgs := []string{"clone", "--no-checkout", "--depth", fmt.Sprintf("%d", vmRunShallowFetchDepth)}
if strings.TrimSpace(spec.CurrentBranch) != "" {
cloneArgs = append(cloneArgs, "--single-branch", "--branch", spec.CurrentBranch)
}
cloneArgs = append(cloneArgs, gitFileURL(spec.RepoRoot), repoCopyDir)
if err := runHostCommand(ctx, "git", cloneArgs...); err != nil {
cleanup()
return "", nil, fmt.Errorf("clone shallow repo copy: %w", err)
}
checkoutCommit := vmRunCheckoutCommit(spec)
if err := runHostCommand(ctx, "git", "-C", repoCopyDir, "cat-file", "-e", checkoutCommit+"^{commit}"); err != nil {
if err := runHostCommand(ctx, "git", "-C", repoCopyDir, "fetch", "--depth", fmt.Sprintf("%d", vmRunShallowFetchDepth), gitFileURL(spec.RepoRoot), checkoutCommit); err != nil {
cleanup()
return "", nil, fmt.Errorf("fetch shallow repo commit %s: %w", checkoutCommit, err)
}
}
if strings.TrimSpace(spec.OriginURL) != "" {
if err := runHostCommand(ctx, "git", "-C", repoCopyDir, "remote", "set-url", "origin", spec.OriginURL); err != nil {
cleanup()
return "", nil, fmt.Errorf("set origin remote: %w", err)
}
} else {
if err := runHostCommand(ctx, "git", "-C", repoCopyDir, "remote", "remove", "origin"); err != nil {
cleanup()
return "", nil, fmt.Errorf("remove placeholder origin remote: %w", err)
}
}
return repoCopyDir, cleanup, nil
}
func vmRunCheckoutCommit(spec vmRunRepoSpec) string {
if strings.TrimSpace(spec.BranchName) != "" {
return spec.BaseCommit
}
return spec.HeadCommit
}
func gitFileURL(path string) string {
return (&url.URL{Scheme: "file", Path: filepath.ToSlash(path)}).String()
}
func runHostCommand(ctx context.Context, name string, args ...string) error {
_, err := hostCommandOutputFunc(ctx, name, args...)
return err
}
func vmRunCheckoutScript(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))
script.WriteString("git config --global --add safe.directory \"$DIR\"\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")
if strings.TrimSpace(spec.GitUserName) != "" && strings.TrimSpace(spec.GitUserEmail) != "" {
fmt.Fprintf(&script, "git -C \"$DIR\" config user.name %s\n", shellQuote(spec.GitUserName))
fmt.Fprintf(&script, "git -C \"$DIR\" config user.email %s\n", shellQuote(spec.GitUserEmail))
}
return script.String()
}
func vmRunGuestDir(repoName string) string {
return filepath.ToSlash(filepath.Join("/root", repoName))
}
func vmRunToolingHarnessPath(repoName string) string {
return filepath.ToSlash(filepath.Join("/tmp", "banger-vm-run-tooling-"+repoName+".sh"))
}
func vmRunToolingHarnessPromptPath(repoName string) string {
return filepath.ToSlash(filepath.Join("/tmp", "banger-vm-run-tooling-"+repoName+".prompt.txt"))
}
func vmRunToolingHarnessLogPath(repoName string) string {
return filepath.ToSlash(filepath.Join("/root/.cache/banger", "vm-run-tooling-"+repoName+".log"))
}
func startVMRunToolingHarness(ctx context.Context, client vmRunGuestClient, spec vmRunRepoSpec, progress *vmRunProgressRenderer) error {
if progress != nil {
progress.render("starting tooling harness")
}
plan := buildVMRunToolingPlanFunc(ctx, spec.RepoRoot)
var uploadLog bytes.Buffer
if err := client.UploadFile(ctx, vmRunToolingHarnessPromptPath(spec.RepoName), 0o644, []byte(vmRunToolingHarnessPromptData(plan)), &uploadLog); err != nil {
return formatVMRunStepError("upload tooling harness prompt", err, uploadLog.String())
}
uploadLog.Reset()
if err := client.UploadFile(ctx, vmRunToolingHarnessPath(spec.RepoName), 0o755, []byte(vmRunToolingHarnessScript(spec, plan)), &uploadLog); err != nil {
return formatVMRunStepError("upload tooling harness", err, uploadLog.String())
}
var launchLog bytes.Buffer
if err := client.RunScript(ctx, vmRunToolingHarnessLaunchScript(spec), &launchLog); err != nil {
return formatVMRunStepError("launch tooling harness", err, launchLog.String())
}
if progress != nil {
progress.render("tooling harness log: " + vmRunToolingHarnessLogPath(spec.RepoName))
}
return nil
}
func vmRunToolingHarnessPromptData(plan toolingplan.Plan) string {
var prompt strings.Builder
prompt.WriteString(vmRunToolingHarnessPrompt)
lines := make([]string, 0, len(plan.RepoManagedTools)+len(plan.Steps)+len(plan.Skips))
for _, tool := range plan.RepoManagedTools {
lines = append(lines, fmt.Sprintf("- Repo already declares %s through mise", tool))
}
for _, step := range plan.Steps {
lines = append(lines, fmt.Sprintf("- Planned deterministic install: %s@%s from %s", step.Tool, step.Version, step.Source))
}
for _, skip := range plan.Skips {
lines = append(lines, fmt.Sprintf("- Deterministic skip: %s (%s)", skip.Target, skip.Reason))
}
if len(lines) == 0 {
lines = append(lines, "- No deterministic prepass actions were planned")
}
prompt.WriteString("\n\nDeterministic prepass summary:\n")
prompt.WriteString(strings.Join(lines, "\n"))
prompt.WriteString("\n\nDo not repeat the deterministic prepass work unless it clearly failed. Focus on the remaining gaps.\n")
return prompt.String()
}
func vmRunToolingHarnessScript(spec vmRunRepoSpec, plan toolingplan.Plan) string {
var script strings.Builder
script.WriteString("set -uo pipefail\n")
fmt.Fprintf(&script, "DIR=%s\n", shellQuote(vmRunGuestDir(spec.RepoName)))
script.WriteString("export PATH=/usr/local/bin:/root/.local/share/mise/shims:$PATH\n")
script.WriteString("if [ -f /etc/profile.d/mise.sh ]; then . /etc/profile.d/mise.sh || true; fi\n")
script.WriteString("log() { printf '%s\\n' \"$*\"; }\n")
script.WriteString("run_best_effort() {\n")
script.WriteString(" \"$@\"\n")
script.WriteString(" rc=$?\n")
script.WriteString(" if [ \"$rc\" -ne 0 ]; then\n")
script.WriteString(" log \"command failed ($rc): $*\"\n")
script.WriteString(" fi\n")
script.WriteString(" return 0\n")
script.WriteString("}\n")
script.WriteString("run_bounded_best_effort() {\n")
script.WriteString(" timeout_secs=\"$1\"\n")
script.WriteString(" shift\n")
script.WriteString(" timeout_marker=\"$(mktemp)\"\n")
script.WriteString(" rm -f \"$timeout_marker\"\n")
script.WriteString(" \"$@\" &\n")
script.WriteString(" cmd_pid=$!\n")
script.WriteString(" (\n")
script.WriteString(" sleep \"$timeout_secs\"\n")
script.WriteString(" if kill -0 \"$cmd_pid\" 2>/dev/null; then\n")
script.WriteString(" : >\"$timeout_marker\"\n")
script.WriteString(" log \"command timed out after ${timeout_secs}s: $*\"\n")
script.WriteString(" kill -TERM \"$cmd_pid\" 2>/dev/null || true\n")
script.WriteString(" if command -v pkill >/dev/null 2>&1; then pkill -TERM -P \"$cmd_pid\" 2>/dev/null || true; fi\n")
script.WriteString(" sleep 2\n")
script.WriteString(" kill -KILL \"$cmd_pid\" 2>/dev/null || true\n")
script.WriteString(" if command -v pkill >/dev/null 2>&1; then pkill -KILL -P \"$cmd_pid\" 2>/dev/null || true; fi\n")
script.WriteString(" fi\n")
script.WriteString(" ) &\n")
script.WriteString(" watchdog_pid=$!\n")
script.WriteString(" wait \"$cmd_pid\"\n")
script.WriteString(" rc=$?\n")
script.WriteString(" kill \"$watchdog_pid\" 2>/dev/null || true\n")
script.WriteString(" wait \"$watchdog_pid\" 2>/dev/null || true\n")
script.WriteString(" if [ -f \"$timeout_marker\" ]; then\n")
script.WriteString(" rm -f \"$timeout_marker\"\n")
script.WriteString(" return 0\n")
script.WriteString(" fi\n")
script.WriteString(" rm -f \"$timeout_marker\"\n")
script.WriteString(" if [ \"$rc\" -ne 0 ]; then\n")
script.WriteString(" log \"command failed ($rc): $*\"\n")
script.WriteString(" fi\n")
script.WriteString(" return 0\n")
script.WriteString("}\n")
script.WriteString("cd \"$DIR\" || { log \"missing repo directory: $DIR\"; exit 0; }\n")
script.WriteString("MISE_BIN=\"$(command -v mise || true)\"\n")
script.WriteString("OPENCODE_BIN=\"$(command -v opencode || true)\"\n")
script.WriteString("if [ -z \"$MISE_BIN\" ]; then log \"mise not found; skipping tooling harness\"; exit 0; fi\n")
script.WriteString("if [ -z \"$OPENCODE_BIN\" ]; then log \"opencode not found; skipping tooling harness\"; exit 0; fi\n")
fmt.Fprintf(&script, "PROMPT_FILE=%s\n", shellQuote(vmRunToolingHarnessPromptPath(spec.RepoName)))
script.WriteString("if [ ! -f \"$PROMPT_FILE\" ]; then log \"tooling prompt file missing: $PROMPT_FILE\"; exit 0; fi\n")
script.WriteString("log \"starting tooling harness in $DIR\"\n")
script.WriteString("if [ -f .mise.toml ] || [ -f .tool-versions ]; then\n")
script.WriteString(" log \"running mise install from repo declarations\"\n")
script.WriteString(" run_best_effort \"$MISE_BIN\" install\n")
script.WriteString("fi\n")
fmt.Fprintf(&script, "INSTALL_TIMEOUT_SECS=%d\n", vmRunToolingInstallTimeoutSeconds)
for _, step := range plan.Steps {
stepLabel := fmt.Sprintf("deterministic install: %s@%s (%s)", step.Tool, step.Version, step.Source)
fmt.Fprintf(&script, "log %s\n", shellQuote(stepLabel))
fmt.Fprintf(&script, "run_bounded_best_effort \"$INSTALL_TIMEOUT_SECS\" \"$MISE_BIN\" use -g --pin %s\n", shellQuote(step.Tool+"@"+step.Version))
}
for _, skip := range plan.Skips {
skipLabel := fmt.Sprintf("deterministic skip: %s (%s)", skip.Target, skip.Reason)
fmt.Fprintf(&script, "log %s\n", shellQuote(skipLabel))
}
if len(plan.Steps) > 0 {
script.WriteString("run_best_effort \"$MISE_BIN\" reshim\n")
}
fmt.Fprintf(&script, "MODEL=%s\n", shellQuote(vmRunToolingHarnessModel))
fmt.Fprintf(&script, "TIMEOUT_SECS=%d\n", vmRunToolingHarnessTimeoutSeconds)
script.WriteString("log \"running bounded opencode repo tooling inspection with $MODEL for up to ${TIMEOUT_SECS}s\"\n")
script.WriteString("run_bounded_best_effort \"$TIMEOUT_SECS\" bash -lc 'exec \"$1\" run --format json -m \"$2\" \"$(cat \"$3\")\"' _ \"$OPENCODE_BIN\" \"$MODEL\" \"$PROMPT_FILE\"\n")
script.WriteString("log \"tooling harness finished\"\n")
return script.String()
}
func vmRunToolingHarnessLaunchScript(spec vmRunRepoSpec) string {
var script strings.Builder
script.WriteString("set -euo pipefail\n")
fmt.Fprintf(&script, "HELPER=%s\n", shellQuote(vmRunToolingHarnessPath(spec.RepoName)))
fmt.Fprintf(&script, "LOG=%s\n", shellQuote(vmRunToolingHarnessLogPath(spec.RepoName)))
script.WriteString("mkdir -p \"$(dirname \"$LOG\")\"\n")
script.WriteString("nohup bash \"$HELPER\" >\"$LOG\" 2>&1 </dev/null &\n")
script.WriteString("disown || true\n")
return script.String()
}
func runVMRunAttach(ctx context.Context, socketPath, vmRef string, cfg model.DaemonConfig, stdin io.Reader, stdout, stderr io.Writer, guestIP, guestDir string, progress *vmRunProgressRenderer) error {
guestIP = strings.TrimSpace(guestIP)
if guestIP == "" {
return errors.New("vm has no guest IP")
}
supportsAttach, err := hostOpencodeAttachSupportedFunc(ctx)
if err != nil {
printVMRunWarning(stderr, fmt.Sprintf("could not detect host opencode attach support: %v", err))
}
if supportsAttach {
if progress != nil {
progress.render("attaching opencode")
}
return opencodeExecFunc(ctx, stdin, stdout, stderr, []string{
"attach",
"--dir", guestDir,
"http://" + net.JoinHostPort(guestIP, "4096"),
})
}
if progress != nil {
progress.render("host opencode has no attach support; starting guest opencode over ssh")
}
sshArgs, err := sshCommandArgs(cfg, guestIP, []string{"bash", "-lc", fmt.Sprintf("cd %s && exec opencode .", shellQuote(guestDir))})
if err != nil {
return err
}
return runSSHSession(ctx, socketPath, vmRef, stdin, stdout, stderr, sshArgs)
}
func hostOpencodeAttachSupported(ctx context.Context) (bool, error) {
output, err := hostCommandOutputFunc(ctx, "opencode", "attach", "--help")
if err != nil {
return false, err
}
return opencodeAttachHelpOutputSupported(output), nil
}
func opencodeAttachHelpOutputSupported(output []byte) bool {
text := strings.ToLower(string(output))
return strings.Contains(text, "opencode attach")
}
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)
}
type vmRunProgressRenderer struct {
out io.Writer
enabled bool
lastLine string
}
func newVMRunProgressRenderer(out io.Writer) *vmRunProgressRenderer {
return &vmRunProgressRenderer{
out: out,
enabled: out != nil,
}
}
func (r *vmRunProgressRenderer) render(detail string) {
if r == nil || !r.enabled {
return
}
line := formatVMRunProgress(detail)
if line == "" || line == r.lastLine {
return
}
r.lastLine = line
_, _ = fmt.Fprintln(r.out, line)
}
func formatVMRunProgress(detail string) string {
detail = strings.TrimSpace(detail)
if detail == "" {
return ""
}
return "[vm run] " + detail
}
func printVMRunWarning(out io.Writer, detail string) {
detail = strings.TrimSpace(detail)
if out == nil || detail == "" {
return
}
_, _ = fmt.Fprintln(out, "[vm run] warning: "+detail)
}
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
}
func absolutizeImageBuildPaths(params *api.ImageBuildParams) error {
return absolutizePaths(&params.KernelPath, &params.InitrdPath, &params.ModulesDir)
}
func absolutizeImageRegisterPaths(params *api.ImageRegisterParams) error {
return absolutizePaths(
&params.RootfsPath,
&params.WorkSeedPath,
&params.KernelPath,
&params.InitrdPath,
&params.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)))
}
}
func formatBuildInfoBlock(info buildinfo.Info) string {
return fmt.Sprintf("version: %s\ncommit: %s\nbuilt_at: %s\n", info.Version, info.Commit, info.BuiltAt)
}