cli,docs: trivial polish for v0.1.0
A pre-release audit collected ~12 trivial-effort UX and code-hygiene
items. Rolling them up here so the v0.1.0 commit log isn't littered
with one-line tweaks.
CLI help / completion:
* commands_image.go: drop dangling reference to a `banger image
catalog` subcommand that doesn't exist; replace with a pointer
to `banger image list`.
* commands_image.go: --size flag example was "4GiB" but the parser
rejects that suffix. Change example to "4G". (Parser-side fix
is in a separate concern.)
* commands_image.go + completion.go: image pull now wires a
catalog completer (falls back to local image names since there's
no image-catalog RPC yet); image show / delete / promote already
completed local names.
* commands_kernel.go + completion.go: kernel pull now wires a new
completeKernelCatalogNameOnlyAtPos0 backed by the kernel.catalog
RPC, so tab-complete suggests pullable kernels.
* commands_vm.go: vm stats and vm set now have Long + Example
blocks (peers all do); --from flag description updated to spell
out the relationship to --branch.
README:
* Define "golden image" inline at first use.
* Add a one-line Requirements block above Quick Start so users
hit the firecracker / KVM dependency before `make build`.
Code hygiene:
* dashIfEmpty / emptyDash were the same function. Deleted
emptyDash, retargeted three call sites.
* formatBytes (introduced today in image cache prune) duplicated
humanSize. Consolidated to humanSize, now with a space ("1.2
GiB" not "1.2GiB"). formatters_test.go expectations updated.
Logging chattiness:
* "operation started" (logger.go), "daemon request canceled"
(daemon.go), and "helper rpc completed" (roothelper.go) all
fired at INFO per RPC. Downgraded to DEBUG so routine shell
completions don't spam syslog.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4d8dca6b72
commit
003b0488ce
10 changed files with 86 additions and 66 deletions
10
README.md
10
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
|
|
@ -177,6 +157,7 @@ func (d *deps) newImagePullCommand() *cobra.Command {
|
||||||
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(¶ms.InitrdPath, "initrd", "", "initrd path")
|
cmd.Flags().StringVar(¶ms.InitrdPath, "initrd", "", "initrd path")
|
||||||
cmd.Flags().StringVar(¶ms.ModulesDir, "modules", "", "modules dir")
|
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(¶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)
|
_ = cmd.RegisterFlagCompletionFunc("kernel-ref", d.completeKernelNames)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ func (d *deps) newKernelPullCommand() *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 {
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -583,6 +583,13 @@ func (d *deps) newVMSetCommand() *cobra.Command {
|
||||||
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")
|
||||||
|
|
@ -858,6 +865,15 @@ 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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue