diff --git a/cli/command/image/list.go b/cli/command/image/list.go index fde226b6095c..6520bd0a84f7 100644 --- a/cli/command/image/list.go +++ b/cli/command/image/list.go @@ -29,7 +29,6 @@ type imagesOptions struct { showDigests bool format string filter opts.FilterOpt - calledAs string tree bool } @@ -45,11 +44,14 @@ func newImagesCommand(dockerCLI command.Cli) *cobra.Command { if len(args) > 0 { options.matchName = args[0] } - // Pass through how the command was invoked. We use this to print - // warnings when an ambiguous argument was passed when using the - // legacy (top-level) "docker images" subcommand. - options.calledAs = cmd.CalledAs() - return runImages(cmd.Context(), dockerCLI, options) + numImages, err := runImages(cmd.Context(), dockerCLI, options) + if err != nil { + return err + } + if numImages == 0 && options.matchName != "" && cmd.CalledAs() == "images" { + printAmbiguousHint(dockerCLI.Err(), options.matchName) + } + return nil }, Annotations: map[string]string{ "category-top": "7", @@ -82,38 +84,28 @@ func newListCommand(dockerCLI command.Cli) *cobra.Command { return &cmd } -//nolint:gocyclo -func runImages(ctx context.Context, dockerCLI command.Cli, options imagesOptions) error { +func runImages(ctx context.Context, dockerCLI command.Cli, options imagesOptions) (int, error) { filters := options.filter.Value() if options.matchName != "" { filters.Add("reference", options.matchName) } - if options.tree { - if options.quiet { - return errors.New("--quiet is not yet supported with --tree") - } - if options.noTrunc { - return errors.New("--no-trunc is not yet supported with --tree") - } - if options.showDigests { - return errors.New("--show-digest is not yet supported with --tree") - } - if options.format != "" { - return errors.New("--format is not yet supported with --tree") - } + useTree, err := shouldUseTree(options) + if err != nil { + return 0, err } listOpts := client.ImageListOptions{ All: options.all, Filters: filters, - Manifests: options.tree, + Manifests: useTree, } res, err := dockerCLI.Client().ImageList(ctx, listOpts) if err != nil { - return err + return 0, err } + images := res.Items if !options.all { if _, ok := filters["dangling"]; !ok { @@ -121,11 +113,12 @@ func runImages(ctx context.Context, dockerCLI command.Cli, options imagesOptions } } - if options.tree { + if useTree { return runTree(ctx, dockerCLI, treeOptions{ - images: images, - all: options.all, - filters: filters, + images: images, + all: options.all, + filters: filters, + expanded: options.tree, }) } @@ -147,12 +140,37 @@ func runImages(ctx context.Context, dockerCLI command.Cli, options imagesOptions Digest: options.showDigests, } if err := formatter.ImageWrite(imageCtx, images); err != nil { - return err + return 0, err } - if options.matchName != "" && len(images) == 0 && options.calledAs == "images" { - printAmbiguousHint(dockerCLI.Err(), options.matchName) + return len(images), nil +} + +func shouldUseTree(options imagesOptions) (bool, error) { + if options.quiet { + if options.tree { + return false, errors.New("--quiet is not yet supported with --tree") + } + return false, nil + } + if options.noTrunc { + if options.tree { + return false, errors.New("--no-trunc is not yet supported with --tree") + } + return false, nil + } + if options.showDigests { + if options.tree { + return false, errors.New("--show-digest is not yet supported with --tree") + } + return false, nil + } + if options.format != "" { + if options.tree { + return false, errors.New("--format is not yet supported with --tree") + } + return false, nil } - return nil + return true, nil } // isDangling is a copy of [formatter.isDangling]. diff --git a/cli/command/image/list_test.go b/cli/command/image/list_test.go index 20b57328e4db..b11b9d106021 100644 --- a/cli/command/image/list_test.go +++ b/cli/command/image/list_test.go @@ -38,7 +38,7 @@ func TestNewImagesCommandErrors(t *testing.T) { cmd := newImagesCommand(test.NewFakeCli(&fakeClient{imageListFunc: tc.imageListFunc})) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) - cmd.SetArgs(tc.args) + cmd.SetArgs(nilToEmptySlice(tc.args)) assert.ErrorContains(t, cmd.Execute(), tc.expectedError) }) } @@ -87,7 +87,7 @@ func TestNewImagesCommandSuccess(t *testing.T) { cmd := newImagesCommand(cli) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) - cmd.SetArgs(tc.args) + cmd.SetArgs(nilToEmptySlice(tc.args)) err := cmd.Execute() assert.NilError(t, err) golden.Assert(t, cli.OutBuffer().String(), fmt.Sprintf("list-command-success.%s.golden", tc.name)) @@ -97,6 +97,7 @@ func TestNewImagesCommandSuccess(t *testing.T) { func TestNewListCommandAlias(t *testing.T) { cmd := newListCommand(test.NewFakeCli(&fakeClient{})) + cmd.SetArgs([]string{""}) assert.Check(t, cmd.HasAlias("list")) assert.Check(t, !cmd.HasAlias("other")) } @@ -114,3 +115,10 @@ func TestNewListCommandAmbiguous(t *testing.T) { assert.NilError(t, err) golden.Assert(t, cli.ErrBuffer().String(), "list-command-ambiguous.golden") } + +func nilToEmptySlice[T any](s []T) []T { + if s == nil { + return []T{} + } + return s +} diff --git a/cli/command/image/remove_test.go b/cli/command/image/remove_test.go index 8d6ebe825d40..e9386bc9d939 100644 --- a/cli/command/image/remove_test.go +++ b/cli/command/image/remove_test.go @@ -26,6 +26,7 @@ func (notFound) NotFound() {} func TestNewRemoveCommandAlias(t *testing.T) { cmd := newImageRemoveCommand(test.NewFakeCli(&fakeClient{})) + cmd.SetArgs([]string{""}) assert.Check(t, cmd.HasAlias("rmi")) assert.Check(t, cmd.HasAlias("remove")) assert.Check(t, !cmd.HasAlias("other")) @@ -69,7 +70,7 @@ func TestNewRemoveCommandErrors(t *testing.T) { })) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) - cmd.SetArgs(tc.args) + cmd.SetArgs(nilToEmptySlice(tc.args)) assert.ErrorContains(t, cmd.Execute(), tc.expectedError) }) } @@ -134,7 +135,7 @@ func TestNewRemoveCommandSuccess(t *testing.T) { cmd := newRemoveCommand(cli) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) - cmd.SetArgs(tc.args) + cmd.SetArgs(nilToEmptySlice(tc.args)) assert.NilError(t, cmd.Execute()) assert.Check(t, is.Equal(tc.expectedStderr, cli.ErrBuffer().String())) golden.Assert(t, cli.OutBuffer().String(), fmt.Sprintf("remove-command-success.%s.golden", tc.name)) diff --git a/cli/command/image/testdata/list-command-ambiguous.golden b/cli/command/image/testdata/list-command-ambiguous.golden index 2d8ffa9efa90..f49f1091ebfb 100644 --- a/cli/command/image/testdata/list-command-ambiguous.golden +++ b/cli/command/image/testdata/list-command-ambiguous.golden @@ -1,2 +1,3 @@ +WARNING: This output is designed for human readability. For machine-readable output, please use --format. No images found matching "ls": did you mean "docker image ls"? diff --git a/cli/command/image/testdata/list-command-success.filters.golden b/cli/command/image/testdata/list-command-success.filters.golden index 28b0b71e7865..2f1898cdb23f 100644 --- a/cli/command/image/testdata/list-command-success.filters.golden +++ b/cli/command/image/testdata/list-command-success.filters.golden @@ -1 +1,2 @@ -REPOSITORY TAG IMAGE ID CREATED SIZE + Info -> U In Use +IMAGE ID DISK USAGE CONTENT SIZE EXTRA diff --git a/cli/command/image/testdata/list-command-success.format.golden b/cli/command/image/testdata/list-command-success.format.golden index e69de29bb2d1..2f1898cdb23f 100644 --- a/cli/command/image/testdata/list-command-success.format.golden +++ b/cli/command/image/testdata/list-command-success.format.golden @@ -0,0 +1,2 @@ + Info -> U In Use +IMAGE ID DISK USAGE CONTENT SIZE EXTRA diff --git a/cli/command/image/testdata/list-command-success.match-name.golden b/cli/command/image/testdata/list-command-success.match-name.golden index 28b0b71e7865..2f1898cdb23f 100644 --- a/cli/command/image/testdata/list-command-success.match-name.golden +++ b/cli/command/image/testdata/list-command-success.match-name.golden @@ -1 +1,2 @@ -REPOSITORY TAG IMAGE ID CREATED SIZE + Info -> U In Use +IMAGE ID DISK USAGE CONTENT SIZE EXTRA diff --git a/cli/command/image/testdata/list-command-success.simple.golden b/cli/command/image/testdata/list-command-success.simple.golden index 28b0b71e7865..2f1898cdb23f 100644 --- a/cli/command/image/testdata/list-command-success.simple.golden +++ b/cli/command/image/testdata/list-command-success.simple.golden @@ -1 +1,2 @@ -REPOSITORY TAG IMAGE ID CREATED SIZE + Info -> U In Use +IMAGE ID DISK USAGE CONTENT SIZE EXTRA diff --git a/cli/command/image/tree.go b/cli/command/image/tree.go index c1586af58d47..e3488f0ca055 100644 --- a/cli/command/image/tree.go +++ b/cli/command/image/tree.go @@ -8,7 +8,6 @@ import ( "fmt" "os" "slices" - "sort" "strings" "github.com/containerd/platforms" @@ -24,9 +23,10 @@ import ( ) type treeOptions struct { - images []imagetypes.Summary - all bool - filters client.Filters + images []imagetypes.Summary + all bool + filters client.Filters + expanded bool } type treeView struct { @@ -36,7 +36,7 @@ type treeView struct { imageSpacing bool } -func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error { +func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) (int, error) { images := opts.images view := treeView{ @@ -46,9 +46,9 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error for _, img := range images { if ctx.Err() != nil { - return ctx.Err() + return 0, ctx.Err() } - details := imageDetails{ + topDetails := imageDetails{ ID: img.ID, DiskUsage: units.HumanSizeWithPrecision(float64(img.Size), 3), InUse: img.Containers > 0, @@ -67,41 +67,58 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error continue } + inUse := len(im.ImageData.Containers) > 0 + if inUse { + // Mark top-level parent image as used if any of its subimages are used. + topDetails.InUse = true + } + + if !opts.expanded { + continue + } + sub := subImage{ Platform: platforms.Format(im.ImageData.Platform), Available: im.Available, Details: imageDetails{ ID: im.ID, DiskUsage: units.HumanSizeWithPrecision(float64(im.Size.Total), 3), - InUse: len(im.ImageData.Containers) > 0, + InUse: inUse, ContentSize: units.HumanSizeWithPrecision(float64(im.Size.Content), 3), }, } - if sub.Details.InUse { - // Mark top-level parent image as used if any of its subimages are used. - details.InUse = true - } - children = append(children, sub) // Add extra spacing between images if there's at least one entry with children. view.imageSpacing = true } - details.ContentSize = units.HumanSizeWithPrecision(float64(totalContent), 3) + topDetails.ContentSize = units.HumanSizeWithPrecision(float64(totalContent), 3) // Sort tags for this image sortedTags := make([]string, len(img.RepoTags)) copy(sortedTags, img.RepoTags) slices.Sort(sortedTags) - view.images = append(view.images, topImage{ - Names: sortedTags, - Details: details, - Children: children, - created: img.Created, - }) + if opts.expanded { + view.images = append(view.images, topImage{ + Names: sortedTags, + Details: topDetails, + Children: children, + created: img.Created, + }) + continue + } + + for _, tag := range sortedTags { + view.images = append(view.images, topImage{ + Names: []string{tag}, + Details: topDetails, + Children: children, + created: img.Created, + }) + } } slices.SortFunc(view.images, func(a, b topImage) int { @@ -123,7 +140,8 @@ func runTree(ctx context.Context, dockerCLI command.Cli, opts treeOptions) error return strings.Compare(nameA, nameB) }) - return printImageTree(dockerCLI, view) + printImageTree(dockerCLI, view) + return len(view.images), nil } type imageDetails struct { @@ -206,7 +224,7 @@ func getPossibleChips(view treeView) (chips []imageChip) { return possible } -func printImageTree(dockerCLI command.Cli, view treeView) error { +func printImageTree(dockerCLI command.Cli, view treeView) { if streamRedirected(dockerCLI.Out()) { _, _ = fmt.Fprintln(dockerCLI.Err(), "WARNING: This output is designed for human readability. For machine-readable output, please use --format.") } @@ -313,8 +331,6 @@ func printImageTree(dockerCLI command.Cli, view treeView) error { printChildren(out, columns, img, normalColor) _, _ = fmt.Fprintln(out) } - - return nil } // adjustColumns adjusts the width of the first column to maximize the space @@ -356,7 +372,6 @@ func generateLegend(out tui.Output, width uint) string { legend += " |" } } - legend += " " r := int(width) - tui.Width(legend) if r < 0 { @@ -406,15 +421,7 @@ func printNames(out tui.Output, headers []imgColumn, img topImage, color, untagg _, _ = fmt.Fprint(out, headers[0].Print(untaggedColor, "")) } - // TODO: Replace with namesLongestToShortest := slices.SortedFunc(slices.Values(img.Names)) - // once we move to Go 1.23. - namesLongestToShortest := make([]string, len(img.Names)) - copy(namesLongestToShortest, img.Names) - sort.Slice(namesLongestToShortest, func(i, j int) bool { - return len(namesLongestToShortest[i]) > len(namesLongestToShortest[j]) - }) - - for nameIdx, name := range namesLongestToShortest { + for nameIdx, name := range img.Names { // Don't limit first names to the column width because only the last // name will be printed alongside other columns. if nameIdx < len(img.Names)-1 {