From 21b74639d8e278b95be21c8e2072f7aaef5646c3 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Sun, 19 Apr 2026 13:06:51 -0300 Subject: [PATCH] vm defaults: host-aware sizing + spec line on spawn + doctor check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 22 +++++ internal/cli/banger.go | 119 +++++++++++++++++-------- internal/cli/cli_test.go | 78 +++++++++++++---- internal/cli/vm_spec_test.go | 53 ++++++++++++ internal/config/config.go | 50 +++++++++++ internal/config/config_test.go | 62 +++++++++++++ internal/daemon/doctor.go | 24 ++++++ internal/model/types.go | 1 + internal/model/vm_defaults.go | 134 +++++++++++++++++++++++++++++ internal/model/vm_defaults_test.go | 107 +++++++++++++++++++++++ 10 files changed, 594 insertions(+), 56 deletions(-) create mode 100644 internal/cli/vm_spec_test.go create mode 100644 internal/model/vm_defaults.go create mode 100644 internal/model/vm_defaults_test.go diff --git a/README.md b/README.md index ac797ae..953e241 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,28 @@ Most commonly set: Full key list in `internal/config/config.go`. +### `vm_defaults` — sizing for new VMs + +Every `vm run` / `vm create` prints a `spec:` line up front showing +the vCPU, RAM, and disk the VM will get. When the flags aren't set, +those values come from: + +1. `[vm_defaults]` in config (if present, wins). +2. Host-derived heuristics (roughly: `cpus/4` capped at 4, `ram/8` + capped at 8 GiB, 8 GiB disk). +3. Built-in constants (floor). + +`banger doctor` prints the effective defaults with provenance. + +```toml +[vm_defaults] +vcpu = 4 +memory_mib = 4096 +disk_size = "16G" +``` + +All keys optional — omit whichever you want banger to decide. + ### `file_sync` — host → guest file copies ```toml diff --git a/internal/cli/banger.go b/internal/cli/banger.go index 9b794db..032662d 100644 --- a/internal/cli/banger.go +++ b/internal/cli/banger.go @@ -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 diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index c3d496c..c9b26cc 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -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") } } diff --git a/internal/cli/vm_spec_test.go b/internal/cli/vm_spec_test.go new file mode 100644 index 0000000..50614fd --- /dev/null +++ b/internal/cli/vm_spec_test.go @@ -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()) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 2692279..ecb8923 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -34,6 +34,7 @@ type fileConfig struct { TapPoolSize int `toml:"tap_pool_size"` DefaultDNS string `toml:"default_dns"` FileSync []fileSyncEntryFile `toml:"file_sync"` + VMDefaults *vmDefaultsFile `toml:"vm_defaults"` } type fileSyncEntryFile struct { @@ -42,6 +43,16 @@ type fileSyncEntryFile struct { Mode string `toml:"mode"` } +// vmDefaultsFile mirrors the optional `[vm_defaults]` block. All +// fields are zero-valued when omitted; the resolver treats zero as +// "not set, compute from host or fall back to builtin constants." +type vmDefaultsFile struct { + VCPUCount int `toml:"vcpu"` + MemoryMiB int `toml:"memory_mib"` + DiskSize string `toml:"disk_size"` + SystemOverlaySize string `toml:"system_overlay_size"` +} + func Load(layout paths.Layout) (model.DaemonConfig, error) { cfg := model.DaemonConfig{ LogLevel: "info", @@ -140,9 +151,48 @@ func Load(layout paths.Layout) (model.DaemonConfig, error) { } cfg.FileSync = append(cfg.FileSync, validated) } + + if file.VMDefaults != nil { + override, err := parseVMDefaults(*file.VMDefaults) + if err != nil { + return cfg, fmt.Errorf("vm_defaults: %w", err) + } + cfg.VMDefaults = override + } return cfg, nil } +// parseVMDefaults validates and translates the TOML block into the +// model-level override struct. Negative values are rejected outright; +// zero means "not set." +func parseVMDefaults(file vmDefaultsFile) (model.VMDefaultsOverride, error) { + override := model.VMDefaultsOverride{ + VCPUCount: file.VCPUCount, + MemoryMiB: file.MemoryMiB, + } + if override.VCPUCount < 0 { + return model.VMDefaultsOverride{}, fmt.Errorf("vcpu must be >= 0 (got %d)", override.VCPUCount) + } + if override.MemoryMiB < 0 { + return model.VMDefaultsOverride{}, fmt.Errorf("memory_mib must be >= 0 (got %d)", override.MemoryMiB) + } + if value := strings.TrimSpace(file.DiskSize); value != "" { + bytes, err := model.ParseSize(value) + if err != nil { + return model.VMDefaultsOverride{}, fmt.Errorf("disk_size: %w", err) + } + override.WorkDiskSizeBytes = bytes + } + if value := strings.TrimSpace(file.SystemOverlaySize); value != "" { + bytes, err := model.ParseSize(value) + if err != nil { + return model.VMDefaultsOverride{}, fmt.Errorf("system_overlay_size: %w", err) + } + override.SystemOverlaySizeByte = bytes + } + return override, nil +} + // validateFileSyncEntry normalises a single `[[file_sync]]` entry // and rejects anything the operator would regret later: empty // paths, unsupported leading characters, path traversal, or diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 05699d7..b22f63c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -205,3 +205,65 @@ func TestLoadRejectsInvalidFileSyncEntries(t *testing.T) { }) } } + +func TestLoadAcceptsVMDefaults(t *testing.T) { + configDir := t.TempDir() + data := []byte(` +[vm_defaults] +vcpu = 4 +memory_mib = 4096 +disk_size = "16G" +system_overlay_size = "12G" +`) + if err := os.WriteFile(filepath.Join(configDir, "config.toml"), data, 0o644); err != nil { + t.Fatal(err) + } + cfg, err := Load(paths.Layout{ConfigDir: configDir}) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.VMDefaults.VCPUCount != 4 { + t.Errorf("VCPUCount = %d, want 4", cfg.VMDefaults.VCPUCount) + } + if cfg.VMDefaults.MemoryMiB != 4096 { + t.Errorf("MemoryMiB = %d, want 4096", cfg.VMDefaults.MemoryMiB) + } + if cfg.VMDefaults.WorkDiskSizeBytes != 16*1024*1024*1024 { + t.Errorf("WorkDiskSizeBytes = %d, want 16 GiB", cfg.VMDefaults.WorkDiskSizeBytes) + } + if cfg.VMDefaults.SystemOverlaySizeByte != 12*1024*1024*1024 { + t.Errorf("SystemOverlaySizeByte = %d, want 12 GiB", cfg.VMDefaults.SystemOverlaySizeByte) + } +} + +func TestLoadEmptyVMDefaultsLeavesZeros(t *testing.T) { + // No [vm_defaults] block → cfg.VMDefaults is the zero value, + // which the resolver will map to auto or builtin. + cfg, err := Load(paths.Layout{ConfigDir: t.TempDir()}) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.VMDefaults.VCPUCount != 0 || cfg.VMDefaults.MemoryMiB != 0 { + t.Errorf("VMDefaults = %+v, want zeroed", cfg.VMDefaults) + } +} + +func TestLoadRejectsNegativeVMDefaults(t *testing.T) { + cases := map[string]string{ + "vcpu": `[vm_defaults]` + "\n" + `vcpu = -1`, + "memory": `[vm_defaults]` + "\n" + `memory_mib = -1`, + "disk_size": `[vm_defaults]` + "\n" + `disk_size = "banana"`, + "overlay": `[vm_defaults]` + "\n" + `system_overlay_size = "banana"`, + } + for name, body := range cases { + t.Run(name, func(t *testing.T) { + configDir := t.TempDir() + if err := os.WriteFile(filepath.Join(configDir, "config.toml"), []byte(body+"\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Load(paths.Layout{ConfigDir: configDir}); err == nil { + t.Fatal("expected error") + } + }) + } +} diff --git a/internal/daemon/doctor.go b/internal/daemon/doctor.go index 6a53494..1dfe608 100644 --- a/internal/daemon/doctor.go +++ b/internal/daemon/doctor.go @@ -2,6 +2,7 @@ package daemon import ( "context" + "fmt" "strings" "banger/internal/config" @@ -40,11 +41,34 @@ func (d *Daemon) doctorReport(ctx context.Context) system.Report { report.AddPreflight("host runtime", d.runtimeChecks(), runtimeStatus(d.config)) report.AddPreflight("core vm lifecycle", d.coreVMLifecycleChecks(), "required host tools available") report.AddPreflight("vsock guest agent", d.vsockChecks(), "vsock guest agent prerequisites available") + d.addVMDefaultsCheck(&report) d.addCapabilityDoctorChecks(ctx, &report) return report } +// addVMDefaultsCheck surfaces the effective VM sizing that `vm run` / +// `vm create` will apply when the user omits the flags. Shown as a +// PASS check so it always renders, with per-field provenance +// (config|auto|builtin) so users can tell what's driving each number. +func (d *Daemon) addVMDefaultsCheck(report *system.Report) { + host, err := system.ReadHostResources() + var cpus int + var memBytes int64 + if err == nil { + cpus = host.CPUCount + memBytes = host.TotalMemoryBytes + } + defaults := model.ResolveVMDefaults(d.config.VMDefaults, cpus, memBytes) + details := []string{ + fmt.Sprintf("vcpu: %d (%s)", defaults.VCPUCount, defaults.VCPUSource), + fmt.Sprintf("memory: %d MiB (%s)", defaults.MemoryMiB, defaults.MemorySource), + fmt.Sprintf("disk: %s (%s)", model.FormatSizeBytes(defaults.WorkDiskSizeBytes), defaults.WorkDiskSource), + "override any of these in ~/.config/banger/config.toml under [vm_defaults]", + } + report.AddPass("vm defaults", details...) +} + func (d *Daemon) runtimeChecks() *system.Preflight { checks := system.NewPreflight() checks.RequireExecutable(d.config.FirecrackerBin, "firecracker binary", `install firecracker or set "firecracker_bin"`) diff --git a/internal/model/types.go b/internal/model/types.go index ef87a0e..673ac3d 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -66,6 +66,7 @@ type DaemonConfig struct { DefaultDNS string DefaultImageName string FileSync []FileSyncEntry + VMDefaults VMDefaultsOverride } // FileSyncEntry is a user-declared host→guest file or directory copy diff --git a/internal/model/vm_defaults.go b/internal/model/vm_defaults.go new file mode 100644 index 0000000..24e783e --- /dev/null +++ b/internal/model/vm_defaults.go @@ -0,0 +1,134 @@ +package model + +import "fmt" + +// VMDefaults captures the baseline sizing applied to a new VM when the +// user omits the corresponding --vcpu / --memory / --disk-size flags. +// Each field carries a Source tag explaining where the number came +// from so UI layers can surface provenance ("auto" vs "config" vs +// "built-in default"). +type VMDefaults struct { + VCPUCount int + MemoryMiB int + WorkDiskSizeBytes int64 + SystemOverlaySizeByte int64 + + // Source describes which layer won for each field, one of: + // "config" — user set it in config.toml + // "auto" — computed from host resources + // "builtin"— hardcoded fallback + VCPUSource string + MemorySource string + WorkDiskSource string + SystemOverlaySource string +} + +// VMDefaultsOverride is the optional block users can place in +// config.toml's [vm_defaults]. Zero-value fields mean "not set, let +// banger decide." +type VMDefaultsOverride struct { + VCPUCount int + MemoryMiB int + WorkDiskSizeBytes int64 + SystemOverlaySizeByte int64 +} + +// ResolveVMDefaults picks effective VM defaults from (in order) the +// user's config overrides, then host-derived heuristics, then baked-in +// constants. hostCPUs and hostMemoryBytes are what `system.ReadHost +// Resources` reports; 0 on either is treated as "unknown" and skipped, +// which pushes that field down to the builtin fallback. +func ResolveVMDefaults(override VMDefaultsOverride, hostCPUs int, hostMemoryBytes int64) VMDefaults { + d := VMDefaults{ + VCPUCount: DefaultVCPUCount, + MemoryMiB: DefaultMemoryMiB, + WorkDiskSizeBytes: DefaultWorkDiskSize, + SystemOverlaySizeByte: DefaultSystemOverlaySize, + VCPUSource: "builtin", + MemorySource: "builtin", + WorkDiskSource: "builtin", + SystemOverlaySource: "builtin", + } + + // vCPU: config > auto > builtin. + switch { + case override.VCPUCount > 0: + d.VCPUCount = override.VCPUCount + d.VCPUSource = "config" + case hostCPUs > 0: + d.VCPUCount = autoVCPU(hostCPUs) + d.VCPUSource = "auto" + } + + // Memory MiB: config > auto > builtin. + switch { + case override.MemoryMiB > 0: + d.MemoryMiB = override.MemoryMiB + d.MemorySource = "config" + case hostMemoryBytes > 0: + d.MemoryMiB = autoMemoryMiB(hostMemoryBytes) + d.MemorySource = "auto" + } + + // Work disk: config > builtin. Disk is a COW overlay — growing + // the allocation with host RAM gives nothing useful, so no auto. + if override.WorkDiskSizeBytes > 0 { + d.WorkDiskSizeBytes = override.WorkDiskSizeBytes + d.WorkDiskSource = "config" + } + + // System overlay: config > builtin. + if override.SystemOverlaySizeByte > 0 { + d.SystemOverlaySizeByte = override.SystemOverlaySizeByte + d.SystemOverlaySource = "config" + } + + return d +} + +// autoVCPU clamps cpus/4 into [1, 4]. A 2-vcpu sandbox is the sweet +// spot for most dev loops; going higher rarely helps interactive use +// and starves the host of cores. +func autoVCPU(hostCPUs int) int { + candidate := hostCPUs / 4 + if candidate < 1 { + candidate = 1 + } + if candidate > 4 { + candidate = 4 + } + return candidate +} + +// autoMemoryMiB caps at host/8, floor 1 GiB, ceiling 8 GiB. 1/8 leaves +// plenty of headroom for the host even if several VMs run +// concurrently; 8 GiB is enough for most language toolchains without +// being hostile on 32 GiB laptops. +func autoMemoryMiB(hostMemoryBytes int64) int { + const ( + mib = int64(1024 * 1024) + gib = 1024 * mib + floorMiB = 1024 // 1 GiB + cappedMiB = 8 * 1024 // 8 GiB + ) + candidate := hostMemoryBytes / 8 / mib + if candidate < floorMiB { + candidate = floorMiB + } + if candidate > cappedMiB { + candidate = cappedMiB + } + // Round down to 256 MiB multiples for tidier output. + candidate -= candidate % 256 + if candidate < floorMiB { + candidate = floorMiB + } + return int(candidate) +} + +// FormatSpecLine renders a one-line summary of VM sizing suitable for +// progress output or doctor display. +func (d VMDefaults) FormatSpecLine() string { + return fmt.Sprintf("%d vcpu · %d MiB · %s disk", + d.VCPUCount, d.MemoryMiB, FormatSizeBytes(d.WorkDiskSizeBytes)) +} diff --git a/internal/model/vm_defaults_test.go b/internal/model/vm_defaults_test.go new file mode 100644 index 0000000..f7f47d8 --- /dev/null +++ b/internal/model/vm_defaults_test.go @@ -0,0 +1,107 @@ +package model + +import ( + "strings" + "testing" +) + +func TestResolveVMDefaultsBuiltinFallback(t *testing.T) { + // No config override, no host info → every field is "builtin". + d := ResolveVMDefaults(VMDefaultsOverride{}, 0, 0) + + if d.VCPUCount != DefaultVCPUCount || d.VCPUSource != "builtin" { + t.Errorf("vcpu = %d (%s), want %d (builtin)", d.VCPUCount, d.VCPUSource, DefaultVCPUCount) + } + if d.MemoryMiB != DefaultMemoryMiB || d.MemorySource != "builtin" { + t.Errorf("memory = %d (%s), want %d (builtin)", d.MemoryMiB, d.MemorySource, DefaultMemoryMiB) + } + if d.WorkDiskSizeBytes != DefaultWorkDiskSize || d.WorkDiskSource != "builtin" { + t.Errorf("disk = %d (%s), want %d (builtin)", d.WorkDiskSizeBytes, d.WorkDiskSource, DefaultWorkDiskSize) + } +} + +func TestResolveVMDefaultsAutoFromHost(t *testing.T) { + // 8 host cores, 16 GiB RAM → 2 vcpus, 2 GiB memory. + d := ResolveVMDefaults(VMDefaultsOverride{}, 8, 16*gib) + + if d.VCPUCount != 2 || d.VCPUSource != "auto" { + t.Errorf("vcpu = %d (%s), want 2 (auto)", d.VCPUCount, d.VCPUSource) + } + if d.MemoryMiB != 2048 || d.MemorySource != "auto" { + t.Errorf("memory = %d (%s), want 2048 (auto)", d.MemoryMiB, d.MemorySource) + } + // Disk has no auto policy — still builtin. + if d.WorkDiskSource != "builtin" { + t.Errorf("disk source = %s, want builtin", d.WorkDiskSource) + } +} + +func TestResolveVMDefaultsConfigWinsOverAuto(t *testing.T) { + override := VMDefaultsOverride{VCPUCount: 6, MemoryMiB: 4096, WorkDiskSizeBytes: 16 * gib} + d := ResolveVMDefaults(override, 8, 16*gib) + + if d.VCPUCount != 6 || d.VCPUSource != "config" { + t.Errorf("vcpu = %d (%s), want 6 (config)", d.VCPUCount, d.VCPUSource) + } + if d.MemoryMiB != 4096 || d.MemorySource != "config" { + t.Errorf("memory = %d (%s), want 4096 (config)", d.MemoryMiB, d.MemorySource) + } + if d.WorkDiskSizeBytes != 16*gib || d.WorkDiskSource != "config" { + t.Errorf("disk = %d (%s), want 16*gib (config)", d.WorkDiskSizeBytes, d.WorkDiskSource) + } +} + +func TestAutoVCPUClamps(t *testing.T) { + cases := []struct { + host, want int + }{ + {1, 1}, // floor + {2, 1}, + {4, 1}, + {5, 1}, + {7, 1}, + {8, 2}, + {16, 4}, + {32, 4}, // ceiling + {128, 4}, // ceiling sticks + } + for _, tc := range cases { + if got := autoVCPU(tc.host); got != tc.want { + t.Errorf("autoVCPU(%d) = %d, want %d", tc.host, got, tc.want) + } + } +} + +func TestAutoMemoryCappedAndFloor(t *testing.T) { + // 4 GiB host → floor 1024 MiB. + if got := autoMemoryMiB(4 * gib); got != 1024 { + t.Errorf("4 GiB → got %d, want 1024", got) + } + // 32 GiB host → 32/8 = 4 GiB = 4096 MiB. + if got := autoMemoryMiB(32 * gib); got != 4096 { + t.Errorf("32 GiB → got %d, want 4096", got) + } + // 128 GiB host → 128/8 = 16 GiB, capped at 8 GiB = 8192 MiB. + if got := autoMemoryMiB(128 * gib); got != 8192 { + t.Errorf("128 GiB → got %d, want 8192", got) + } +} + +func TestAutoMemoryRoundsTo256MiB(t *testing.T) { + // 17 GiB host → 17/8 = 2.125 GiB ≈ 2176 MiB → rounded to 2048. + if got := autoMemoryMiB(17 * gib); got%256 != 0 { + t.Errorf("%d MiB not a 256 multiple", got) + } +} + +func TestFormatSpecLine(t *testing.T) { + d := VMDefaults{VCPUCount: 2, MemoryMiB: 2048, WorkDiskSizeBytes: 8 * gib} + line := d.FormatSpecLine() + for _, want := range []string{"2 vcpu", "2048 MiB", "disk"} { + if !strings.Contains(line, want) { + t.Errorf("line %q missing %q", line, want) + } + } +} + +const gib = int64(1024 * 1024 * 1024)