330 lines
9.1 KiB
Go
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)))
|
|
}
|
|
}
|