banger/internal/cli/commands_image.go
Thales Maciel d0997fd3b5
model,cli,docs: medium-effort polish for v0.1.0
* model.ParseSize / FormatSizeBytes: pinned with table tests in
    internal/model/types_test.go (TestParseSize 22 cases,
    TestFormatSizeBytes 11 cases, TestParseSizeFormatRoundTrip 7
    boundaries). Fixed the long-suffix regression: "4GiB", "512MiB",
    "4KiB" now parse correctly (parser strips trailing IB before
    inspecting the unit byte). Pinned current behaviour for
    no-suffix input ("1024" treated as MiB) and FormatSizeBytes(0).
    commands_image.go --size flag-help updated to show 4GiB now
    that the parser accepts it.
  * vm ports --json: matches the JSON-vs-table inconsistency between
    vm stats (always JSON) and vm ports (always table). --json on
    vm ports flips to the same printJSON path as vm stats. Default
    table output unchanged. Other vm subcommands (show, stats,
    logs, health, ping) didn't fit the identical pattern; left
    alone.
  * docs/oci-import.md architecture section moved to a new
    docs/oci-import-internals.md (precedent: internal/daemon/
    ARCHITECTURE.md). User-facing oci-import.md keeps a one-line
    pointer for advanced reading.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:36:03 -03:00

322 lines
11 KiB
Go

package cli
import (
"errors"
"fmt"
"strings"
"banger/internal/api"
"banger/internal/model"
"banger/internal/rpc"
"github.com/spf13/cobra"
)
func (d *deps) newImageCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "image",
Short: "Pull and manage banger images (rootfs + kernel + work-seed)",
Long: strings.TrimSpace(`
A banger image bundles a rootfs.ext4, a kernel, an optional initrd
+ modules, and an optional work-seed (the snapshot used to populate
each new VM's /root). Most users only need 'banger image pull
<catalog-name>' for the cataloged paths (see internal/imagecat),
or 'banger image pull <oci-ref>' for an OCI image.
Subcommands:
pull fetch a bundle by catalog name OR pull an OCI image
register point banger at an existing local rootfs (advanced)
promote copy a registered image's files into banger's managed dir
list show what's installed
show print one image's full record as JSON
delete remove an image (no VMs may reference it)
`),
Example: strings.TrimSpace(`
banger image pull debian-bookworm
banger image pull docker.io/library/alpine:3.20 --kernel-ref generic-6.12
banger image list
`),
RunE: helpNoArgs,
}
cmd.AddCommand(
d.newImageRegisterCommand(),
d.newImagePullCommand(),
d.newImagePromoteCommand(),
d.newImageListCommand(),
d.newImageShowCommand(),
d.newImageDeleteCommand(),
d.newImageCacheCommand(),
)
return cmd
}
// newImageCacheCommand groups OCI-cache lifecycle subcommands. Today
// the only one is `prune`; future additions (size, list, etc.) plug
// in here without polluting the top-level `image` namespace.
func (d *deps) newImageCacheCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "cache",
Short: "Manage banger's OCI layer-blob cache",
Long: strings.TrimSpace(`
banger keeps a local copy of every OCI layer it downloads so a re-pull
of the same image (or any image that shares a base layer) skips the
network round-trip. The cache lives under the daemon's CacheDir
(see 'banger doctor' or docs/config.md). Layers accumulate forever;
'banger image cache prune' is the cheap way to reclaim disk.
`),
Example: strings.TrimSpace(`
banger image cache prune --dry-run
banger image cache prune
`),
RunE: helpNoArgs,
}
cmd.AddCommand(d.newImageCachePruneCommand())
return cmd
}
func (d *deps) newImageCachePruneCommand() *cobra.Command {
var dryRun bool
cmd := &cobra.Command{
Use: "prune",
Short: "Remove every cached OCI layer blob",
Long: strings.TrimSpace(`
Removes every layer blob under the OCI cache. Registered banger
images are independent of the cache (each pull flattens layers into
a self-contained ext4), so prune only loses re-pull avoidance — the
next pull of the same image re-downloads the layers it needs.
Safe to run any time the daemon is idle. If you have an image pull
in flight when you run prune, that pull may fail and need a retry.
--dry-run reports the byte count without removing anything.
`),
Args: noArgsUsage("usage: banger image cache prune [--dry-run]"),
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := rpc.Call[api.ImageCachePruneResult](cmd.Context(), layout.SocketPath, "image.cache.prune", api.ImageCachePruneParams{DryRun: dryRun})
if err != nil {
return err
}
out := cmd.OutOrStdout()
verb := "freed"
if result.DryRun {
verb = "would free"
}
_, err = fmt.Fprintf(out, "%s %s across %d blob(s) in %s\n",
verb, formatBytes(result.BytesFreed), result.BlobsFreed, result.CacheDir)
return err
},
}
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "report the size that would be freed without deleting anything")
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{
Use: "register",
Short: "Register or update an unmanaged image",
Args: noArgsUsage("usage: banger image register --name <name> --rootfs <path> [--work-seed <path>] (--kernel <path> [--initrd <path>] [--modules <dir>] | --kernel-ref <name>)"),
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(params.KernelRef) != "" && (params.KernelPath != "" || params.InitrdPath != "" || params.ModulesDir != "") {
return errors.New("--kernel-ref is mutually exclusive with --kernel/--initrd/--modules")
}
if err := absolutizeImageRegisterPaths(&params); err != nil {
return err
}
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := rpc.Call[api.ImageShowResult](cmd.Context(), layout.SocketPath, "image.register", params)
if err != nil {
return err
}
return printImageSummary(cmd.OutOrStdout(), result.Image)
},
}
cmd.Flags().StringVar(&params.Name, "name", "", "image name")
cmd.Flags().StringVar(&params.RootfsPath, "rootfs", "", "rootfs path")
cmd.Flags().StringVar(&params.WorkSeedPath, "work-seed", "", "work-seed path")
cmd.Flags().StringVar(&params.KernelPath, "kernel", "", "kernel path")
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.RegisterFlagCompletionFunc("kernel-ref", d.completeKernelNames)
return cmd
}
func (d *deps) newImagePullCommand() *cobra.Command {
var (
params api.ImagePullParams
sizeRaw string
)
cmd := &cobra.Command{
Use: "pull <name-or-oci-ref>",
Short: "Pull an image bundle (catalog name) or OCI image and register it",
Long: strings.TrimSpace(`
Pull an image into banger. Two paths:
• Catalog name (e.g. 'debian-bookworm')
Fetches a pre-built bundle from the embedded imagecat catalog.
Kernel-ref comes from the catalog entry; --kernel-ref still
overrides.
• OCI reference (e.g. 'docker.io/library/debian:bookworm')
Pulls the image, flattens its layers, fixes ownership, injects
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).
`),
Example: strings.TrimSpace(`
banger image pull debian-bookworm
banger image pull debian-bookworm --name sandbox
banger image pull docker.io/library/debian:bookworm --kernel-ref generic-6.12
`),
Args: exactArgsUsage(1, "usage: banger image pull <name-or-oci-ref> [--name <name>] [--kernel-ref <name>] [--kernel <path>] [--initrd <path>] [--modules <dir>] [--size <human>]"),
RunE: func(cmd *cobra.Command, args []string) error {
params.Ref = args[0]
if strings.TrimSpace(params.KernelRef) != "" && (params.KernelPath != "" || params.InitrdPath != "" || params.ModulesDir != "") {
return errors.New("--kernel-ref is mutually exclusive with --kernel/--initrd/--modules")
}
if strings.TrimSpace(sizeRaw) != "" {
size, err := model.ParseSize(sizeRaw)
if err != nil {
return fmt.Errorf("--size: %w", err)
}
params.SizeBytes = size
}
if err := absolutizePaths(&params.KernelPath, &params.InitrdPath, &params.ModulesDir); err != nil {
return err
}
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
var result api.ImageShowResult
err = withHeartbeat(cmd.ErrOrStderr(), "image pull", func() error {
var callErr error
result, callErr = rpc.Call[api.ImageShowResult](cmd.Context(), layout.SocketPath, "image.pull", params)
return callErr
})
if err != nil {
return err
}
return printImageSummary(cmd.OutOrStdout(), result.Image)
},
}
cmd.Flags().StringVar(&params.Name, "name", "", "image name (defaults to the ref's repo+tag, sanitised)")
cmd.Flags().StringVar(&params.KernelPath, "kernel", "", "kernel path")
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. 4GiB, 512M, 2G (defaults to content + 25%, min 1GiB)")
_ = cmd.RegisterFlagCompletionFunc("kernel-ref", d.completeKernelNames)
return cmd
}
func (d *deps) newImagePromoteCommand() *cobra.Command {
return &cobra.Command{
Use: "promote <id-or-name>",
Short: "Promote an unmanaged image to a managed artifact",
Args: exactArgsUsage(1, "usage: banger image promote <id-or-name>"),
ValidArgsFunction: d.completeImageNameOnlyAtPos0,
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := rpc.Call[api.ImageShowResult](cmd.Context(), layout.SocketPath, "image.promote", api.ImageRefParams{IDOrName: args[0]})
if err != nil {
return err
}
return printImageSummary(cmd.OutOrStdout(), result.Image)
},
}
}
func (d *deps) newImageListCommand() *cobra.Command {
return &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "List images",
Args: noArgsUsage("usage: banger image list"),
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := rpc.Call[api.ImageListResult](cmd.Context(), layout.SocketPath, "image.list", api.Empty{})
if err != nil {
return err
}
return printImageListTable(cmd.OutOrStdout(), result.Images)
},
}
}
func (d *deps) newImageShowCommand() *cobra.Command {
return &cobra.Command{
Use: "show <id-or-name>",
Short: "Show image details",
Args: exactArgsUsage(1, "usage: banger image show <id-or-name>"),
ValidArgsFunction: d.completeImageNameOnlyAtPos0,
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := rpc.Call[api.ImageShowResult](cmd.Context(), layout.SocketPath, "image.show", api.ImageRefParams{IDOrName: args[0]})
if err != nil {
return err
}
return printJSON(cmd.OutOrStdout(), result.Image)
},
}
}
func (d *deps) newImageDeleteCommand() *cobra.Command {
return &cobra.Command{
Use: "delete <id-or-name>",
Aliases: []string{"rm"},
Short: "Delete an image",
Args: exactArgsUsage(1, "usage: banger image delete <id-or-name>"),
ValidArgsFunction: d.completeImageNameOnlyAtPos0,
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := rpc.Call[api.ImageShowResult](cmd.Context(), layout.SocketPath, "image.delete", api.ImageRefParams{IDOrName: args[0]})
if err != nil {
return err
}
return printImageSummary(cmd.OutOrStdout(), result.Image)
},
}
}