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
|
|
@ -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
|
||||
|
|
|
|||
134
internal/model/vm_defaults.go
Normal file
134
internal/model/vm_defaults.go
Normal file
|
|
@ -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))
|
||||
}
|
||||
107
internal/model/vm_defaults_test.go
Normal file
107
internal/model/vm_defaults_test.go
Normal file
|
|
@ -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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue