diff --git a/README.md b/README.md index 69a43d4..270e0e3 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ One-command development sandboxes on Firecracker microVMs. +**Requirements:** Linux + KVM (`/dev/kvm`), `firecracker` on PATH (or `firecracker_bin` in config). + ## Quick start ```bash @@ -10,10 +12,10 @@ sudo ./build/bin/banger system install --owner "$USER" banger vm run --name sandbox ``` -That's it. `banger vm run` auto-pulls the default golden image (Debian -bookworm with systemd, sshd, Docker CE, git, jq, mise, and the usual -dev tools) and kernel, creates a VM, starts it, and drops you into -an interactive ssh session. First run takes a couple minutes (bundle +That's it. `banger vm run` auto-pulls the default golden image (a pre-built +Debian rootfs with sshd, mise, and the usual dev tools: Debian bookworm with +systemd, sshd, Docker CE, git, jq, and mise) and kernel, creates a VM, starts +it, and drops you into an interactive ssh session. First run takes a couple minutes (bundle download); subsequent `vm run`s are seconds. ## Supported host path diff --git a/internal/cli/commands_image.go b/internal/cli/commands_image.go index fd9c65d..76ced4a 100644 --- a/internal/cli/commands_image.go +++ b/internal/cli/commands_image.go @@ -106,7 +106,7 @@ in flight when you run prune, that pull may fail and need a retry. verb = "would free" } _, 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 }, } @@ -114,26 +114,6 @@ in flight when you run prune, that pull may fail and need a retry. 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 { var params api.ImageRegisterParams cmd := &cobra.Command{ @@ -175,8 +155,9 @@ func (d *deps) newImagePullCommand() *cobra.Command { sizeRaw string ) cmd := &cobra.Command{ - Use: "pull ", - Short: "Pull an image bundle (catalog name) or OCI image and register it", + Use: "pull ", + Short: "Pull an image bundle (catalog name) or OCI image and register it", + ValidArgsFunction: d.completeImageCatalogNameOnlyAtPos0, Long: strings.TrimSpace(` 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/ --modules are required. -Use 'banger image catalog' to see available catalog names (once that -subcommand lands). +Use 'banger image list' to see installed images. `), Example: strings.TrimSpace(` banger image pull debian-bookworm @@ -235,7 +215,7 @@ subcommand lands). cmd.Flags().StringVar(¶ms.InitrdPath, "initrd", "", "initrd path") cmd.Flags().StringVar(¶ms.ModulesDir, "modules", "", "modules dir") cmd.Flags().StringVar(¶ms.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) return cmd } diff --git a/internal/cli/commands_kernel.go b/internal/cli/commands_kernel.go index 3026d07..a4afd55 100644 --- a/internal/cli/commands_kernel.go +++ b/internal/cli/commands_kernel.go @@ -54,9 +54,10 @@ Subcommands: func (d *deps) newKernelPullCommand() *cobra.Command { var force bool cmd := &cobra.Command{ - Use: "pull ", - Short: "Download a cataloged kernel bundle", - Args: exactArgsUsage(1, "usage: banger kernel pull [--force]"), + Use: "pull ", + Short: "Download a cataloged kernel bundle", + Args: exactArgsUsage(1, "usage: banger kernel pull [--force]"), + ValidArgsFunction: d.completeKernelCatalogNameOnlyAtPos0, RunE: func(cmd *cobra.Command, args []string) error { layout, _, err := d.ensureDaemon(cmd.Context()) if err != nil { diff --git a/internal/cli/commands_vm.go b/internal/cli/commands_vm.go index bfda996..57d9d3b 100644 --- a/internal/cli/commands_vm.go +++ b/internal/cli/commands_vm.go @@ -185,7 +185,7 @@ Three modes: cmd.Flags().StringVar(&workDiskSize, "disk-size", model.FormatSizeBytes(defaults.WorkDiskSizeBytes), "work disk size") cmd.Flags().BoolVar(&natEnabled, "nat", false, "enable NAT") 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(&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") @@ -581,8 +581,15 @@ func (d *deps) newVMSetCommand() *cobra.Command { noNat bool ) cmd := &cobra.Command{ - Use: "set ...", - Short: "Update stopped VM settings", + Use: "set ...", + 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] ..."), ValidArgsFunction: d.completeVMNames, 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(&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().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") @@ -856,8 +863,17 @@ hanging on boot. func (d *deps) newVMStatsCommand() *cobra.Command { return &cobra.Command{ - Use: "stats ", - Short: "Show VM stats", + Use: "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 "), ValidArgsFunction: d.completeVMNameOnlyAtPos0, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/internal/cli/completion.go b/internal/cli/completion.go index d6d1a32..8bb4f8b 100644 --- a/internal/cli/completion.go +++ b/internal/cli/completion.go @@ -160,3 +160,32 @@ func (d *deps) completeKernelNames(cmd *cobra.Command, args []string, toComplete } 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) +} diff --git a/internal/cli/formatters_test.go b/internal/cli/formatters_test.go index 65e2ba0..f712266 100644 --- a/internal/cli/formatters_test.go +++ b/internal/cli/formatters_test.go @@ -20,14 +20,14 @@ func TestHumanSize(t *testing.T) { }{ {-1, "-"}, {0, "-"}, - {1, "1B"}, - {1023, "1023B"}, - {1024, "1.0KiB"}, - {2048, "2.0KiB"}, - {1024 * 1024, "1.0MiB"}, - {5 * 1024 * 1024, "5.0MiB"}, - {1024 * 1024 * 1024, "1.0GiB"}, - {3 * 1024 * 1024 * 1024, "3.0GiB"}, + {1, "1 B"}, + {1023, "1023 B"}, + {1024, "1.0 KiB"}, + {2048, "2.0 KiB"}, + {1024 * 1024, "1.0 MiB"}, + {5 * 1024 * 1024, "5.0 MiB"}, + {1024 * 1024 * 1024, "1.0 GiB"}, + {3 * 1024 * 1024 * 1024, "3.0 GiB"}, } for _, tc := range cases { 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) } } - if !strings.Contains(got, "2.0MiB") { + if !strings.Contains(got, "2.0 MiB") { t.Errorf("expected humanSize(2 MiB), got:\n%s", got) } } diff --git a/internal/cli/printers.go b/internal/cli/printers.go index aaea21c..d4ea646 100644 --- a/internal/cli/printers.go +++ b/internal/cli/printers.go @@ -35,13 +35,13 @@ func humanSize(bytes int64) string { ) switch { case bytes >= gib: - return fmt.Sprintf("%.1fGiB", float64(bytes)/float64(gib)) + return fmt.Sprintf("%.1f GiB", float64(bytes)/float64(gib)) case bytes >= mib: - return fmt.Sprintf("%.1fMiB", float64(bytes)/float64(mib)) + return fmt.Sprintf("%.1f MiB", float64(bytes)/float64(mib)) case bytes >= kib: - return fmt.Sprintf("%.1fKiB", float64(bytes)/float64(kib)) + return fmt.Sprintf("%.1f KiB", float64(bytes)/float64(kib)) default: - return fmt.Sprintf("%dB", bytes) + return fmt.Sprintf("%d B", bytes) } } @@ -52,14 +52,6 @@ func dashIfEmpty(s string) string { return s } -func emptyDash(value string) string { - value = strings.TrimSpace(value) - if value == "" { - return "-" - } - return value -} - // -- generic printers ----------------------------------------------- func printJSON(out anyWriter, v any) error { @@ -165,9 +157,9 @@ func printVMPortsTable(out anyWriter, result api.VMPortsResult) error { w, "%s\t%s\t%s\t%s\n", row.Proto, - emptyDash(row.Endpoint), - emptyDash(row.Process), - emptyDash(row.Command), + dashIfEmpty(row.Endpoint), + dashIfEmpty(row.Process), + dashIfEmpty(row.Command), ); err != nil { return err } diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 9f727b6..174b53f 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -353,7 +353,7 @@ func (d *Daemon) watchRequestDisconnect(conn net.Conn, reader *bufio.Reader, met default: } 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() return diff --git a/internal/daemon/logger.go b/internal/daemon/logger.go index cdc5fb7..99ea3f5 100644 --- a/internal/daemon/logger.go +++ b/internal/daemon/logger.go @@ -66,7 +66,7 @@ func (d *Daemon) beginOperation(ctx context.Context, name string, attrs ...any) allAttrs = append([]any{"op_id", opID}, allAttrs...) } 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() return &operationLog{ diff --git a/internal/roothelper/roothelper.go b/internal/roothelper/roothelper.go index 1699040..f164b5d 100644 --- a/internal/roothelper/roothelper.go +++ b/internal/roothelper/roothelper.go @@ -395,7 +395,7 @@ func (s *Server) handleConn(conn net.Conn) { 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) } 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)