302 lines
11 KiB
Go
302 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, humanSize(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
|
|
}
|
|
|
|
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(¶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 <name-or-oci-ref>",
|
|
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:
|
|
|
|
• 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 list' to see installed images.
|
|
`),
|
|
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(¶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, 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)
|
|
},
|
|
}
|
|
}
|