Merge model,cli,docs polish for v0.1.0

# Conflicts:
#	internal/cli/commands_image.go
This commit is contained in:
Thales Maciel 2026-04-28 17:36:47 -03:00
commit f7a6832ebf
No known key found for this signature in database
GPG key ID: 33112E6833C34679
6 changed files with 196 additions and 42 deletions

View file

@ -215,7 +215,7 @@ Use 'banger image list' to see installed images.
cmd.Flags().StringVar(&params.InitrdPath, "initrd", "", "initrd path")
cmd.Flags().StringVar(&params.ModulesDir, "modules", "", "modules dir")
cmd.Flags().StringVar(&params.KernelRef, "kernel-ref", "", "name of a cataloged kernel (see 'banger kernel list')")
cmd.Flags().StringVar(&sizeRaw, "size", "", "ext4 image size (e.g. 4G); defaults to content + 25%, min 1GiB")
cmd.Flags().StringVar(&sizeRaw, "size", "", "ext4 image size, e.g. 4GiB, 512M, 2G (defaults to content + 25%, min 1GiB)")
_ = cmd.RegisterFlagCompletionFunc("kernel-ref", d.completeKernelNames)
return cmd
}

View file

@ -891,7 +891,8 @@ Pipe into 'jq' for quick field extraction, e.g. banger vm stats dev | jq .mem.
}
func (d *deps) newVMPortsCommand() *cobra.Command {
return &cobra.Command{
var jsonOut bool
cmd := &cobra.Command{
Use: "ports <id-or-name>",
Short: "Show host-reachable listening guest ports",
Args: exactArgsUsage(1, "usage: banger vm ports <id-or-name>"),
@ -905,9 +906,14 @@ func (d *deps) newVMPortsCommand() *cobra.Command {
if err != nil {
return err
}
if jsonOut {
return printJSON(cmd.OutOrStdout(), result)
}
return printVMPortsTable(cmd.OutOrStdout(), result)
},
}
cmd.Flags().BoolVar(&jsonOut, "json", false, "print ports as JSON instead of a table")
return cmd
}
type resolvedVMTarget struct {

View file

@ -240,23 +240,26 @@ func ParseSize(raw string) (int64, error) {
if raw == "" {
return 0, errors.New("size is required")
}
unit := raw[len(raw)-1]
// Strip an optional "IB" suffix so that "GiB", "MiB", "KiB" work the
// same as "G", "M", "K" (case-insensitive after ToUpper).
number := strings.TrimSuffix(raw, "IB")
unit := number[len(number)-1]
multiplier := int64(1024 * 1024)
number := raw
switch unit {
case 'K':
multiplier = 1024
number = raw[:len(raw)-1]
number = number[:len(number)-1]
case 'M':
multiplier = 1024 * 1024
number = raw[:len(raw)-1]
number = number[:len(number)-1]
case 'G':
multiplier = 1024 * 1024 * 1024
number = raw[:len(raw)-1]
number = number[:len(number)-1]
default:
if unit < '0' || unit > '9' {
return 0, fmt.Errorf("unsupported size suffix: %q", string(unit))
}
number = raw // no suffix stripped — keep original digits-only string
}
value, err := strconv.ParseInt(number, 10, 64)
if err != nil {

View file

@ -0,0 +1,135 @@
package model
import (
"strings"
"testing"
)
func TestParseSize(t *testing.T) {
const (
kib = int64(1024)
mib = int64(1024 * 1024)
gib = int64(1024 * 1024 * 1024)
)
cases := []struct {
name string
input string
want int64
wantErrSub string
}{
// Happy path — short suffixes.
{"1G", "1G", gib, ""},
{"512M", "512M", 512 * mib, ""},
{"4K", "4K", 4 * kib, ""},
{"4G", "4G", 4 * gib, ""},
// GiB/MiB/KiB suffixes — parser now accepts these.
{"4GiB", "4GiB", 4 * gib, ""},
{"512MiB", "512MiB", 512 * mib, ""},
{"4KiB", "4KiB", 4 * kib, ""},
// Lowercase — ToUpper normalises; should work like uppercase.
{"lowercase 1g", "1g", gib, ""},
{"lowercase 512m", "512m", 512 * mib, ""},
{"lowercase 4gib", "4gib", 4 * gib, ""},
// No-suffix — treated as MiB (the parser's default multiplier is 1 MiB).
// "1024" → 1024 MiB, "1" → 1 MiB.
{"no-suffix 1024", "1024", 1024 * mib, ""},
{"no-suffix 1", "1", mib, ""},
// Whitespace trimming.
{"leading space", " 2G", 2 * gib, ""},
{"trailing space", "2G ", 2 * gib, ""},
{"both spaces", " 2G ", 2 * gib, ""},
// Error cases.
{"empty string", "", 0, "required"},
{"whitespace only", " ", 0, "required"},
{"unknown suffix B", "512B", 0, "unsupported size suffix"},
{"negative", "-1G", 0, "positive"},
{"zero", "0G", 0, "positive"},
{"overflow MaxDiskBytes", "129G", 0, "exceeds max"},
{"non-numeric", "xG", 0, "parse size"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := ParseSize(tc.input)
if tc.wantErrSub != "" {
if err == nil {
t.Fatalf("ParseSize(%q) = %d, want error containing %q", tc.input, got, tc.wantErrSub)
}
if !strings.Contains(err.Error(), tc.wantErrSub) {
t.Fatalf("ParseSize(%q) error = %q, want substring %q", tc.input, err.Error(), tc.wantErrSub)
}
return
}
if err != nil {
t.Fatalf("ParseSize(%q) unexpected error: %v", tc.input, err)
}
if got != tc.want {
t.Fatalf("ParseSize(%q) = %d, want %d", tc.input, got, tc.want)
}
})
}
}
func TestFormatSizeBytes(t *testing.T) {
const (
kib = int64(1024)
mib = int64(1024 * 1024)
gib = int64(1024 * 1024 * 1024)
)
cases := []struct {
name string
input int64
want string
}{
// FormatSizeBytes(0): 0 is divisible by GiB so it formats as "0G".
{"0", 0, "0G"},
{"1 byte", 1, "1"},
{"1 KiB", kib, "1K"},
{"4 KiB", 4 * kib, "4K"},
{"1 MiB", mib, "1M"},
{"512 MiB", 512 * mib, "512M"},
{"1 GiB", gib, "1G"},
{"4 GiB", 4 * gib, "4G"},
{"128 GiB (max disk)", 128 * gib, "128G"},
// Non-round: falls through to raw bytes.
{"non-round bytes", 1500, "1500"},
{"non-round MiB", 3*mib + 1, "3145729"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := FormatSizeBytes(tc.input)
if got != tc.want {
t.Fatalf("FormatSizeBytes(%d) = %q, want %q", tc.input, got, tc.want)
}
})
}
}
func TestParseSizeFormatRoundTrip(t *testing.T) {
const (
kib = int64(1024)
mib = int64(1024 * 1024)
gib = int64(1024 * 1024 * 1024)
)
boundaries := []int64{kib, 4 * kib, mib, 512 * mib, gib, 4 * gib, 8 * gib}
for _, n := range boundaries {
formatted := FormatSizeBytes(n)
parsed, err := ParseSize(formatted)
if err != nil {
t.Errorf("ParseSize(FormatSizeBytes(%d) = %q): %v", n, formatted, err)
continue
}
if parsed != n {
t.Errorf("round-trip(%d): FormatSizeBytes → %q → ParseSize → %d", n, formatted, parsed)
}
}
}