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) (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) renderer.render(begin.Operation) op := begin.Operation for { if op.Done { renderer.render(op) if op.Success && op.VM != nil { 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 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) } // 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))) } }