Merge cli,docs polish for v0.1.0

Brings in commit 003b048 from the agent-2 worktree:
  - CLI help text + completers (image pull / kernel pull / vm stats /
    vm set / --from flag).
  - README golden-image definition + Requirements block above
    Quick Start.
  - Code hygiene: drop emptyDash; consolidate formatBytes into
    humanSize.
  - Logger downgrades for per-RPC INFO chatter.
This commit is contained in:
Thales Maciel 2026-04-28 17:35:06 -03:00
commit 26dbf0f221
No known key found for this signature in database
GPG key ID: 33112E6833C34679
10 changed files with 86 additions and 66 deletions

View file

@ -2,6 +2,8 @@
One-command development sandboxes on Firecracker microVMs. One-command development sandboxes on Firecracker microVMs.
**Requirements:** Linux + KVM (`/dev/kvm`), `firecracker` on PATH (or `firecracker_bin` in config).
## Quick start ## Quick start
```bash ```bash
@ -10,10 +12,10 @@ sudo ./build/bin/banger system install --owner "$USER"
banger vm run --name sandbox banger vm run --name sandbox
``` ```
That's it. `banger vm run` auto-pulls the default golden image (Debian That's it. `banger vm run` auto-pulls the default golden image (a pre-built
bookworm with systemd, sshd, Docker CE, git, jq, mise, and the usual Debian rootfs with sshd, mise, and the usual dev tools: Debian bookworm with
dev tools) and kernel, creates a VM, starts it, and drops you into systemd, sshd, Docker CE, git, jq, and mise) and kernel, creates a VM, starts
an interactive ssh session. First run takes a couple minutes (bundle it, and drops you into an interactive ssh session. First run takes a couple minutes (bundle
download); subsequent `vm run`s are seconds. download); subsequent `vm run`s are seconds.
## Supported host path ## Supported host path

View file

@ -106,7 +106,7 @@ in flight when you run prune, that pull may fail and need a retry.
verb = "would free" verb = "would free"
} }
_, err = fmt.Fprintf(out, "%s %s across %d blob(s) in %s\n", _, err = fmt.Fprintf(out, "%s %s across %d blob(s) in %s\n",
verb, formatBytes(result.BytesFreed), result.BlobsFreed, result.CacheDir) verb, humanSize(result.BytesFreed), result.BlobsFreed, result.CacheDir)
return err return err
}, },
} }
@ -114,26 +114,6 @@ in flight when you run prune, that pull may fail and need a retry.
return cmd return cmd
} }
// formatBytes renders a byte count as a short human-readable string
// (e.g. "1.2 GiB", "456 MiB"). Zero stays "0 B" for clarity.
func formatBytes(n int64) string {
const (
ki = 1024
mi = ki * 1024
gi = mi * 1024
)
switch {
case n >= gi:
return fmt.Sprintf("%.1f GiB", float64(n)/float64(gi))
case n >= mi:
return fmt.Sprintf("%.1f MiB", float64(n)/float64(mi))
case n >= ki:
return fmt.Sprintf("%.1f KiB", float64(n)/float64(ki))
default:
return fmt.Sprintf("%d B", n)
}
}
func (d *deps) newImageRegisterCommand() *cobra.Command { func (d *deps) newImageRegisterCommand() *cobra.Command {
var params api.ImageRegisterParams var params api.ImageRegisterParams
cmd := &cobra.Command{ cmd := &cobra.Command{
@ -175,8 +155,9 @@ func (d *deps) newImagePullCommand() *cobra.Command {
sizeRaw string sizeRaw string
) )
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "pull <name-or-oci-ref>", Use: "pull <name-or-oci-ref>",
Short: "Pull an image bundle (catalog name) or OCI image and register it", Short: "Pull an image bundle (catalog name) or OCI image and register it",
ValidArgsFunction: d.completeImageCatalogNameOnlyAtPos0,
Long: strings.TrimSpace(` Long: strings.TrimSpace(`
Pull an image into banger. Two paths: Pull an image into banger. Two paths:
@ -190,8 +171,7 @@ Pull an image into banger. Two paths:
banger's guest agents. --kernel-ref or direct --kernel/--initrd/ banger's guest agents. --kernel-ref or direct --kernel/--initrd/
--modules are required. --modules are required.
Use 'banger image catalog' to see available catalog names (once that Use 'banger image list' to see installed images.
subcommand lands).
`), `),
Example: strings.TrimSpace(` Example: strings.TrimSpace(`
banger image pull debian-bookworm banger image pull debian-bookworm
@ -235,7 +215,7 @@ subcommand lands).
cmd.Flags().StringVar(&params.InitrdPath, "initrd", "", "initrd path") cmd.Flags().StringVar(&params.InitrdPath, "initrd", "", "initrd path")
cmd.Flags().StringVar(&params.ModulesDir, "modules", "", "modules dir") 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(&params.KernelRef, "kernel-ref", "", "name of a cataloged kernel (see 'banger kernel list')")
cmd.Flags().StringVar(&sizeRaw, "size", "", "ext4 image size (e.g. 4GiB); defaults to content + 25%, min 1GiB") cmd.Flags().StringVar(&sizeRaw, "size", "", "ext4 image size (e.g. 4G); defaults to content + 25%, min 1GiB")
_ = cmd.RegisterFlagCompletionFunc("kernel-ref", d.completeKernelNames) _ = cmd.RegisterFlagCompletionFunc("kernel-ref", d.completeKernelNames)
return cmd return cmd
} }

View file

@ -54,9 +54,10 @@ Subcommands:
func (d *deps) newKernelPullCommand() *cobra.Command { func (d *deps) newKernelPullCommand() *cobra.Command {
var force bool var force bool
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "pull <name>", Use: "pull <name>",
Short: "Download a cataloged kernel bundle", Short: "Download a cataloged kernel bundle",
Args: exactArgsUsage(1, "usage: banger kernel pull <name> [--force]"), Args: exactArgsUsage(1, "usage: banger kernel pull <name> [--force]"),
ValidArgsFunction: d.completeKernelCatalogNameOnlyAtPos0,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := d.ensureDaemon(cmd.Context()) layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil { if err != nil {

View file

@ -185,7 +185,7 @@ Three modes:
cmd.Flags().StringVar(&workDiskSize, "disk-size", model.FormatSizeBytes(defaults.WorkDiskSizeBytes), "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", "git ref to branch from when --branch is set (default: HEAD)")
cmd.Flags().BoolVar(&removeOnExit, "rm", false, "delete the VM after the ssh session / command exits") cmd.Flags().BoolVar(&removeOnExit, "rm", false, "delete the VM after the ssh session / command exits")
cmd.Flags().BoolVar(&includeUntracked, "include-untracked", false, "also copy untracked non-ignored files into the guest workspace (default: tracked files only)") cmd.Flags().BoolVar(&includeUntracked, "include-untracked", false, "also copy untracked non-ignored files into the guest workspace (default: tracked files only)")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "list the files that would be copied into the guest workspace and exit without creating a VM") cmd.Flags().BoolVar(&dryRun, "dry-run", false, "list the files that would be copied into the guest workspace and exit without creating a VM")
@ -581,8 +581,15 @@ func (d *deps) newVMSetCommand() *cobra.Command {
noNat bool noNat bool
) )
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "set <id-or-name>...", Use: "set <id-or-name>...",
Short: "Update stopped VM settings", Short: "Update stopped VM settings",
Long: strings.TrimSpace(`
Reconfigure one or more stopped VMs. The VM must be stopped before
reconfiguring start it again with 'banger vm start' to apply the new settings.
`),
Example: strings.TrimSpace(`
banger vm set dev --vcpu 4 --memory 8192
`),
Args: minArgsUsage(1, "usage: banger vm set [--vcpu N] [--memory MiB] [--disk-size SIZE] [--nat|--no-nat] <id-or-name>..."), Args: minArgsUsage(1, "usage: banger vm set [--vcpu N] [--memory MiB] [--disk-size SIZE] [--nat|--no-nat] <id-or-name>..."),
ValidArgsFunction: d.completeVMNames, ValidArgsFunction: d.completeVMNames,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
@ -760,7 +767,7 @@ func (d *deps) newVMWorkspacePrepareCommand() *cobra.Command {
} }
cmd.Flags().StringVar(&guestPath, "guest-path", "/root/repo", "guest workspace path") cmd.Flags().StringVar(&guestPath, "guest-path", "/root/repo", "guest workspace path")
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", "git ref to branch from when --branch is set (default: HEAD)")
cmd.Flags().StringVar(&mode, "mode", string(model.WorkspacePrepareModeShallowOverlay), "workspace mode: shallow_overlay, full_copy, metadata_only") cmd.Flags().StringVar(&mode, "mode", string(model.WorkspacePrepareModeShallowOverlay), "workspace mode: shallow_overlay, full_copy, metadata_only")
cmd.Flags().BoolVar(&includeUntracked, "include-untracked", false, "also copy untracked non-ignored files into the guest workspace (default: tracked files only)") cmd.Flags().BoolVar(&includeUntracked, "include-untracked", false, "also copy untracked non-ignored files into the guest workspace (default: tracked files only)")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "list the files that would be copied and exit without touching the guest") cmd.Flags().BoolVar(&dryRun, "dry-run", false, "list the files that would be copied and exit without touching the guest")
@ -856,8 +863,17 @@ hanging on boot.
func (d *deps) newVMStatsCommand() *cobra.Command { func (d *deps) newVMStatsCommand() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "stats <id-or-name>", Use: "stats <id-or-name>",
Short: "Show VM stats", Short: "Show VM stats",
Long: strings.TrimSpace(`
Print real-time resource statistics for a running VM as a JSON object,
including CPU usage, memory balloon metrics, and disk I/O counters.
Pipe into 'jq' for quick field extraction, e.g. banger vm stats dev | jq .mem.
`),
Example: strings.TrimSpace(`
banger vm stats dev
banger vm stats dev | jq .
`),
Args: exactArgsUsage(1, "usage: banger vm stats <id-or-name>"), Args: exactArgsUsage(1, "usage: banger vm stats <id-or-name>"),
ValidArgsFunction: d.completeVMNameOnlyAtPos0, ValidArgsFunction: d.completeVMNameOnlyAtPos0,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {

View file

@ -160,3 +160,32 @@ func (d *deps) completeKernelNames(cmd *cobra.Command, args []string, toComplete
} }
return filterPrefix(names, args, toComplete), cobra.ShellCompDirectiveNoFileComp return filterPrefix(names, args, toComplete), cobra.ShellCompDirectiveNoFileComp
} }
// completeKernelCatalogNameOnlyAtPos0 completes kernel names from the
// remote catalog (pulled + available) at position 0 only.
func (d *deps) completeKernelCatalogNameOnlyAtPos0(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
socket, ok := d.daemonSocketForCompletion(cmd.Context())
if !ok {
return nil, cobra.ShellCompDirectiveNoFileComp
}
result, err := rpc.Call[api.KernelCatalogResult](cmd.Context(), socket, "kernel.catalog", api.Empty{})
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
names := make([]string, 0, len(result.Entries))
for _, entry := range result.Entries {
if entry.Name != "" {
names = append(names, entry.Name)
}
}
return filterPrefix(names, args, toComplete), cobra.ShellCompDirectiveNoFileComp
}
// completeImageCatalogNameOnlyAtPos0 falls back to the locally-installed
// image list (there is no remote image catalog RPC today).
func (d *deps) completeImageCatalogNameOnlyAtPos0(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return d.completeImageNameOnlyAtPos0(cmd, args, toComplete)
}

View file

@ -20,14 +20,14 @@ func TestHumanSize(t *testing.T) {
}{ }{
{-1, "-"}, {-1, "-"},
{0, "-"}, {0, "-"},
{1, "1B"}, {1, "1 B"},
{1023, "1023B"}, {1023, "1023 B"},
{1024, "1.0KiB"}, {1024, "1.0 KiB"},
{2048, "2.0KiB"}, {2048, "2.0 KiB"},
{1024 * 1024, "1.0MiB"}, {1024 * 1024, "1.0 MiB"},
{5 * 1024 * 1024, "5.0MiB"}, {5 * 1024 * 1024, "5.0 MiB"},
{1024 * 1024 * 1024, "1.0GiB"}, {1024 * 1024 * 1024, "1.0 GiB"},
{3 * 1024 * 1024 * 1024, "3.0GiB"}, {3 * 1024 * 1024 * 1024, "3.0 GiB"},
} }
for _, tc := range cases { for _, tc := range cases {
if got := humanSize(tc.bytes); got != tc.want { if got := humanSize(tc.bytes); got != tc.want {
@ -197,7 +197,7 @@ func TestPrintKernelCatalogTable(t *testing.T) {
t.Errorf("output missing %q:\n%s", want, got) t.Errorf("output missing %q:\n%s", want, got)
} }
} }
if !strings.Contains(got, "2.0MiB") { if !strings.Contains(got, "2.0 MiB") {
t.Errorf("expected humanSize(2 MiB), got:\n%s", got) t.Errorf("expected humanSize(2 MiB), got:\n%s", got)
} }
} }

View file

@ -35,13 +35,13 @@ func humanSize(bytes int64) string {
) )
switch { switch {
case bytes >= gib: case bytes >= gib:
return fmt.Sprintf("%.1fGiB", float64(bytes)/float64(gib)) return fmt.Sprintf("%.1f GiB", float64(bytes)/float64(gib))
case bytes >= mib: case bytes >= mib:
return fmt.Sprintf("%.1fMiB", float64(bytes)/float64(mib)) return fmt.Sprintf("%.1f MiB", float64(bytes)/float64(mib))
case bytes >= kib: case bytes >= kib:
return fmt.Sprintf("%.1fKiB", float64(bytes)/float64(kib)) return fmt.Sprintf("%.1f KiB", float64(bytes)/float64(kib))
default: default:
return fmt.Sprintf("%dB", bytes) return fmt.Sprintf("%d B", bytes)
} }
} }
@ -52,14 +52,6 @@ func dashIfEmpty(s string) string {
return s return s
} }
func emptyDash(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "-"
}
return value
}
// -- generic printers ----------------------------------------------- // -- generic printers -----------------------------------------------
func printJSON(out anyWriter, v any) error { func printJSON(out anyWriter, v any) error {
@ -165,9 +157,9 @@ func printVMPortsTable(out anyWriter, result api.VMPortsResult) error {
w, w,
"%s\t%s\t%s\t%s\n", "%s\t%s\t%s\t%s\n",
row.Proto, row.Proto,
emptyDash(row.Endpoint), dashIfEmpty(row.Endpoint),
emptyDash(row.Process), dashIfEmpty(row.Process),
emptyDash(row.Command), dashIfEmpty(row.Command),
); err != nil { ); err != nil {
return err return err
} }

View file

@ -353,7 +353,7 @@ func (d *Daemon) watchRequestDisconnect(conn net.Conn, reader *bufio.Reader, met
default: default:
} }
if d.logger != nil { if d.logger != nil {
d.logger.Info("daemon request canceled", "method", method, "remote", conn.RemoteAddr().String(), "error", err.Error()) d.logger.Debug("daemon request canceled", "method", method, "remote", conn.RemoteAddr().String(), "error", err.Error())
} }
cancel() cancel()
return return

View file

@ -66,7 +66,7 @@ func (d *Daemon) beginOperation(ctx context.Context, name string, attrs ...any)
allAttrs = append([]any{"op_id", opID}, allAttrs...) allAttrs = append([]any{"op_id", opID}, allAttrs...)
} }
if d.logger != nil { if d.logger != nil {
d.logger.Info("operation started", append([]any{"operation", name}, allAttrs...)...) d.logger.Debug("operation started", append([]any{"operation", name}, allAttrs...)...)
} }
now := time.Now() now := time.Now()
return &operationLog{ return &operationLog{

View file

@ -395,7 +395,7 @@ func (s *Server) handleConn(conn net.Conn) {
if !resp.OK && resp.Error != nil { if !resp.OK && resp.Error != nil {
s.logger.Error("helper rpc failed", "method", req.Method, "op_id", req.OpID, "duration_ms", duration, "code", resp.Error.Code, "message", resp.Error.Message) s.logger.Error("helper rpc failed", "method", req.Method, "op_id", req.OpID, "duration_ms", duration, "code", resp.Error.Code, "message", resp.Error.Message)
} else { } else {
s.logger.Info("helper rpc completed", "method", req.Method, "op_id", req.OpID, "duration_ms", duration) s.logger.Debug("helper rpc completed", "method", req.Method, "op_id", req.OpID, "duration_ms", duration)
} }
} }
_ = json.NewEncoder(conn).Encode(resp) _ = json.NewEncoder(conn).Encode(resp)