vm defaults: host-aware sizing + spec line on spawn + doctor check
Replaces the static model.Default* constants that drove --vcpu / --memory / --disk-size with a three-layer resolver: 1. [vm_defaults] in ~/.config/banger/config.toml (if set) 2. host-derived heuristics (cpus/4 capped at 4; ram/8 capped at 8 GiB) 3. baked-in constants (floor) Visibility: - Every `vm run` / `vm create` prints a `spec:` line before progress begins: `spec: 4 vcpu · 8192 MiB · 8G disk`. Matches the VM that actually gets created because the CLI is now the single source of truth — it resolves, populates the flag defaults, and forwards the explicit values to the daemon. - `banger doctor` adds a "vm defaults" check showing per-field provenance (config|auto|builtin) and the config file path for overrides. - `--help` shows the resolved defaults (e.g. `--vcpu int (default 4)` on an 8-core host). No `banger config init` command, no first-run side effects, no writes to the user's filesystem behind their back. Users who want explicit control set the keys; everyone else gets sensible numbers that track their hardware.
This commit is contained in:
parent
78ff482bfa
commit
21b74639d8
10 changed files with 594 additions and 56 deletions
|
|
@ -750,13 +750,14 @@ func newVMCommand() *cobra.Command {
|
|||
}
|
||||
|
||||
func newVMRunCommand() *cobra.Command {
|
||||
defaults := effectiveVMDefaults()
|
||||
var (
|
||||
name string
|
||||
imageName string
|
||||
vcpu = model.DefaultVCPUCount
|
||||
memory = model.DefaultMemoryMiB
|
||||
systemOverlaySize = model.FormatSizeBytes(model.DefaultSystemOverlaySize)
|
||||
workDiskSize = model.FormatSizeBytes(model.DefaultWorkDiskSize)
|
||||
vcpu = defaults.VCPUCount
|
||||
memory = defaults.MemoryMiB
|
||||
systemOverlaySize = model.FormatSizeBytes(defaults.SystemOverlaySizeByte)
|
||||
workDiskSize = model.FormatSizeBytes(defaults.WorkDiskSizeBytes)
|
||||
natEnabled bool
|
||||
branchName string
|
||||
fromRef = "HEAD"
|
||||
|
|
@ -842,10 +843,10 @@ Three modes:
|
|||
}
|
||||
cmd.Flags().StringVar(&name, "name", "", "vm name")
|
||||
cmd.Flags().StringVar(&imageName, "image", "", "image name or id (defaults to config's default_image_name; auto-pulled from imagecat if missing)")
|
||||
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().IntVar(&vcpu, "vcpu", defaults.VCPUCount, "vcpu count")
|
||||
cmd.Flags().IntVar(&memory, "memory", defaults.MemoryMiB, "memory in MiB")
|
||||
cmd.Flags().StringVar(&systemOverlaySize, "system-overlay-size", model.FormatSizeBytes(defaults.SystemOverlaySizeByte), "system overlay size")
|
||||
cmd.Flags().StringVar(&workDiskSize, "disk-size", model.FormatSizeBytes(defaults.WorkDiskSizeBytes), "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")
|
||||
|
|
@ -998,13 +999,14 @@ func promptYesNo(in io.Reader, out io.Writer, prompt string) (bool, error) {
|
|||
}
|
||||
|
||||
func newVMCreateCommand() *cobra.Command {
|
||||
defaults := effectiveVMDefaults()
|
||||
var (
|
||||
name string
|
||||
imageName string
|
||||
vcpu = model.DefaultVCPUCount
|
||||
memory = model.DefaultMemoryMiB
|
||||
systemOverlaySize = model.FormatSizeBytes(model.DefaultSystemOverlaySize)
|
||||
workDiskSize = model.FormatSizeBytes(model.DefaultWorkDiskSize)
|
||||
vcpu = defaults.VCPUCount
|
||||
memory = defaults.MemoryMiB
|
||||
systemOverlaySize = model.FormatSizeBytes(defaults.SystemOverlaySizeByte)
|
||||
workDiskSize = model.FormatSizeBytes(defaults.WorkDiskSizeBytes)
|
||||
natEnabled bool
|
||||
noStart bool
|
||||
)
|
||||
|
|
@ -1033,10 +1035,10 @@ func newVMCreateCommand() *cobra.Command {
|
|||
}
|
||||
cmd.Flags().StringVar(&name, "name", "", "vm name")
|
||||
cmd.Flags().StringVar(&imageName, "image", "", "image name or id (defaults to config's default_image_name; auto-pulled from imagecat if missing)")
|
||||
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().IntVar(&vcpu, "vcpu", defaults.VCPUCount, "vcpu count")
|
||||
cmd.Flags().IntVar(&memory, "memory", defaults.MemoryMiB, "memory in MiB")
|
||||
cmd.Flags().StringVar(&systemOverlaySize, "system-overlay-size", model.FormatSizeBytes(defaults.SystemOverlaySizeByte), "system overlay size")
|
||||
cmd.Flags().StringVar(&workDiskSize, "disk-size", model.FormatSizeBytes(defaults.WorkDiskSizeBytes), "work disk size")
|
||||
cmd.Flags().BoolVar(&natEnabled, "nat", false, "enable NAT")
|
||||
cmd.Flags().BoolVar(&noStart, "no-start", false, "create without starting")
|
||||
_ = cmd.RegisterFlagCompletionFunc("image", completeImageNames)
|
||||
|
|
@ -2563,33 +2565,73 @@ func vmSetParamsFromFlags(idOrName string, vcpu, memory int, diskSize string, na
|
|||
}
|
||||
|
||||
func vmCreateParamsFromFlags(cmd *cobra.Command, name, imageName string, vcpu, memory int, systemOverlaySize, workDiskSize string, natEnabled, noStart bool) (api.VMCreateParams, error) {
|
||||
// The flag defaults are already resolved from config + host
|
||||
// heuristics at command-build time, so we always forward the flag
|
||||
// values to the daemon. This makes the CLI the single source of
|
||||
// truth for effective defaults and lets the progress renderer show
|
||||
// exactly what the VM will be sized at.
|
||||
if err := validatePositiveSetting("vcpu", vcpu); err != nil {
|
||||
return api.VMCreateParams{}, err
|
||||
}
|
||||
if err := validatePositiveSetting("memory", memory); err != nil {
|
||||
return api.VMCreateParams{}, err
|
||||
}
|
||||
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
|
||||
Name: name,
|
||||
ImageName: imageName,
|
||||
NATEnabled: natEnabled,
|
||||
NoStart: noStart,
|
||||
VCPUCount: &vcpu,
|
||||
MemoryMiB: &memory,
|
||||
SystemOverlaySize: systemOverlaySize,
|
||||
WorkDiskSize: workDiskSize,
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
|
||||
// effectiveVMDefaults resolves the default sizing applied to commands
|
||||
// that accept --vcpu / --memory / --disk-size flags when the user
|
||||
// doesn't set them. It combines config overrides (if any) with
|
||||
// host-derived heuristics, falling back to baked-in constants.
|
||||
//
|
||||
// Called at command-build time, which runs before any RunE. It
|
||||
// reads config.toml and /proc — any read error collapses to builtin
|
||||
// constants so the CLI stays usable even on a misconfigured host.
|
||||
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))
|
||||
}
|
||||
|
||||
func validatePositiveSetting(label string, value int) error {
|
||||
if value <= 0 {
|
||||
return fmt.Errorf("%s must be a positive integer", label)
|
||||
|
|
@ -3479,6 +3521,7 @@ type anyWriter interface {
|
|||
}
|
||||
|
||||
func runVMCreate(ctx context.Context, socketPath string, stderr io.Writer, params api.VMCreateParams) (model.VMRecord, error) {
|
||||
printVMSpecLine(stderr, params)
|
||||
begin, err := vmCreateBeginFunc(ctx, socketPath, params)
|
||||
if err != nil {
|
||||
return model.VMRecord{}, err
|
||||
|
|
|
|||
|
|
@ -268,7 +268,11 @@ func TestVMRunFlagsExist(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestVMCreateFlagsShowStaticDefaults(t *testing.T) {
|
||||
func TestVMCreateFlagsShowResolvedDefaults(t *testing.T) {
|
||||
// Defaults are resolved at command-build time from config + host
|
||||
// heuristics. Guarantee only that the values are sensible-positive
|
||||
// and match the resolver's output — the exact numbers depend on
|
||||
// the host the tests run on.
|
||||
root := NewBangerCommand()
|
||||
vm, _, err := root.Find([]string{"vm"})
|
||||
if err != nil {
|
||||
|
|
@ -279,17 +283,23 @@ func TestVMCreateFlagsShowStaticDefaults(t *testing.T) {
|
|||
t.Fatalf("find create: %v", err)
|
||||
}
|
||||
|
||||
if got := create.Flags().Lookup("vcpu").DefValue; got != fmt.Sprintf("%d", model.DefaultVCPUCount) {
|
||||
t.Fatalf("vcpu default = %q, want %d", got, model.DefaultVCPUCount)
|
||||
for _, flagName := range []string{"vcpu", "memory"} {
|
||||
flag := create.Flags().Lookup(flagName)
|
||||
if flag == nil {
|
||||
t.Fatalf("flag %q missing", flagName)
|
||||
}
|
||||
if flag.DefValue == "" || flag.DefValue == "0" {
|
||||
t.Errorf("flag %q default = %q, want a positive integer", flagName, flag.DefValue)
|
||||
}
|
||||
}
|
||||
if got := create.Flags().Lookup("memory").DefValue; got != fmt.Sprintf("%d", model.DefaultMemoryMiB) {
|
||||
t.Fatalf("memory default = %q, want %d", got, model.DefaultMemoryMiB)
|
||||
}
|
||||
if got := create.Flags().Lookup("system-overlay-size").DefValue; got != model.FormatSizeBytes(model.DefaultSystemOverlaySize) {
|
||||
t.Fatalf("system-overlay-size default = %q, want %q", got, model.FormatSizeBytes(model.DefaultSystemOverlaySize))
|
||||
}
|
||||
if got := create.Flags().Lookup("disk-size").DefValue; got != model.FormatSizeBytes(model.DefaultWorkDiskSize) {
|
||||
t.Fatalf("disk-size default = %q, want %q", got, model.FormatSizeBytes(model.DefaultWorkDiskSize))
|
||||
for _, flagName := range []string{"system-overlay-size", "disk-size"} {
|
||||
flag := create.Flags().Lookup(flagName)
|
||||
if flag == nil {
|
||||
t.Fatalf("flag %q missing", flagName)
|
||||
}
|
||||
if !strings.ContainsAny(flag.DefValue, "GMK") {
|
||||
t.Errorf("flag %q default = %q, want a formatted size like '8G'", flagName, flag.DefValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -385,7 +395,11 @@ func TestVMSetParamsFromFlags(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestVMCreateParamsFromFlagsOmitsStaticDefaultsWhenFlagsAreUnchanged(t *testing.T) {
|
||||
func TestVMCreateParamsFromFlagsAlwaysPopulatesResolvedValues(t *testing.T) {
|
||||
// Post-resolver behavior: the CLI is the single source of truth for
|
||||
// effective defaults. Whether or not the user changed a flag, the
|
||||
// daemon receives the explicit value so the spec printed to the
|
||||
// user matches the VM that gets created.
|
||||
cmd := NewBangerCommand()
|
||||
vm, _, err := cmd.Find([]string{"vm"})
|
||||
if err != nil {
|
||||
|
|
@ -400,18 +414,46 @@ func TestVMCreateParamsFromFlagsOmitsStaticDefaultsWhenFlagsAreUnchanged(t *test
|
|||
create,
|
||||
"devbox",
|
||||
"default",
|
||||
model.DefaultVCPUCount,
|
||||
model.DefaultMemoryMiB,
|
||||
model.FormatSizeBytes(model.DefaultSystemOverlaySize),
|
||||
model.FormatSizeBytes(model.DefaultWorkDiskSize),
|
||||
3,
|
||||
4096,
|
||||
"10G",
|
||||
"20G",
|
||||
false,
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("vmCreateParamsFromFlags: %v", err)
|
||||
}
|
||||
if params.VCPUCount != nil || params.MemoryMiB != nil || params.SystemOverlaySize != "" || params.WorkDiskSize != "" {
|
||||
t.Fatalf("expected unchanged defaults to stay omitted: %+v", params)
|
||||
if params.VCPUCount == nil || *params.VCPUCount != 3 {
|
||||
t.Errorf("VCPUCount = %v, want 3", params.VCPUCount)
|
||||
}
|
||||
if params.MemoryMiB == nil || *params.MemoryMiB != 4096 {
|
||||
t.Errorf("MemoryMiB = %v, want 4096", params.MemoryMiB)
|
||||
}
|
||||
if params.SystemOverlaySize != "10G" {
|
||||
t.Errorf("SystemOverlaySize = %q, want 10G", params.SystemOverlaySize)
|
||||
}
|
||||
if params.WorkDiskSize != "20G" {
|
||||
t.Errorf("WorkDiskSize = %q, want 20G", params.WorkDiskSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVMCreateParamsFromFlagsRejectsNonPositive(t *testing.T) {
|
||||
cmd := NewBangerCommand()
|
||||
vm, _, err := cmd.Find([]string{"vm"})
|
||||
if err != nil {
|
||||
t.Fatalf("find vm: %v", err)
|
||||
}
|
||||
create, _, err := vm.Find([]string{"create"})
|
||||
if err != nil {
|
||||
t.Fatalf("find create: %v", err)
|
||||
}
|
||||
|
||||
if _, err := vmCreateParamsFromFlags(create, "x", "", 0, 1024, "8G", "8G", false, false); err == nil {
|
||||
t.Error("expected error for vcpu=0")
|
||||
}
|
||||
if _, err := vmCreateParamsFromFlags(create, "x", "", 2, 0, "8G", "8G", false, false); err == nil {
|
||||
t.Error("expected error for memory=0")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
53
internal/cli/vm_spec_test.go
Normal file
53
internal/cli/vm_spec_test.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"banger/internal/api"
|
||||
)
|
||||
|
||||
func TestPrintVMSpecLineWithAllFields(t *testing.T) {
|
||||
vcpu, mem := 2, 2048
|
||||
params := api.VMCreateParams{
|
||||
VCPUCount: &vcpu,
|
||||
MemoryMiB: &mem,
|
||||
WorkDiskSize: "8G",
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
printVMSpecLine(&buf, params)
|
||||
got := buf.String()
|
||||
for _, want := range []string{"spec:", "2 vcpu", "2048 MiB", "8G"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if !strings.HasSuffix(got, "\n") {
|
||||
t.Error("spec line should terminate with newline")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintVMSpecLineFallsBackToBuiltinsOnNilFields(t *testing.T) {
|
||||
// Empty params — the printer reaches for DefaultVCPUCount /
|
||||
// DefaultMemoryMiB / DefaultWorkDiskSize so output is still sane.
|
||||
var buf bytes.Buffer
|
||||
printVMSpecLine(&buf, api.VMCreateParams{})
|
||||
got := buf.String()
|
||||
// Not asserting exact values — just that it produced a plausible
|
||||
// line with the three labels.
|
||||
for _, want := range []string{"spec:", "vcpu", "MiB", "disk"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintVMSpecLineIgnoresUnparseableDiskSize(t *testing.T) {
|
||||
// Falls back to builtin default; must not panic or print garbage.
|
||||
var buf bytes.Buffer
|
||||
printVMSpecLine(&buf, api.VMCreateParams{WorkDiskSize: "not-a-size"})
|
||||
if !strings.Contains(buf.String(), "spec:") {
|
||||
t.Errorf("expected spec line even with bad input, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue