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:
Thales Maciel 2026-04-19 13:06:51 -03:00
parent 78ff482bfa
commit 21b74639d8
No known key found for this signature in database
GPG key ID: 33112E6833C34679
10 changed files with 594 additions and 56 deletions

View file

@ -134,6 +134,28 @@ Most commonly set:
Full key list in `internal/config/config.go`. 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 ### `file_sync` — host → guest file copies
```toml ```toml

View file

@ -750,13 +750,14 @@ func newVMCommand() *cobra.Command {
} }
func newVMRunCommand() *cobra.Command { func newVMRunCommand() *cobra.Command {
defaults := effectiveVMDefaults()
var ( var (
name string name string
imageName string imageName string
vcpu = model.DefaultVCPUCount vcpu = defaults.VCPUCount
memory = model.DefaultMemoryMiB memory = defaults.MemoryMiB
systemOverlaySize = model.FormatSizeBytes(model.DefaultSystemOverlaySize) systemOverlaySize = model.FormatSizeBytes(defaults.SystemOverlaySizeByte)
workDiskSize = model.FormatSizeBytes(model.DefaultWorkDiskSize) workDiskSize = model.FormatSizeBytes(defaults.WorkDiskSizeBytes)
natEnabled bool natEnabled bool
branchName string branchName string
fromRef = "HEAD" fromRef = "HEAD"
@ -842,10 +843,10 @@ Three modes:
} }
cmd.Flags().StringVar(&name, "name", "", "vm name") 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().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(&vcpu, "vcpu", defaults.VCPUCount, "vcpu count")
cmd.Flags().IntVar(&memory, "memory", model.DefaultMemoryMiB, "memory in MiB") cmd.Flags().IntVar(&memory, "memory", defaults.MemoryMiB, "memory in MiB")
cmd.Flags().StringVar(&systemOverlaySize, "system-overlay-size", model.FormatSizeBytes(model.DefaultSystemOverlaySize), "system overlay size") cmd.Flags().StringVar(&systemOverlaySize, "system-overlay-size", model.FormatSizeBytes(defaults.SystemOverlaySizeByte), "system overlay size")
cmd.Flags().StringVar(&workDiskSize, "disk-size", model.FormatSizeBytes(model.DefaultWorkDiskSize), "work disk 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(&natEnabled, "nat", false, "enable NAT")
cmd.Flags().StringVar(&branchName, "branch", "", "create and switch to a new guest branch") cmd.Flags().StringVar(&branchName, "branch", "", "create and switch to a new guest branch")
cmd.Flags().StringVar(&fromRef, "from", "HEAD", "base ref for --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 { func newVMCreateCommand() *cobra.Command {
defaults := effectiveVMDefaults()
var ( var (
name string name string
imageName string imageName string
vcpu = model.DefaultVCPUCount vcpu = defaults.VCPUCount
memory = model.DefaultMemoryMiB memory = defaults.MemoryMiB
systemOverlaySize = model.FormatSizeBytes(model.DefaultSystemOverlaySize) systemOverlaySize = model.FormatSizeBytes(defaults.SystemOverlaySizeByte)
workDiskSize = model.FormatSizeBytes(model.DefaultWorkDiskSize) workDiskSize = model.FormatSizeBytes(defaults.WorkDiskSizeBytes)
natEnabled bool natEnabled bool
noStart bool noStart bool
) )
@ -1033,10 +1035,10 @@ func newVMCreateCommand() *cobra.Command {
} }
cmd.Flags().StringVar(&name, "name", "", "vm name") 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().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(&vcpu, "vcpu", defaults.VCPUCount, "vcpu count")
cmd.Flags().IntVar(&memory, "memory", model.DefaultMemoryMiB, "memory in MiB") cmd.Flags().IntVar(&memory, "memory", defaults.MemoryMiB, "memory in MiB")
cmd.Flags().StringVar(&systemOverlaySize, "system-overlay-size", model.FormatSizeBytes(model.DefaultSystemOverlaySize), "system overlay size") cmd.Flags().StringVar(&systemOverlaySize, "system-overlay-size", model.FormatSizeBytes(defaults.SystemOverlaySizeByte), "system overlay size")
cmd.Flags().StringVar(&workDiskSize, "disk-size", model.FormatSizeBytes(model.DefaultWorkDiskSize), "work disk 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(&natEnabled, "nat", false, "enable NAT")
cmd.Flags().BoolVar(&noStart, "no-start", false, "create without starting") cmd.Flags().BoolVar(&noStart, "no-start", false, "create without starting")
_ = cmd.RegisterFlagCompletionFunc("image", completeImageNames) _ = 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) { 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{ params := api.VMCreateParams{
Name: name, Name: name,
ImageName: imageName, ImageName: imageName,
NATEnabled: natEnabled, NATEnabled: natEnabled,
NoStart: noStart, NoStart: noStart,
} VCPUCount: &vcpu,
if cmd.Flags().Changed("vcpu") { MemoryMiB: &memory,
if err := validatePositiveSetting("vcpu", vcpu); err != nil { SystemOverlaySize: systemOverlaySize,
return api.VMCreateParams{}, err WorkDiskSize: workDiskSize,
}
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
} }
return params, nil 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 { func validatePositiveSetting(label string, value int) error {
if value <= 0 { if value <= 0 {
return fmt.Errorf("%s must be a positive integer", label) 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) { 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) begin, err := vmCreateBeginFunc(ctx, socketPath, params)
if err != nil { if err != nil {
return model.VMRecord{}, err return model.VMRecord{}, err

View file

@ -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() root := NewBangerCommand()
vm, _, err := root.Find([]string{"vm"}) vm, _, err := root.Find([]string{"vm"})
if err != nil { if err != nil {
@ -279,17 +283,23 @@ func TestVMCreateFlagsShowStaticDefaults(t *testing.T) {
t.Fatalf("find create: %v", err) t.Fatalf("find create: %v", err)
} }
if got := create.Flags().Lookup("vcpu").DefValue; got != fmt.Sprintf("%d", model.DefaultVCPUCount) { for _, flagName := range []string{"vcpu", "memory"} {
t.Fatalf("vcpu default = %q, want %d", got, model.DefaultVCPUCount) 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) { for _, flagName := range []string{"system-overlay-size", "disk-size"} {
t.Fatalf("memory default = %q, want %d", got, model.DefaultMemoryMiB) flag := create.Flags().Lookup(flagName)
} if flag == nil {
if got := create.Flags().Lookup("system-overlay-size").DefValue; got != model.FormatSizeBytes(model.DefaultSystemOverlaySize) { t.Fatalf("flag %q missing", flagName)
t.Fatalf("system-overlay-size default = %q, want %q", got, model.FormatSizeBytes(model.DefaultSystemOverlaySize)) }
} if !strings.ContainsAny(flag.DefValue, "GMK") {
if got := create.Flags().Lookup("disk-size").DefValue; got != model.FormatSizeBytes(model.DefaultWorkDiskSize) { t.Errorf("flag %q default = %q, want a formatted size like '8G'", flagName, flag.DefValue)
t.Fatalf("disk-size default = %q, want %q", got, model.FormatSizeBytes(model.DefaultWorkDiskSize)) }
} }
} }
@ -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() cmd := NewBangerCommand()
vm, _, err := cmd.Find([]string{"vm"}) vm, _, err := cmd.Find([]string{"vm"})
if err != nil { if err != nil {
@ -400,18 +414,46 @@ func TestVMCreateParamsFromFlagsOmitsStaticDefaultsWhenFlagsAreUnchanged(t *test
create, create,
"devbox", "devbox",
"default", "default",
model.DefaultVCPUCount, 3,
model.DefaultMemoryMiB, 4096,
model.FormatSizeBytes(model.DefaultSystemOverlaySize), "10G",
model.FormatSizeBytes(model.DefaultWorkDiskSize), "20G",
false, false,
false, false,
) )
if err != nil { if err != nil {
t.Fatalf("vmCreateParamsFromFlags: %v", err) t.Fatalf("vmCreateParamsFromFlags: %v", err)
} }
if params.VCPUCount != nil || params.MemoryMiB != nil || params.SystemOverlaySize != "" || params.WorkDiskSize != "" { if params.VCPUCount == nil || *params.VCPUCount != 3 {
t.Fatalf("expected unchanged defaults to stay omitted: %+v", params) 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")
} }
} }

View 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())
}
}

View file

@ -34,6 +34,7 @@ type fileConfig struct {
TapPoolSize int `toml:"tap_pool_size"` TapPoolSize int `toml:"tap_pool_size"`
DefaultDNS string `toml:"default_dns"` DefaultDNS string `toml:"default_dns"`
FileSync []fileSyncEntryFile `toml:"file_sync"` FileSync []fileSyncEntryFile `toml:"file_sync"`
VMDefaults *vmDefaultsFile `toml:"vm_defaults"`
} }
type fileSyncEntryFile struct { type fileSyncEntryFile struct {
@ -42,6 +43,16 @@ type fileSyncEntryFile struct {
Mode string `toml:"mode"` 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) { func Load(layout paths.Layout) (model.DaemonConfig, error) {
cfg := model.DaemonConfig{ cfg := model.DaemonConfig{
LogLevel: "info", LogLevel: "info",
@ -140,9 +151,48 @@ func Load(layout paths.Layout) (model.DaemonConfig, error) {
} }
cfg.FileSync = append(cfg.FileSync, validated) 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 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 // validateFileSyncEntry normalises a single `[[file_sync]]` entry
// and rejects anything the operator would regret later: empty // and rejects anything the operator would regret later: empty
// paths, unsupported leading characters, path traversal, or // paths, unsupported leading characters, path traversal, or

View file

@ -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")
}
})
}
}

View file

@ -2,6 +2,7 @@ package daemon
import ( import (
"context" "context"
"fmt"
"strings" "strings"
"banger/internal/config" "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("host runtime", d.runtimeChecks(), runtimeStatus(d.config))
report.AddPreflight("core vm lifecycle", d.coreVMLifecycleChecks(), "required host tools available") report.AddPreflight("core vm lifecycle", d.coreVMLifecycleChecks(), "required host tools available")
report.AddPreflight("vsock guest agent", d.vsockChecks(), "vsock guest agent prerequisites available") report.AddPreflight("vsock guest agent", d.vsockChecks(), "vsock guest agent prerequisites available")
d.addVMDefaultsCheck(&report)
d.addCapabilityDoctorChecks(ctx, &report) d.addCapabilityDoctorChecks(ctx, &report)
return 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 { func (d *Daemon) runtimeChecks() *system.Preflight {
checks := system.NewPreflight() checks := system.NewPreflight()
checks.RequireExecutable(d.config.FirecrackerBin, "firecracker binary", `install firecracker or set "firecracker_bin"`) checks.RequireExecutable(d.config.FirecrackerBin, "firecracker binary", `install firecracker or set "firecracker_bin"`)

View file

@ -66,6 +66,7 @@ type DaemonConfig struct {
DefaultDNS string DefaultDNS string
DefaultImageName string DefaultImageName string
FileSync []FileSyncEntry FileSync []FileSyncEntry
VMDefaults VMDefaultsOverride
} }
// FileSyncEntry is a user-declared host→guest file or directory copy // FileSyncEntry is a user-declared host→guest file or directory copy

View 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))
}

View 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)