diff --git a/internal/cli/banger.go b/internal/cli/banger.go index bfa3f1e..a022108 100644 --- a/internal/cli/banger.go +++ b/internal/cli/banger.go @@ -1427,6 +1427,7 @@ func newImageCommand() *cobra.Command { cmd.AddCommand( newImageBuildCommand(), newImageRegisterCommand(), + newImagePullCommand(), newImagePromoteCommand(), newImageListCommand(), newImageShowCommand(), @@ -1507,6 +1508,60 @@ func newImageRegisterCommand() *cobra.Command { return cmd } +func newImagePullCommand() *cobra.Command { + var ( + params api.ImagePullParams + sizeRaw string + ) + cmd := &cobra.Command{ + Use: "pull ", + Short: "Pull an OCI image and register it as a managed banger image", + Long: "Download an OCI image (e.g. docker.io/library/debian:bookworm), " + + "flatten its layers into an ext4 rootfs, and register the result as a " + + "managed image. Kernel info is required (via --kernel-ref or direct paths). " + + "\n\nNote: Phase A primitive — file ownership in the produced ext4 reflects " + + "the runner's uid/gid, not the OCI tar headers, so the resulting image is " + + "suitable as a base for `image build` but is not directly bootable until a " + + "future ownership-fixup pass lands.", + 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 + } + if err := system.EnsureSudo(cmd.Context()); err != nil { + return err + } + layout, _, err := ensureDaemon(cmd.Context()) + if err != nil { + return err + } + result, err := rpc.Call[api.ImageShowResult](cmd.Context(), layout.SocketPath, "image.pull", params) + 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") + return cmd +} + func newImagePromoteCommand() *cobra.Command { return &cobra.Command{ Use: "promote ", diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index dd21570..8a7329c 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -59,6 +59,35 @@ func TestVersionCommandPrintsBuildInfo(t *testing.T) { } } +func TestImageCommandIncludesPull(t *testing.T) { + cmd := NewBangerCommand() + var image *cobra.Command + for _, sub := range cmd.Commands() { + if sub.Name() == "image" { + image = sub + break + } + } + if image == nil { + t.Fatalf("image command missing from root") + } + hasPull := false + for _, sub := range image.Commands() { + if sub.Name() == "pull" { + hasPull = true + if flag := sub.Flags().Lookup("kernel-ref"); flag == nil { + t.Errorf("image pull missing --kernel-ref flag") + } + if flag := sub.Flags().Lookup("size"); flag == nil { + t.Errorf("image pull missing --size flag") + } + } + } + if !hasPull { + t.Fatalf("image pull subcommand missing") + } +} + func TestKernelCommandExposesSubcommands(t *testing.T) { cmd := NewBangerCommand() var kernel *cobra.Command