Treat `banger`, `bangerd`, and `banger-vsock-agent` as one release by stamping the same version, commit SHA, and build timestamp into every binary through a shared ldflag-backed `internal/buildinfo` package. Add `banger version`, extend daemon ping/status to report the running daemon's build tuple, and keep the guest helper linked to the same build metadata without adding a new public version surface for it. Validate with `GOCACHE=/tmp/banger-gocache go test ./...`, `make build`, `./build/bin/banger version`, and `./build/bin/banger daemon status` after the daemon restarts onto the new binary.
2090 lines
60 KiB
Go
2090 lines
60 KiB
Go
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/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/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})
|
|
}
|
|
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)
|
|
}
|
|
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(), 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(¶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 <name> --rootfs <path> [--work-seed <path>] --kernel <path> [--initrd <path>] [--modules <dir>]"),
|
|
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 <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)
|
|
}
|
|
}
|
|
|
|
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)))
|
|
}
|
|
}
|
|
|
|
func formatBuildInfoBlock(info buildinfo.Info) string {
|
|
return fmt.Sprintf("version: %s\ncommit: %s\nbuilt_at: %s\n", info.Version, info.Commit, info.BuiltAt)
|
|
}
|