Add a localhost-only web console so VM and image management no longer depends on the CLI for every inspection and lifecycle action. Wire bangerd up to a configurable web listener, expose dashboard and async image-build state through the daemon, and serve CSRF-protected HTML pages with host-path picking, VM/image detail views, logs, ports, and progress polling for long-running operations. Keep the browser path aligned with the existing sudo and host-owned artifact model: surface sudo readiness, print the web URL in daemon status, and document the new workflow. Polish the UI with resource usage cards, clearer clickable affordances, cancel paths, confirmation prompts, image-name links, and HTTP port links. Validation: GOCACHE=/tmp/banger-gocache go test ./...
1472 lines
41 KiB
Go
1472 lines
41 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"text/tabwriter"
|
|
"time"
|
|
|
|
"banger/internal/api"
|
|
"banger/internal/config"
|
|
"banger/internal/daemon"
|
|
"banger/internal/hostnat"
|
|
"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()
|
|
}
|
|
vmHealthFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error) {
|
|
return rpc.Call[api.VMHealthResult](ctx, socketPath, "vm.health", api.VMRefParams{IDOrName: idOrName})
|
|
}
|
|
vmCreateBeginFunc = func(ctx context.Context, socketPath string, params api.VMCreateParams) (api.VMCreateBeginResult, error) {
|
|
return rpc.Call[api.VMCreateBeginResult](ctx, socketPath, "vm.create.begin", params)
|
|
}
|
|
vmCreateStatusFunc = func(ctx context.Context, socketPath, operationID string) (api.VMCreateStatusResult, error) {
|
|
return rpc.Call[api.VMCreateStatusResult](ctx, socketPath, "vm.create.status", api.VMCreateStatusParams{ID: operationID})
|
|
}
|
|
vmCreateCancelFunc = func(ctx context.Context, socketPath, operationID string) error {
|
|
_, err := rpc.Call[api.Empty](ctx, socketPath, "vm.create.cancel", api.VMCreateStatusParams{ID: operationID})
|
|
return err
|
|
}
|
|
vmPortsFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMPortsResult, error) {
|
|
return rpc.Call[api.VMPortsResult](ctx, socketPath, "vm.ports", api.VMRefParams{IDOrName: idOrName})
|
|
}
|
|
)
|
|
|
|
func NewBangerCommand() *cobra.Command {
|
|
root := &cobra.Command{
|
|
Use: "banger",
|
|
Short: "Manage development VMs and images",
|
|
SilenceUsage: true,
|
|
SilenceErrors: true,
|
|
RunE: helpNoArgs,
|
|
}
|
|
root.CompletionOptions.DisableDefaultCmd = true
|
|
root.AddCommand(newDaemonCommand(), newDoctorCommand(), newVMCommand(), newImageCommand(), newInternalCommand())
|
|
return root
|
|
}
|
|
|
|
func newDoctorCommand() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "doctor",
|
|
Short: "Check host and runtime readiness",
|
|
Args: noArgsUsage("usage: banger doctor"),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
report, err := doctorFunc(cmd.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := printDoctorReport(cmd.OutOrStdout(), report); err != nil {
|
|
return err
|
|
}
|
|
if report.HasFailures() {
|
|
return errors.New("doctor found failing checks")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func newInternalCommand() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "internal",
|
|
Hidden: true,
|
|
RunE: helpNoArgs,
|
|
}
|
|
cmd.AddCommand(newInternalNATCommand(), newInternalWorkSeedCommand())
|
|
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 := rpc.Call[api.PingResult](cmd.Context(), layout.SocketPath, "ping", api.Empty{})
|
|
if pingErr != nil {
|
|
if strings.TrimSpace(cfg.WebListenAddr) != "" {
|
|
_, err = fmt.Fprintf(cmd.OutOrStdout(), "stopped\nsocket: %s\nlog: %s\ndns: %s\nweb: http://%s\n", layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr, cfg.WebListenAddr)
|
|
return err
|
|
}
|
|
_, err = fmt.Fprintf(cmd.OutOrStdout(), "stopped\nsocket: %s\nlog: %s\ndns: %s\n", layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr)
|
|
return err
|
|
}
|
|
if strings.TrimSpace(ping.WebURL) != "" {
|
|
_, err = fmt.Fprintf(cmd.OutOrStdout(), "running\npid: %d\nsocket: %s\nlog: %s\ndns: %s\nweb: %s\n", ping.PID, layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr, ping.WebURL)
|
|
return err
|
|
}
|
|
_, err = fmt.Fprintf(cmd.OutOrStdout(), "running\npid: %d\nsocket: %s\nlog: %s\ndns: %s\n", ping.PID, layout.SocketPath, layout.DaemonLog, vmdns.DefaultListenAddr)
|
|
return err
|
|
},
|
|
},
|
|
&cobra.Command{
|
|
Use: "stop",
|
|
Short: "Stop the daemon",
|
|
Args: noArgsUsage("usage: banger daemon stop"),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if err := system.EnsureSudo(cmd.Context()); err != nil {
|
|
return err
|
|
}
|
|
layout, err := paths.Resolve()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = rpc.Call[api.ShutdownResult](cmd.Context(), layout.SocketPath, "shutdown", api.Empty{})
|
|
if err != nil {
|
|
if os.IsNotExist(err) || strings.Contains(err.Error(), "connect") {
|
|
_, writeErr := fmt.Fprintln(cmd.OutOrStdout(), "daemon not running")
|
|
return writeErr
|
|
}
|
|
return err
|
|
}
|
|
_, err = fmt.Fprintln(cmd.OutOrStdout(), "stopping")
|
|
return err
|
|
},
|
|
},
|
|
&cobra.Command{
|
|
Use: "socket",
|
|
Short: "Print the daemon socket path",
|
|
Args: noArgsUsage("usage: banger daemon socket"),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
layout, err := paths.Resolve()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = fmt.Fprintln(cmd.OutOrStdout(), layout.SocketPath)
|
|
return err
|
|
},
|
|
},
|
|
)
|
|
return cmd
|
|
}
|
|
|
|
func newVMCommand() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "vm",
|
|
Short: "Manage virtual machines",
|
|
RunE: helpNoArgs,
|
|
}
|
|
cmd.AddCommand(
|
|
newVMCreateCommand(),
|
|
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 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
|
|
}
|
|
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 8, 2, ' ', 0)
|
|
fmt.Fprintln(w, "ID\tNAME\tSTATE\tIMAGE\tIP\tVCPU\tMEM\tDISK\tCREATED")
|
|
for _, vm := range result.VMs {
|
|
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,
|
|
shortID(vm.ImageID),
|
|
vm.Runtime.GuestIP,
|
|
vm.Spec.VCPUCount,
|
|
vm.Spec.MemoryMiB,
|
|
model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes),
|
|
relativeTime(vm.CreatedAt),
|
|
)
|
|
}
|
|
return w.Flush()
|
|
},
|
|
}
|
|
}
|
|
|
|
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.BaseRootfs, "base-rootfs", "", "base rootfs path")
|
|
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>] [--packages <path>]"),
|
|
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().StringVar(¶ms.PackagesPath, "packages", "", "packages manifest path")
|
|
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
|
|
}
|
|
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 8, 2, ' ', 0)
|
|
fmt.Fprintln(w, "ID\tNAME\tMANAGED\tROOTFS\tCREATED")
|
|
for _, image := range result.Images {
|
|
fmt.Fprintf(w, "%s\t%s\t%t\t%s\t%s\n", shortID(image.ID), image.Name, image.Managed, image.RootfsPath, relativeTime(image.CreatedAt))
|
|
}
|
|
return w.Flush()
|
|
},
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
type resolvedVMTarget struct {
|
|
Index int
|
|
Ref string
|
|
VM model.VMRecord
|
|
}
|
|
|
|
type vmRefResolutionError struct {
|
|
Index int
|
|
Ref string
|
|
Err error
|
|
}
|
|
|
|
type vmBatchActionResult struct {
|
|
Target resolvedVMTarget
|
|
VM model.VMRecord
|
|
Err error
|
|
}
|
|
|
|
func runVMBatchAction(cmd *cobra.Command, socketPath string, refs []string, action func(context.Context, string) (model.VMRecord, error)) error {
|
|
listResult, err := rpc.Call[api.VMListResult](cmd.Context(), socketPath, "vm.list", api.Empty{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
targets, resolutionErrs := resolveVMTargets(listResult.VMs, refs)
|
|
results := executeVMActionBatch(cmd.Context(), targets, action)
|
|
|
|
failed := false
|
|
for _, resolutionErr := range resolutionErrs {
|
|
if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "%s: %v\n", resolutionErr.Ref, resolutionErr.Err); err != nil {
|
|
return err
|
|
}
|
|
failed = true
|
|
}
|
|
for _, result := range results {
|
|
if result.Err != nil {
|
|
if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "%s: %v\n", result.Target.Ref, result.Err); err != nil {
|
|
return err
|
|
}
|
|
failed = true
|
|
continue
|
|
}
|
|
if err := printVMSummary(cmd.OutOrStdout(), result.VM); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if failed {
|
|
return errors.New("one or more VM operations failed")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func resolveVMTargets(vms []model.VMRecord, refs []string) ([]resolvedVMTarget, []vmRefResolutionError) {
|
|
targets := make([]resolvedVMTarget, 0, len(refs))
|
|
resolutionErrs := make([]vmRefResolutionError, 0)
|
|
seen := make(map[string]struct{}, len(refs))
|
|
for index, ref := range refs {
|
|
vm, err := resolveVMRef(vms, ref)
|
|
if err != nil {
|
|
resolutionErrs = append(resolutionErrs, vmRefResolutionError{Index: index, Ref: ref, Err: err})
|
|
continue
|
|
}
|
|
if _, ok := seen[vm.ID]; ok {
|
|
continue
|
|
}
|
|
seen[vm.ID] = struct{}{}
|
|
targets = append(targets, resolvedVMTarget{Index: index, Ref: ref, VM: vm})
|
|
}
|
|
return targets, resolutionErrs
|
|
}
|
|
|
|
func resolveVMRef(vms []model.VMRecord, ref string) (model.VMRecord, error) {
|
|
ref = strings.TrimSpace(ref)
|
|
if ref == "" {
|
|
return model.VMRecord{}, errors.New("vm id or name is required")
|
|
}
|
|
exactMatches := make([]model.VMRecord, 0, 1)
|
|
for _, vm := range vms {
|
|
if vm.ID == ref || vm.Name == ref {
|
|
exactMatches = append(exactMatches, vm)
|
|
}
|
|
}
|
|
switch len(exactMatches) {
|
|
case 1:
|
|
return exactMatches[0], nil
|
|
case 0:
|
|
default:
|
|
return model.VMRecord{}, fmt.Errorf("multiple VMs match %q", ref)
|
|
}
|
|
|
|
prefixMatches := make([]model.VMRecord, 0, 1)
|
|
for _, vm := range vms {
|
|
if strings.HasPrefix(vm.ID, ref) || strings.HasPrefix(vm.Name, ref) {
|
|
prefixMatches = append(prefixMatches, vm)
|
|
}
|
|
}
|
|
switch len(prefixMatches) {
|
|
case 1:
|
|
return prefixMatches[0], nil
|
|
case 0:
|
|
return model.VMRecord{}, fmt.Errorf("vm %q not found", ref)
|
|
default:
|
|
return model.VMRecord{}, fmt.Errorf("multiple VMs match %q", ref)
|
|
}
|
|
}
|
|
|
|
func executeVMActionBatch(ctx context.Context, targets []resolvedVMTarget, action func(context.Context, string) (model.VMRecord, error)) []vmBatchActionResult {
|
|
results := make([]vmBatchActionResult, len(targets))
|
|
var wg sync.WaitGroup
|
|
wg.Add(len(targets))
|
|
for index, target := range targets {
|
|
index := index
|
|
target := target
|
|
go func() {
|
|
defer wg.Done()
|
|
vm, err := action(ctx, target.VM.ID)
|
|
results[index] = vmBatchActionResult{
|
|
Target: target,
|
|
VM: vm,
|
|
Err: err,
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
return results
|
|
}
|
|
|
|
func ensureDaemon(ctx context.Context) (paths.Layout, model.DaemonConfig, error) {
|
|
layout, err := paths.Resolve()
|
|
if err != nil {
|
|
return paths.Layout{}, model.DaemonConfig{}, err
|
|
}
|
|
cfg, err := config.Load(layout)
|
|
if err != nil {
|
|
return paths.Layout{}, model.DaemonConfig{}, err
|
|
}
|
|
if ping, err := rpc.Call[api.PingResult](ctx, layout.SocketPath, "ping", api.Empty{}); err == nil {
|
|
if daemonOutdated(ping.PID) {
|
|
if err := restartDaemon(ctx, layout, ping.PID); err != nil {
|
|
return paths.Layout{}, model.DaemonConfig{}, err
|
|
}
|
|
return layout, cfg, nil
|
|
}
|
|
return layout, cfg, nil
|
|
}
|
|
if err := startDaemon(ctx, layout); err != nil {
|
|
return paths.Layout{}, model.DaemonConfig{}, err
|
|
}
|
|
return layout, cfg, nil
|
|
}
|
|
|
|
func daemonOutdated(pid int) bool {
|
|
if pid <= 0 {
|
|
return false
|
|
}
|
|
daemonBin, err := bangerdPathFunc()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
currentInfo, err := os.Stat(daemonBin)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
runningInfo, err := os.Stat(daemonExePath(pid))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return !os.SameFile(currentInfo, runningInfo)
|
|
}
|
|
|
|
func restartDaemon(ctx context.Context, layout paths.Layout, pid int) error {
|
|
stopCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
|
defer cancel()
|
|
|
|
_, _ = rpc.Call[api.ShutdownResult](stopCtx, layout.SocketPath, "shutdown", api.Empty{})
|
|
if waitForPIDExit(pid, 2*time.Second) {
|
|
return startDaemon(ctx, layout)
|
|
}
|
|
if proc, err := os.FindProcess(pid); err == nil {
|
|
_ = proc.Signal(syscall.SIGTERM)
|
|
}
|
|
if !waitForPIDExit(pid, 2*time.Second) {
|
|
return fmt.Errorf("timed out restarting stale daemon pid %d", pid)
|
|
}
|
|
return startDaemon(ctx, layout)
|
|
}
|
|
|
|
func waitForPIDExit(pid int, timeout time.Duration) bool {
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
if !pidRunning(pid) {
|
|
return true
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
return !pidRunning(pid)
|
|
}
|
|
|
|
func pidRunning(pid int) bool {
|
|
if pid <= 0 {
|
|
return false
|
|
}
|
|
proc, err := os.FindProcess(pid)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return proc.Signal(syscall.Signal(0)) == nil
|
|
}
|
|
|
|
func startDaemon(ctx context.Context, layout paths.Layout) error {
|
|
if err := paths.Ensure(layout); err != nil {
|
|
return err
|
|
}
|
|
logFile, err := os.OpenFile(layout.DaemonLog, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer logFile.Close()
|
|
|
|
daemonBin, err := paths.BangerdPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cmd := buildDaemonCommand(daemonBin)
|
|
cmd.Stdout = logFile
|
|
cmd.Stderr = logFile
|
|
cmd.Stdin = nil
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
|
if err := cmd.Start(); err != nil {
|
|
return err
|
|
}
|
|
if err := rpc.WaitForSocket(layout.SocketPath, 5*time.Second); err != nil {
|
|
return fmt.Errorf("daemon failed to start; inspect %s: %w", layout.DaemonLog, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func buildDaemonCommand(daemonBin string) *exec.Cmd {
|
|
return exec.Command(daemonBin)
|
|
}
|
|
|
|
func vmSetParamsFromFlags(idOrName string, vcpu, memory int, diskSize string, nat, noNat bool) (api.VMSetParams, error) {
|
|
if nat && noNat {
|
|
return api.VMSetParams{}, errors.New("use only one of --nat or --no-nat")
|
|
}
|
|
params := api.VMSetParams{IDOrName: idOrName, WorkDiskSize: diskSize}
|
|
if vcpu >= 0 {
|
|
if err := validatePositiveSetting("vcpu", vcpu); err != nil {
|
|
return api.VMSetParams{}, err
|
|
}
|
|
params.VCPUCount = &vcpu
|
|
}
|
|
if memory >= 0 {
|
|
if err := validatePositiveSetting("memory", memory); err != nil {
|
|
return api.VMSetParams{}, err
|
|
}
|
|
params.MemoryMiB = &memory
|
|
}
|
|
if nat || noNat {
|
|
value := nat && !noNat
|
|
params.NATEnabled = &value
|
|
}
|
|
if params.VCPUCount == nil && params.MemoryMiB == nil && params.WorkDiskSize == "" && params.NATEnabled == nil {
|
|
return api.VMSetParams{}, errors.New("no VM settings changed")
|
|
}
|
|
return params, nil
|
|
}
|
|
|
|
func vmCreateParamsFromFlags(cmd *cobra.Command, name, imageName string, vcpu, memory int, systemOverlaySize, workDiskSize string, natEnabled, noStart bool) (api.VMCreateParams, error) {
|
|
params := api.VMCreateParams{
|
|
Name: name,
|
|
ImageName: imageName,
|
|
NATEnabled: natEnabled,
|
|
NoStart: noStart,
|
|
}
|
|
if cmd.Flags().Changed("vcpu") {
|
|
if err := validatePositiveSetting("vcpu", vcpu); err != nil {
|
|
return api.VMCreateParams{}, err
|
|
}
|
|
params.VCPUCount = &vcpu
|
|
}
|
|
if cmd.Flags().Changed("memory") {
|
|
if err := validatePositiveSetting("memory", memory); err != nil {
|
|
return api.VMCreateParams{}, err
|
|
}
|
|
params.MemoryMiB = &memory
|
|
}
|
|
if cmd.Flags().Changed("system-overlay-size") {
|
|
params.SystemOverlaySize = systemOverlaySize
|
|
}
|
|
if cmd.Flags().Changed("disk-size") {
|
|
params.WorkDiskSize = workDiskSize
|
|
}
|
|
return params, nil
|
|
}
|
|
|
|
func validatePositiveSetting(label string, value int) error {
|
|
if value <= 0 {
|
|
return fmt.Errorf("%s must be a positive integer", label)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runSSHSession(ctx context.Context, socketPath, vmRef string, stdin io.Reader, stdout, stderr io.Writer, sshArgs []string) error {
|
|
sshErr := sshExecFunc(ctx, stdin, stdout, stderr, sshArgs)
|
|
if !shouldCheckSSHReminder(sshErr) || ctx.Err() != nil {
|
|
return sshErr
|
|
}
|
|
pingCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
health, err := vmHealthFunc(pingCtx, socketPath, vmRef)
|
|
if err != nil {
|
|
_, _ = fmt.Fprintln(stderr, vsockagent.WarningMessage(vmRef, err))
|
|
return sshErr
|
|
}
|
|
if health.Healthy {
|
|
name := health.Name
|
|
if strings.TrimSpace(name) == "" {
|
|
name = vmRef
|
|
}
|
|
_, _ = fmt.Fprintln(stderr, vsockagent.ReminderMessage(name))
|
|
}
|
|
return sshErr
|
|
}
|
|
|
|
func shouldCheckSSHReminder(err error) bool {
|
|
if err == nil {
|
|
return true
|
|
}
|
|
var exitErr *exec.ExitError
|
|
if !errors.As(err, &exitErr) {
|
|
return false
|
|
}
|
|
return exitErr.ExitCode() != 255
|
|
}
|
|
|
|
func sshCommandArgs(cfg model.DaemonConfig, guestIP string, extra []string) ([]string, error) {
|
|
if guestIP == "" {
|
|
return nil, errors.New("vm has no guest IP")
|
|
}
|
|
args := []string{}
|
|
args = append(args, "-F", "/dev/null")
|
|
if cfg.SSHKeyPath != "" {
|
|
args = append(args, "-i", cfg.SSHKeyPath)
|
|
}
|
|
args = append(
|
|
args,
|
|
"-o", "IdentitiesOnly=yes",
|
|
"-o", "BatchMode=yes",
|
|
"-o", "PreferredAuthentications=publickey",
|
|
"-o", "PasswordAuthentication=no",
|
|
"-o", "KbdInteractiveAuthentication=no",
|
|
"-o", "StrictHostKeyChecking=no",
|
|
"-o", "UserKnownHostsFile=/dev/null",
|
|
"root@"+guestIP,
|
|
)
|
|
args = append(args, extra...)
|
|
return args, nil
|
|
}
|
|
|
|
func validateSSHPrereqs(cfg model.DaemonConfig) error {
|
|
checks := system.NewPreflight()
|
|
checks.RequireCommand("ssh", "install openssh-client")
|
|
if strings.TrimSpace(cfg.SSHKeyPath) != "" {
|
|
checks.RequireFile(cfg.SSHKeyPath, "runtime ssh private key", `refresh the runtime bundle`)
|
|
}
|
|
return checks.Err("ssh preflight failed")
|
|
}
|
|
|
|
func absolutizeImageBuildPaths(params *api.ImageBuildParams) error {
|
|
return absolutizePaths(¶ms.BaseRootfs, ¶ms.KernelPath, ¶ms.InitrdPath, ¶ms.ModulesDir)
|
|
}
|
|
|
|
func absolutizeImageRegisterPaths(params *api.ImageRegisterParams) error {
|
|
return absolutizePaths(
|
|
¶ms.RootfsPath,
|
|
¶ms.WorkSeedPath,
|
|
¶ms.KernelPath,
|
|
¶ms.InitrdPath,
|
|
¶ms.ModulesDir,
|
|
¶ms.PackagesPath,
|
|
)
|
|
}
|
|
|
|
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 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 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)))
|
|
}
|
|
}
|