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 ' for the cataloged paths (see internal/imagecat), or 'banger image pull ' 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 --rootfs [--work-seed ] (--kernel [--initrd ] [--modules ] | --kernel-ref )"), 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(¶ms); 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(¶ms.Name, "name", "", "image name") cmd.Flags().StringVar(¶ms.RootfsPath, "rootfs", "", "rootfs path") cmd.Flags().StringVar(¶ms.WorkSeedPath, "work-seed", "", "work-seed path") cmd.Flags().StringVar(¶ms.KernelPath, "kernel", "", "kernel path") 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.RegisterFlagCompletionFunc("kernel-ref", d.completeKernelNames) return cmd } func (d *deps) newImagePullCommand() *cobra.Command { var ( params api.ImagePullParams sizeRaw string ) cmd := &cobra.Command{ Use: "pull ", 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 ] [--kernel-ref ] [--kernel ] [--initrd ] [--modules ] [--size ]"), 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(¶ms.KernelPath, ¶ms.InitrdPath, ¶ms.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(¶ms.Name, "name", "", "image name (defaults to the ref's repo+tag, sanitised)") cmd.Flags().StringVar(¶ms.KernelPath, "kernel", "", "kernel path") 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.RegisterFlagCompletionFunc("kernel-ref", d.completeKernelNames) return cmd } func (d *deps) newImagePromoteCommand() *cobra.Command { return &cobra.Command{ Use: "promote ", Short: "Promote an unmanaged image to a managed artifact", Args: exactArgsUsage(1, "usage: banger image promote "), 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 ", Short: "Show image details", Args: exactArgsUsage(1, "usage: banger image show "), 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 ", Aliases: []string{"rm"}, Short: "Delete an image", Args: exactArgsUsage(1, "usage: banger image delete "), 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) }, } }