banger/internal/cli/vm_create.go
2026-05-01 19:34:44 -03:00

330 lines
9.1 KiB
Go

package cli
import (
"context"
"errors"
"fmt"
"io"
"os"
"strings"
"time"
"banger/internal/api"
"banger/internal/cli/style"
"banger/internal/config"
"banger/internal/model"
"banger/internal/paths"
"banger/internal/system"
)
// effectiveVMDefaults resolves the default VM sizing applied when
// --vcpu/--memory/--disk-size aren't given: config overrides win
// over host-derived heuristics, both fall back to baked-in
// constants. Called at command-build time so the cobra flag defaults
// reflect the resolved values.
func effectiveVMDefaults() model.VMDefaults {
var override model.VMDefaultsOverride
if layout, err := paths.Resolve(); err == nil {
if cfg, err := config.Load(layout); err == nil {
override = cfg.VMDefaults
}
}
host, err := system.ReadHostResources()
if err != nil {
return model.ResolveVMDefaults(override, 0, 0)
}
return model.ResolveVMDefaults(override, host.CPUCount, host.TotalMemoryBytes)
}
// printVMSpecLine writes a one-line sizing summary to out. Always
// emitted (even non-TTY) so logs and CI output carry the numbers.
func printVMSpecLine(out io.Writer, params api.VMCreateParams) {
vcpu := model.DefaultVCPUCount
if params.VCPUCount != nil {
vcpu = *params.VCPUCount
}
memory := model.DefaultMemoryMiB
if params.MemoryMiB != nil {
memory = *params.MemoryMiB
}
diskBytes := int64(model.DefaultWorkDiskSize)
if strings.TrimSpace(params.WorkDiskSize) != "" {
if parsed, err := model.ParseSize(params.WorkDiskSize); err == nil {
diskBytes = parsed
}
}
_, _ = fmt.Fprintf(out, "spec: %d vcpu | %d MiB | %s disk\n",
vcpu, memory, model.FormatSizeBytes(diskBytes))
}
// runVMCreate drives the create RPC + polls for progress. stderr
// gets the spec line up front and the progress renderer thereafter.
// On context cancel we cooperate with the daemon to cancel the
// in-flight op so it doesn't leak partially-created VM state.
func (d *deps) runVMCreate(ctx context.Context, socketPath string, stderr io.Writer, params api.VMCreateParams, verbose bool) (model.VMRecord, error) {
start := time.Now()
printVMSpecLine(stderr, params)
begin, err := d.vmCreateBegin(ctx, socketPath, params)
if err != nil {
return model.VMRecord{}, err
}
renderer := newVMCreateProgressRenderer(stderr, verbose)
renderer.render(begin.Operation)
op := begin.Operation
for {
if op.Done {
renderer.render(op)
if op.Success && op.VM != nil {
renderer.clear()
elapsed := formatVMCreateElapsed(time.Since(start))
_, _ = fmt.Fprintf(stderr, "[vm create] ready in %s\n", style.Dim(stderr, elapsed))
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()
_ = d.vmCreateCancel(cancelCtx, socketPath, op.ID)
return model.VMRecord{}, ctx.Err()
case <-time.After(200 * time.Millisecond):
}
status, err := d.vmCreateStatus(ctx, socketPath, op.ID)
if err != nil {
if ctx.Err() != nil {
cancelCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_ = d.vmCreateCancel(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
inline bool
active bool
lastLine string
}
// newVMCreateProgressRenderer wires up progress for `vm create`. On
// non-TTY writers it stays disabled (CI/test logs already capture the
// spec + ready lines); on TTY it rewrites a single line via \r unless
// verbose is set or BANGER_NO_PROGRESS is exported, in which case it
// falls back to one line per stage.
func newVMCreateProgressRenderer(out io.Writer, verbose bool) *vmCreateProgressRenderer {
tty := writerSupportsProgress(out)
return &vmCreateProgressRenderer{
out: out,
enabled: tty,
inline: tty && !verbose && !progressDisabledByEnv(),
}
}
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
if r.inline {
_, _ = fmt.Fprint(r.out, "\r\x1b[K", line)
r.active = true
return
}
_, _ = fmt.Fprintln(r.out, line)
}
// clear resets the live inline line so the caller can write a clean
// terminating message. No-op outside inline mode.
func (r *vmCreateProgressRenderer) clear() {
if r == nil || !r.enabled || !r.inline || !r.active {
return
}
_, _ = fmt.Fprint(r.out, "\r\x1b[K")
r.active = false
r.lastLine = ""
}
// progressDisabledByEnv is the BANGER_NO_PROGRESS escape hatch — a
// non-empty value forces line-per-stage output even on a TTY, so users
// can pipe `script(1)` / tmux capture without \r artifacts.
func progressDisabledByEnv() bool {
return strings.TrimSpace(os.Getenv("BANGER_NO_PROGRESS")) != ""
}
// writerSupportsProgress returns true only when out is a terminal.
// Keeps stage lines + heartbeat dots out of piped / logged output
// where they'd just be noise.
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
}
// withHeartbeat runs fn while emitting a dot to stderr every 2
// seconds so the user sees long-running RPCs (bundle downloads, etc.)
// aren't wedged. No-op when stderr isn't a terminal, so piped or
// logged output stays clean.
func withHeartbeat(stderr io.Writer, label string, fn func() error) error {
if !writerSupportsProgress(stderr) {
return fn()
}
fmt.Fprintf(stderr, "[%s] ", label)
stop := make(chan struct{})
done := make(chan struct{})
go func() {
defer close(done)
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
fmt.Fprint(stderr, ".")
}
}
}()
err := fn()
close(stop)
<-done
fmt.Fprintln(stderr)
return err
}
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
}
// vmCreateStageLabel humanises the daemon-side stage IDs. Anything
// unknown falls through to `strings.ReplaceAll(_, "_", " ")` so new
// stages still render meaningfully without a code change.
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 "apply_dns":
return "publishing dns"
case "apply_nat":
return "configuring nat"
case "finalize":
return "finalizing"
case "ready":
return "ready"
default:
return strings.ReplaceAll(stage, "_", " ")
}
}
// formatVMCreateElapsed renders a wall-clock duration as a friendly
// "ready in 4.7s" / "ready in 1m02s" string. Sub-second durations
// keep one decimal so quick smoke runs don't print "0s".
func formatVMCreateElapsed(d time.Duration) string {
if d < time.Second {
return fmt.Sprintf("%dms", d.Milliseconds())
}
if d < time.Minute {
return fmt.Sprintf("%.1fs", d.Seconds())
}
d = d.Round(time.Second)
minutes := int(d / time.Minute)
seconds := int((d % time.Minute) / time.Second)
return fmt.Sprintf("%dm%02ds", minutes, seconds)
}
func validatePositiveSetting(label string, value int) error {
if value <= 0 {
return fmt.Errorf("%s must be a positive integer", label)
}
return nil
}
// shortID and relativeTime are small display helpers used across
// every printer; kept here alongside the other render-time helpers.
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)))
}
}