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
22
README.md
22
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"`)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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