From 174afe31b7735e64d93eda9e82734141999175e4 Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Tue, 4 Aug 2026 19:01:39 +0300 Subject: [PATCH] feat(image): add --tree flag to image list Expand the Docker v29 view added in #5093 with `--tree`, matching `docker image ls --tree`: the same IMAGE, ID, DISK USAGE, CONTENT SIZE and EXTRA columns, plus one row per platform the image declares, prefixed with the branch glyphs docker/cli uses. Like docker, a platform that an index lists but that was never pulled is shown with zero sizes. The collapsed and the legacy views keep describing only what is in the store, and so does EnsureAllContent, which would otherwise reach out for platforms the user never asked for. A platform row carries its own manifest digest as the ID, its own sizes, and the "U" flag only when a container actually runs that platform. The image row keeps aggregating its platforms and reuses the existing target-digest lookup, so the default view is unchanged. Matching a container to a platform is done on the full platform form, not on the name the row displays: an index can carry several windows/amd64 manifests that differ only by OSVersion, and keying on the displayed name would let a container on one build flag all of them. The container label is normalized first, since platforms.DefaultString does not normalize: on arm64 it carries a variant while the manifest platform normalizes to a bare linux/arm64, and a raw comparison would never match. The rows are sorted on that same full form, so the ones that render identically keep a stable order. Unlike docker/cli, which computes its column widths itself and separates the images with a blank line, the rows go through a tabwriter shared with the header, where a blank line would terminate the column block and misalign every following group. The groups are therefore separated by the glyphs alone. The flag combinations docker rejects in shouldUseTree are rejected here too, with the same messages. They are validated in pkg/cmd/image, where the options are consumed, so that library callers are covered as well: the tree branch takes precedence over the formatter, so Tree together with Format used to print unaligned rows with no header. The CLI validates too, so that the error still surfaces before a containerd connection is attempted. Also read the platform of a multi-platform image from its index descriptor rather than from the image config. The config may be less specific: alpine ships linux/arm/v6 and linux/arm/v7 manifests whose configs both declare a bare "linux/arm", which normalizes to linux/arm/v7 and collapsed the two onto a single key, dropping one platform. That also left it out of the aggregated sizes of the default view. The expected per-platform content sizes are declared in testutil, and the integration test runs against docker as well, only diverging where docker does: `docker pull` has no --all-platforms, and its message for the digests conflict names its internal flag. DISK USAGE is only asserted to cover CONTENT SIZE, because which platforms are unpacked depends on what the rest of the suite did with the shared image store. Closes #5005 Signed-off-by: Eugene Kalinin --- cmd/nerdctl/image/image_list.go | 19 +- cmd/nerdctl/image/image_list_linux_test.go | 251 ++++++++++++++++++++ docs/command-reference.md | 15 ++ pkg/api/types/image_types.go | 2 + pkg/cmd/image/ensure.go | 5 + pkg/cmd/image/list.go | 248 +++++++++++++++++--- pkg/cmd/image/list_test.go | 261 +++++++++++++++++++++ pkg/testutil/images.yaml | 19 +- pkg/testutil/images_linux.go | 27 +++ 9 files changed, 806 insertions(+), 41 deletions(-) create mode 100644 cmd/nerdctl/image/image_list_linux_test.go diff --git a/cmd/nerdctl/image/image_list.go b/cmd/nerdctl/image/image_list.go index 03e854d19f5..267f8a07a00 100644 --- a/cmd/nerdctl/image/image_list.go +++ b/cmd/nerdctl/image/image_list.go @@ -41,6 +41,9 @@ By default (Docker v29 compatible view) the following columns are shown: - CONTENT SIZE: Size of the blobs (such as layer tarballs) in the content store - EXTRA: Flags for the image; "U" means the image is in use by a container +--tree expands multi-platform images: the same columns are shown, with an additional row per +platform the image declares. Platforms that were never pulled are listed with zero sizes. + Passing --format, --quiet, --no-trunc, --digests or --names falls back to the legacy table: - REPOSITORY: Repository - TAG: Tag @@ -74,6 +77,7 @@ Passing --format, --quiet, --no-trunc, --digests or --names falls back to the le cmd.Flags().Bool("digests", false, "Show digests (compatible with Docker, unlike ID)") cmd.Flags().Bool("names", false, "Show image names") cmd.Flags().BoolP("all", "a", true, "(unimplemented yet, always true)") + cmd.Flags().Bool("tree", false, "List multi-platform images as a tree (EXPERIMENTAL)") return cmd } @@ -118,7 +122,11 @@ func listOptions(cmd *cobra.Command, args []string) (*types.ImageListOptions, er if err != nil { return nil, err } - return &types.ImageListOptions{ + tree, err := cmd.Flags().GetBool("tree") + if err != nil { + return nil, err + } + options := &types.ImageListOptions{ GOptions: globalOptions, Quiet: quiet, NoTrunc: noTrunc, @@ -128,8 +136,15 @@ func listOptions(cmd *cobra.Command, args []string) (*types.ImageListOptions, er Digests: digests, Names: names, All: true, + Tree: tree, Stdout: cmd.OutOrStdout(), - }, nil + } + // Validated here as well as in the logic layer, so that an invalid flag combination is + // reported before a containerd connection is attempted. + if err := image.ValidateListOptions(options); err != nil { + return nil, err + } + return options, nil } diff --git a/cmd/nerdctl/image/image_list_linux_test.go b/cmd/nerdctl/image/image_list_linux_test.go new file mode 100644 index 00000000000..4aad5a83f43 --- /dev/null +++ b/cmd/nerdctl/image/image_list_linux_test.go @@ -0,0 +1,251 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package image + +import ( + "errors" + "slices" + "strings" + "testing" + + "github.com/docker/go-units" + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/mod/tigron/expect" + "github.com/containerd/nerdctl/mod/tigron/require" + "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" + "github.com/containerd/platforms" + + "github.com/containerd/nerdctl/v2/pkg/referenceutil" + "github.com/containerd/nerdctl/v2/pkg/tabutil" + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" +) + +// treeImageKey is the testutil registry key of testutil.CommonImage. Its entry declares the +// platforms of the image and the expected content size of each of them. +const treeImageKey = "alpine" + +// treeChildFields splits a per-platform row of `image ls --tree` into its cells, returning nil for +// any other line. The rows are split on whitespace rather than read with tabutil, because tabutil +// indexes the columns by byte offset while the branch glyphs are multi-byte: the tabwriter aligns +// them by rune, so the byte offsets of a child row no longer match the header's. +func treeChildFields(line string) []string { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "├─") && !strings.HasPrefix(trimmed, "└─") { + return nil + } + // ["├─", "linux/amd64", "", "", "", optional "U"] + return strings.Fields(trimmed) +} + +// normalizeTreePlatform renders a platform the way the testutil registry keys it, so that a row can +// be looked up whatever form the tested binary printed it in (docker keeps the "v8" variant of +// linux/arm64, nerdctl normalizes it away). +func normalizeTreePlatform(platform string) string { + parsed, err := platforms.Parse(platform) + if err != nil { + return platform + } + return platforms.Format(platforms.Normalize(parsed)) +} + +func TestImagesTree(t *testing.T) { + nerdtest.Setup() + + commonImage, _ := referenceutil.Parse(testutil.CommonImage) + imageRef := commonImage.FamiliarName() + ":" + commonImage.Tag + hostPlatform := platforms.Format(platforms.Normalize(platforms.DefaultSpec())) + treeHeader := "IMAGE\tID\tDISK USAGE\tCONTENT SIZE\tEXTRA" + + testCase := &test.Case{ + Setup: func(data test.Data, helpers test.Helpers) { + if nerdtest.IsDocker() { + // `docker pull` has no --all-platforms, so only the host platform is available there. + helpers.Ensure("pull", "--quiet", commonImage.String()) + return + } + helpers.Ensure("pull", "--quiet", "--all-platforms", commonImage.String()) + }, + SubTests: []*test.Case{ + { + Description: "a row per platform, with the sizes of the content store", + Command: test.Command("images", "--tree", commonImage.String()), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, t tig.T) { + lines := strings.Split(strings.TrimSpace(stdout), "\n") + assert.Assert(t, len(lines) >= 3, + "expected a header, an image row and at least one platform row\n") + + tab := tabutil.NewReader(treeHeader) + err := tab.ParseHeader(lines[0]) + assert.NilError(t, err, "ParseHeader should not fail\n") + + known := testutil.GetTestImagePlatforms(treeImageKey) + foundImage := false + var seen, notPulled []string + for _, line := range lines[1:] { + fields := treeChildFields(line) + if fields == nil { + if image, _ := tab.ReadRow(line, "IMAGE"); image == imageRef { + foundImage = true + } + continue + } + assert.Assert(t, len(fields) >= 5, + "a platform row should have all its columns, got %q\n", line) + + platform := normalizeTreePlatform(fields[1]) + assert.Assert(t, slices.Contains(known, platform), + "unexpected platform %q, the testutil registry knows %v\n", platform, known) + seen = append(seen, platform) + + assert.Equal(t, len(fields[2]), 12, + "a platform row should carry a truncated ID\n") + + diskUsage, err := units.FromHumanSize(fields[3]) + assert.NilError(t, err, "DISK USAGE of %s is %q\n", platform, fields[3]) + contentSize, err := units.FromHumanSize(fields[4]) + assert.NilError(t, err, "CONTENT SIZE of %s is %q\n", platform, fields[4]) + + // DISK USAGE adds the unpacked snapshots on top of the content. Which + // platforms are unpacked depends on what the rest of the suite did with + // the shared image store (TestMultiPlatformRun runs this very image on + // several platforms), so only the invariant can be asserted. + assert.Assert(t, diskUsage >= contentSize, + "DISK USAGE (%d) should cover CONTENT SIZE (%d) of %s\n", + diskUsage, contentSize, platform) + + if contentSize == 0 { + // The index lists every platform of the image, including the ones + // that were never pulled: those have no content to size. + notPulled = append(notPulled, platform) + continue + } + // CONTENT SIZE is the size of the blobs, which is fixed for a given + // image, so it can be checked exactly. + assert.Equal(t, fields[4], units.HumanSizeWithPrecision( + float64(testutil.GetTestImageContentSize(treeImageKey, platform)), 3), + "CONTENT SIZE of %s\n", platform) + } + + assert.Assert(t, foundImage, "we should have found the image row\n") + + // Every platform the index declares is listed, whether it was pulled or not. + slices.Sort(seen) + assert.DeepEqual(t, seen, known) + + if nerdtest.IsDocker() { + // `docker pull` could only fetch the host platform, see Setup. + assert.Assert(t, !slices.Contains(notPulled, hostPlatform), + "the host platform should have been pulled, %v were not\n", notPulled) + return + } + assert.Assert(t, len(notPulled) == 0, + "--all-platforms should have pulled every platform, but %v have no content\n", + notPulled) + }, + } + }, + }, + { + Description: "flags the platform a container runs", + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("create", "--name", data.Identifier(), + commonImage.String(), "sleep", nerdtest.Infinity) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: test.Command("images", "--tree", commonImage.String()), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + Output: func(stdout string, t tig.T) { + lines := strings.Split(strings.TrimSpace(stdout), "\n") + tab := tabutil.NewReader(treeHeader) + err := tab.ParseHeader(lines[0]) + assert.NilError(t, err, "ParseHeader should not fail\n") + + imageInUse := false + var platformsInUse []string + for _, line := range lines[1:] { + fields := treeChildFields(line) + if fields == nil { + if image, _ := tab.ReadRow(line, "IMAGE"); image == imageRef { + extra, _ := tab.ReadRow(line, "EXTRA") + imageInUse = extra == "U" + } + continue + } + if len(fields) >= 6 && fields[5] == "U" { + platformsInUse = append(platformsInUse, normalizeTreePlatform(fields[1])) + } + } + + assert.Assert(t, imageInUse, "the image row should be flagged as in use\n") + // Only the platform the container actually runs is flagged, not every + // platform of the image. + assert.DeepEqual(t, platformsInUse, []string{hostPlatform}) + }, + } + }, + }, + { + Description: "conflicts with --quiet", + Command: test.Command("images", "--tree", "--quiet"), + Expected: test.Expects(expect.ExitCodeGenericFail, + []error{errors.New("--quiet is not yet supported with --tree")}, nil), + }, + { + Description: "conflicts with --no-trunc", + Command: test.Command("images", "--tree", "--no-trunc"), + Expected: test.Expects(expect.ExitCodeGenericFail, + []error{errors.New("--no-trunc is not yet supported with --tree")}, nil), + }, + { + Description: "conflicts with --format", + Command: test.Command("images", "--tree", "--format", "json"), + Expected: test.Expects(expect.ExitCodeGenericFail, + []error{errors.New("--format is not yet supported with --tree")}, nil), + }, + { + Description: "conflicts with --digests", + Command: test.Command("images", "--tree", "--digests"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + // docker names its internal flag in that message, nerdctl names the real one. + message := "--digests is not yet supported with --tree" + if nerdtest.IsDocker() { + message = "--show-digest is not yet supported with --tree" + } + return test.Expects(expect.ExitCodeGenericFail, []error{errors.New(message)}, nil)(data, helpers) + }, + }, + { + Description: "conflicts with --names", + // --names is a nerdctl-specific flag; Docker does not support it. + Require: require.Not(nerdtest.Docker), + Command: test.Command("images", "--tree", "--names"), + Expected: test.Expects(expect.ExitCodeGenericFail, + []error{errors.New("--names is not yet supported with --tree")}, nil), + }, + }, + } + + testCase.Run(t) +} diff --git a/docs/command-reference.md b/docs/command-reference.md index 20239a0f3b0..16b1827721c 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -840,6 +840,20 @@ By default (Docker v29 compatible view) the columns are `IMAGE`, `ID`, `DISK USA Passing `--format`, `--quiet`, `--no-trunc`, `--digests` or `--names` falls back to the legacy table (`REPOSITORY`, `TAG`, `IMAGE ID`, `CREATED`, `PLATFORM`, `SIZE`, `BLOB SIZE`). +`--tree` keeps the same columns and adds a row per platform the image declares: + +```console +$ nerdctl images --tree +IMAGE ID DISK USAGE CONTENT SIZE EXTRA +nginx:latest 7f553e8bbc89 211MB 67.4MB U +├─ linux/amd64 d9153e78d05e 72.4MB 25.2MB U +├─ linux/arm64 1a2b3c4d5e6f 70.1MB 24.9MB +└─ linux/s390x 2b3c4d5e6f7a 0B 0B +``` + +The `U` flag on a platform row means a container runs that specific platform. Platforms that were +never pulled are listed with zero sizes, like `docker image ls --tree` does. + Usage: `nerdctl images [OPTIONS] [REPOSITORY[:TAG]]` Flags: @@ -860,6 +874,7 @@ Flags: - :whale: `--filter=dangling=true`: Filter images by dangling - :nerd_face: `--filter=reference=`: Filter images by reference (Matches both docker compatible wildcard pattern and regexp match) - :nerd_face: `--names`: Show image names +- :whale: `--tree`: List multi-platform images as a tree (EXPERIMENTAL). Cannot be combined with `--quiet`, `--no-trunc`, `--digests`, `--format` or `--names`. ### :whale: nerdctl pull diff --git a/pkg/api/types/image_types.go b/pkg/api/types/image_types.go index 4bea77dcf8b..e84a8b3bd53 100644 --- a/pkg/api/types/image_types.go +++ b/pkg/api/types/image_types.go @@ -43,6 +43,8 @@ type ImageListOptions struct { Names bool // All (unimplemented yet, always true) All bool + // Tree list multi-platform images as a tree, with a row per platform + Tree bool } // ImageConvertOptions specifies options for `nerdctl image convert`. diff --git a/pkg/cmd/image/ensure.go b/pkg/cmd/image/ensure.go index c3315e58c29..ac0dae8302c 100644 --- a/pkg/cmd/image/ensure.go +++ b/pkg/cmd/image/ensure.go @@ -52,6 +52,11 @@ func EnsureAllContent(ctx context.Context, client *containerd.Client, srcName st imagesList, _ := read(ctx, provider, snapshotter, img.Target) // Iterate through the list for _, i := range imagesList { + // An index also lists the platforms that were never pulled. Ensuring their content would + // mean fetching a platform the user never asked for, so keep to what is in the store. + if !i.available { + continue + } if platMC.Match(i.platform) { err = ensureOne(ctx, client, srcName, img.Target, i.platform, options) if err != nil { diff --git a/pkg/cmd/image/list.go b/pkg/cmd/image/list.go index f87fce0a272..bbfedb26ab4 100644 --- a/pkg/cmd/image/list.go +++ b/pkg/cmd/image/list.go @@ -24,6 +24,7 @@ import ( "fmt" "io" "os" + "slices" "sort" "strings" "text/tabwriter" @@ -48,11 +49,15 @@ import ( "github.com/containerd/nerdctl/v2/pkg/containerdutil" "github.com/containerd/nerdctl/v2/pkg/formatter" "github.com/containerd/nerdctl/v2/pkg/imgutil" + "github.com/containerd/nerdctl/v2/pkg/labels" "github.com/containerd/nerdctl/v2/pkg/referenceutil" ) // ListCommandHandler `List` and print images matching filters in `options`. func ListCommandHandler(ctx context.Context, client *containerd.Client, options *types.ImageListOptions) error { + if err := ValidateListOptions(options); err != nil { + return err + } imageList, err := List(ctx, client, options.Filters, options.NameAndRefFilter) if err != nil { return err @@ -60,6 +65,34 @@ func ListCommandHandler(ctx context.Context, client *containerd.Client, options return printImages(ctx, client, imageList, options) } +// ValidateListOptions rejects option combinations the list views cannot honor, mirroring the +// checks docker/cli makes in shouldUseTree. Unlike the implicit choice between the default and the +// legacy view, Tree is an explicit request, so silently falling back would be surprising. +// +// It is enforced here, where the options are actually consumed, so that library callers get the +// error too. The CLI calls it as well, so that the error surfaces before a containerd connection +// is attempted. +func ValidateListOptions(options *types.ImageListOptions) error { + if !options.Tree { + return nil + } + for _, conflict := range []struct { + set bool + flag string + }{ + {options.Quiet, "--quiet"}, + {options.NoTrunc, "--no-trunc"}, + {options.Digests, "--digests"}, + {options.Format != "", "--format"}, + {options.Names, "--names"}, + } { + if conflict.set { + return fmt.Errorf("%s is not yet supported with --tree", conflict.flag) + } + } + return nil +} + // List queries containerd client to get image list and only returns those matching given filters. // // Supported filters: @@ -220,23 +253,26 @@ func printImages(ctx context.Context, client *containerd.Client, imageList []ima // In-use detection requires a container scan, so only pay for it in the new view where the // EXTRA column is rendered. var inUse map[digest.Digest]bool + var inUseByPlatform map[platformRef]bool if newView { - inUse = imagesInUse(ctx, client) + inUse, inUseByPlatform = imagesInUse(ctx, client) sortByImageRef(finalImageList) } printer := &imagePrinter{ - w: w, - quiet: options.Quiet, - noTrunc: options.NoTrunc, - digestsFlag: digestsFlag, - namesFlag: options.Names, - newView: newView, - inUse: inUse, - tmpl: tmpl, - client: client, - provider: containerdutil.NewProvider(client), - snapshotter: containerdutil.SnapshotService(client, options.GOptions.Snapshotter), + w: w, + quiet: options.Quiet, + noTrunc: options.NoTrunc, + digestsFlag: digestsFlag, + namesFlag: options.Names, + newView: newView, + tree: options.Tree, + inUse: inUse, + inUseByPlatform: inUseByPlatform, + tmpl: tmpl, + client: client, + provider: containerdutil.NewProvider(client), + snapshotter: containerdutil.SnapshotService(client, options.GOptions.Snapshotter), } for _, img := range finalImageList { @@ -251,13 +287,14 @@ func printImages(ctx context.Context, client *containerd.Client, imageList []ima } type imagePrinter struct { - w io.Writer - quiet, noTrunc, digestsFlag, namesFlag, newView bool - inUse map[digest.Digest]bool // image target -> referenced by at least one container - tmpl *template.Template - client *containerd.Client - provider content.Provider - snapshotter snapshots.Snapshotter + w io.Writer + quiet, noTrunc, digestsFlag, namesFlag, newView, tree bool + inUse map[digest.Digest]bool // image target -> referenced by at least one container + inUseByPlatform map[platformRef]bool // image target + platform -> run by at least one container + tmpl *template.Template + client *containerd.Client + provider content.Provider + snapshotter snapshots.Snapshotter } type image struct { @@ -265,6 +302,13 @@ type image struct { size int64 platform platforms.Platform config *ocispec.Descriptor + // manifestDigest is the digest of the platform-specific manifest itself, used as the per-platform + // ID in the tree view. For a single-platform image it is the image target digest. + manifestDigest digest.Digest + // available reports whether the content of that platform is in the store. An index lists every + // platform of the image, including the ones that were never pulled; only the tree view reports + // those, with zero sizes, the way docker does. + available bool } func readManifest(ctx context.Context, provider content.Provider, snapshotter snapshots.Snapshotter, desc ocispec.Descriptor) (*image, error) { @@ -312,10 +356,12 @@ func readManifest(ctx context.Context, provider content.Provider, snapshotter sn } return &image{ - blobSize: blobSize, - size: size, - platform: plt, - config: &manifest.Config, + blobSize: blobSize, + size: size, + platform: plt, + config: &manifest.Config, + manifestDigest: desc.Digest, + available: true, }, nil } @@ -342,7 +388,20 @@ func readIndex(ctx context.Context, provider content.Provider, snapshotter snaps manifest, err := readManifest(ctx, provider, snapshotter, manifestDescriptor) if err != nil { - continue + // The index lists that platform, but its content is not in the store. Keep it as an + // unavailable entry: docker's tree lists those too, with zero sizes. Without a platform + // to name it by there is nothing to report, so drop it. + if manifestDescriptor.Platform == nil { + continue + } + manifest = &image{manifestDigest: manifestDescriptor.Digest} + } + // Prefer the platform declared by the index: it is the authoritative selector, while the + // image config may be less specific. Alpine, for instance, ships linux/arm/v6 and + // linux/arm/v7 manifests whose configs both say a bare "linux/arm", which normalizes to + // linux/arm/v7 and would collapse the two onto a single key, dropping one of them. + if manifestDescriptor.Platform != nil { + manifest.platform = platforms.Normalize(*manifestDescriptor.Platform) } descs[platforms.FormatAll(manifest.platform)] = manifest } @@ -371,11 +430,20 @@ func (x *imagePrinter) printImage(ctx context.Context, img images.Image) error { return err } + if x.tree { + return x.printImageTree(img, candidateImages) + } + if x.newView { return x.printImageCollapsed(img, candidateImages) } for platform, desc := range candidateImages { + // The legacy table describes what is in the store, so leave out the platforms the index + // mentions but that were never pulled (they also carry no config to describe). + if !desc.available { + continue + } if err := x.printImageSinglePlatform(*desc.config, img, desc.blobSize, desc.size, desc.platform); err != nil { log.G(ctx).WithError(err).Debugf("failed to get platform %q of image %q", platform, img.Name) } @@ -468,12 +536,6 @@ func (x *imagePrinter) printImageCollapsed(img images.Image, candidateImages map diskUsage := units.HumanSizeWithPrecision(float64(totalContentSize+totalSnapshotSize), 3) contentSize := units.HumanSizeWithPrecision(float64(totalContentSize), 3) - // The new view always truncates the ID (it never coexists with --no-trunc). - id := img.Target.Digest.String() - if _, hex, ok := strings.Cut(id, ":"); ok && len(hex) >= 12 { - id = hex[:12] - } - extra := "" if x.inUse[img.Target.Digest] { extra = "U" @@ -481,7 +543,7 @@ func (x *imagePrinter) printImageCollapsed(img images.Image, candidateImages map _, err := fmt.Fprintf(x.w, "%s\t%s\t%s\t%s\t%s\n", newViewImageRef(img.Name), - id, + shortImageID(img.Target.Digest), diskUsage, contentSize, extra, @@ -489,6 +551,72 @@ func (x *imagePrinter) printImageCollapsed(img images.Image, candidateImages map return err } +// Tree branch prefixes for the per-platform rows, matching docker/cli's tree view. +const ( + treeBranch = "├─ " + treeBranchLast = "└─ " +) + +// printImageTree renders `nerdctl images --tree` for one image: the collapsed row, followed by one +// row per platform present in the content store. +// +// Unlike docker/cli, which computes its column widths itself and can afford a blank line between +// images, the rows here go through a tabwriter shared with the header: a blank line would terminate +// its column block and misalign every following group, so the groups are separated by the branch +// glyphs alone. +func (x *imagePrinter) printImageTree(img images.Image, candidateImages map[string]*image) error { + if err := x.printImageCollapsed(img, candidateImages); err != nil { + return err + } + + children := make([]*image, 0, len(candidateImages)) + for _, candidate := range candidateImages { + children = append(children, candidate) + } + // The candidates come from a map, so they have to be ordered. Sorting on the full form rather + // than on the displayed one keeps that order stable even for the platforms that render + // identically, such as two windows/amd64 manifests differing only by OSVersion. + slices.SortFunc(children, func(a, b *image) int { + return strings.Compare(platforms.FormatAll(a.platform), platforms.FormatAll(b.platform)) + }) + + for i, child := range children { + branch := treeBranch + if i == len(children)-1 { + branch = treeBranchLast + } + // The displayed name drops the OSVersion, so it cannot serve as identity: an index may + // carry several windows/amd64 manifests that differ only by it. Match on the full form. + platform := platforms.Format(child.platform) + extra := "" + if x.inUseByPlatform[platformRef{img.Target.Digest, platforms.FormatAll(child.platform)}] { + extra = "U" + } + // Same size semantics as the collapsed row, for this platform alone. + if _, err := fmt.Fprintf(x.w, "%s%s\t%s\t%s\t%s\t%s\n", + branch, + platform, + shortImageID(child.manifestDigest), + units.HumanSizeWithPrecision(float64(child.blobSize+child.size), 3), + units.HumanSizeWithPrecision(float64(child.blobSize), 3), + extra, + ); err != nil { + return err + } + } + return nil +} + +// shortImageID renders a digest the way the Docker v29 views do: the hex part, truncated to 12 +// characters. Those views never coexist with --no-trunc, so the ID is always truncated. +func shortImageID(dgst digest.Digest) string { + id := dgst.String() + if _, hex, ok := strings.Cut(id, ":"); ok && len(hex) >= 12 { + return hex[:12] + } + return id +} + // printImagesLegend writes the right-aligned "In Use" legend for the Docker v29 default view. // Matching Docker, it is only emitted when the output is a terminal with a known width, so it // never pollutes piped or redirected output. @@ -563,16 +691,29 @@ func referenceHasDomain(name string) bool { return host == "localhost" || strings.ContainsAny(host, ".:") } -// imagesInUse returns the set of image target digests that are referenced by at least one -// container (in any state), used to render the Docker v29 "In Use" (U) indicator. Docker matches -// containers to images by digest, so every name pointing at the same target is flagged, not just -// the one the container was created from. -func imagesInUse(ctx context.Context, client *containerd.Client) map[digest.Digest]bool { +// platformRef identifies a single platform of a single image, used to flag the exact manifest a +// container runs in the tree view. +type platformRef struct { + target digest.Digest + // platform is in platforms.FormatAll form: the identity of a platform, unlike the name the + // tree displays, has to keep the OSVersion. + platform string +} + +// imagesInUse returns the image target digests that are referenced by at least one container (in +// any state), used to render the Docker v29 "In Use" (U) indicator. Docker matches containers to +// images by digest, so every name pointing at the same target is flagged, not just the one the +// container was created from. +// +// The second return value narrows this down to the platform each container actually runs, for the +// per-platform rows of the tree view. Both are collected in a single container scan. +func imagesInUse(ctx context.Context, client *containerd.Client) (map[digest.Digest]bool, map[platformRef]bool) { inUse := map[digest.Digest]bool{} + inUseByPlatform := map[platformRef]bool{} containerList, err := client.Containers(ctx) if err != nil { log.G(ctx).WithError(err).Warn("failed to list containers for image in-use detection") - return inUse + return inUse, inUseByPlatform } for _, container := range containerList { image, err := container.Image(ctx) @@ -580,8 +721,39 @@ func imagesInUse(ctx context.Context, client *containerd.Client) map[digest.Dige continue } inUse[image.Target().Digest] = true + inUseByPlatform[platformRef{image.Target().Digest, containerPlatform(ctx, container)}] = true + } + return inUse, inUseByPlatform +} + +// containerPlatform reports the platform a container runs. Containers created outside nerdctl +// (e.g. by ctr or the CRI plugin) carry no platform label; assume the default platform for those, +// as pkg/imgutil/commit and `nerdctl container diff` already do. +func containerPlatform(ctx context.Context, container containerd.Container) string { + platform := "" + // The already-loaded metadata carries the labels, so this costs no extra round trip. + if info, err := container.Info(ctx, containerd.WithoutRefreshedMetadata); err == nil { + platform = info.Labels[labels.Platform] + } + if platform == "" { + platform = platforms.DefaultString() + } + return normalizePlatform(platform) +} + +// normalizePlatform renders a platform string in the full form the manifest platforms are keyed by, +// so that the two can be compared. +// +// It keeps the OSVersion, which is what tells two otherwise identical windows/amd64 manifests +// apart, and normalizes the rest: platforms.DefaultString does not normalize, so on arm64 the +// label can carry a "v8"/"8" variant while the manifest platform normalizes to a bare +// "linux/arm64", and a raw comparison would never match. +func normalizePlatform(platform string) string { + parsed, err := platforms.Parse(platform) + if err != nil { + return platform } - return inUse + return platforms.FormatAll(platforms.Normalize(parsed)) } func isAttestationManifestDescriptor(desc ocispec.Descriptor) bool { diff --git a/pkg/cmd/image/list_test.go b/pkg/cmd/image/list_test.go index da83f8fc769..1854ded938d 100644 --- a/pkg/cmd/image/list_test.go +++ b/pkg/cmd/image/list_test.go @@ -17,11 +17,18 @@ package image import ( + "bytes" + "strings" "testing" + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" "gotest.tools/v3/assert" "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/platforms" + + "github.com/containerd/nerdctl/v2/pkg/api/types" ) func TestNewViewImageRef(t *testing.T) { @@ -75,3 +82,257 @@ func TestSortByImageRef(t *testing.T) { assert.Equal(t, img.Name, expected[i]) } } + +func TestValidateListOptions(t *testing.T) { + testCases := []struct { + name string + options types.ImageListOptions + expected string + }{ + { + name: "no conflict without Tree", + options: types.ImageListOptions{Quiet: true, Format: "json"}, + }, + { + name: "no conflict for Tree alone", + options: types.ImageListOptions{Tree: true}, + }, + { + name: "quiet", + options: types.ImageListOptions{Tree: true, Quiet: true}, + expected: "--quiet is not yet supported with --tree", + }, + { + name: "no-trunc", + options: types.ImageListOptions{Tree: true, NoTrunc: true}, + expected: "--no-trunc is not yet supported with --tree", + }, + { + name: "digests", + options: types.ImageListOptions{Tree: true, Digests: true}, + expected: "--digests is not yet supported with --tree", + }, + { + name: "format", + options: types.ImageListOptions{Tree: true, Format: "json"}, + expected: "--format is not yet supported with --tree", + }, + { + name: "names", + options: types.ImageListOptions{Tree: true, Names: true}, + expected: "--names is not yet supported with --tree", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := ValidateListOptions(&tc.options) + if tc.expected == "" { + assert.NilError(t, err) + return + } + assert.Error(t, err, tc.expected) + }) + } +} + +func TestShortImageID(t *testing.T) { + testCases := []struct { + name string + dgst digest.Digest + expected string + }{ + { + name: "digest is truncated to 12 hex characters", + dgst: digest.Digest("sha256:" + strings.Repeat("a", 64)), + expected: strings.Repeat("a", 12), + }, + { + name: "a value without an algorithm is left alone", + dgst: digest.Digest("not-a-digest"), + expected: "not-a-digest", + }, + { + name: "a hex part shorter than 12 characters is left alone", + dgst: digest.Digest("sha256:abcd"), + expected: "sha256:abcd", + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, shortImageID(tc.dgst), tc.expected) + }) + } +} + +func TestNormalizePlatform(t *testing.T) { + // The platforms are keyed by platforms.FormatAll(platforms.Normalize(...)), so an arm64 + // manifest is keyed as a bare "linux/arm64" while a Windows one keeps its OSVersion. A + // container's label may still carry the arm variant, because platforms.DefaultString does not + // normalize. + testCases := []struct { + platform string + expected string + }{ + {"linux/arm64/v8", "linux/arm64"}, + {"linux/arm64/8", "linux/arm64"}, + {"linux/arm64", "linux/arm64"}, + {"linux/amd64", "linux/amd64"}, + {"linux/arm/v7", "linux/arm/v7"}, + {"linux/armhf", "linux/arm/v7"}, + // the OSVersion is what tells two windows/amd64 manifests apart, so it is kept + {"windows(10.0.20348.2582)/amd64", "windows(10.0.20348.2582)/amd64"}, + {"windows/amd64", "windows/amd64"}, + // an unparsable value is passed through rather than dropped + {"", ""}, + } + for _, tc := range testCases { + t.Run(tc.platform, func(t *testing.T) { + assert.Equal(t, normalizePlatform(tc.platform), tc.expected) + }) + } +} + +// treeTestImage builds a candidate platform entry with sizes chosen to render exactly at the +// 3-significant-digit precision the Docker v29 views use. +func treeTestImage(os, arch string, dgst digest.Digest, blobSize, snapshotSize int64) *image { + return &image{ + blobSize: blobSize, + size: snapshotSize, + platform: platforms.Platform{OS: os, Architecture: arch}, + manifestDigest: dgst, + available: true, + } +} + +func TestPrintImageTree(t *testing.T) { + const ( + targetDigest = digest.Digest("sha256:" + "1111111111111111111111111111111111111111111111111111111111111111") + amd64Digest = digest.Digest("sha256:" + "2222222222222222222222222222222222222222222222222222222222222222") + arm64Digest = digest.Digest("sha256:" + "3333333333333333333333333333333333333333333333333333333333333333") + ) + img := images.Image{ + Name: "docker.io/library/nginx:latest", + Target: ocispec.Descriptor{Digest: targetDigest}, + } + + t.Run("multi-platform image expands into a sorted row per platform", func(t *testing.T) { + var buf bytes.Buffer + printer := &imagePrinter{ + w: &buf, + newView: true, + tree: true, + inUse: map[digest.Digest]bool{targetDigest: true}, + // Only the amd64 manifest is actually run by a container. + inUseByPlatform: map[platformRef]bool{{targetDigest, "linux/amd64"}: true}, + } + // Deliberately insert arm64 first: the candidates come from a map, so the printer has to + // sort them itself to stay deterministic. + candidates := map[string]*image{ + "linux/arm64": treeTestImage("linux", "arm64", arm64Digest, 24_000_000, 45_000_000), + "linux/amd64": treeTestImage("linux", "amd64", amd64Digest, 25_000_000, 47_000_000), + } + + assert.NilError(t, printer.printImageTree(img, candidates)) + + // DISK USAGE is content plus snapshots, CONTENT SIZE is content alone; the parent row + // aggregates both across the platforms. + expected := strings.Join([]string{ + "nginx:latest\t111111111111\t141MB\t49MB\tU", + "├─ linux/amd64\t222222222222\t72MB\t25MB\tU", + "└─ linux/arm64\t333333333333\t69MB\t24MB\t", + }, "\n") + "\n" + assert.Equal(t, buf.String(), expected) + }) + + // Regression test: the displayed name drops the OSVersion, so using it as the identity made a + // container on one Windows build flag every windows/amd64 row of the index. + t.Run("windows platforms differing only by OSVersion are told apart", func(t *testing.T) { + var buf bytes.Buffer + printer := &imagePrinter{ + w: &buf, + newView: true, + tree: true, + inUse: map[digest.Digest]bool{targetDigest: true}, + // A container runs the older build only. + inUseByPlatform: map[platformRef]bool{{targetDigest, "windows(10.0.20348.2582)/amd64"}: true}, + } + candidates := map[string]*image{ + "windows(10.0.26100.1)/amd64": { + blobSize: 24_000_000, + size: 45_000_000, + platform: platforms.Platform{OS: "windows", Architecture: "amd64", OSVersion: "10.0.26100.1"}, + manifestDigest: arm64Digest, + available: true, + }, + "windows(10.0.20348.2582)/amd64": { + blobSize: 25_000_000, + size: 47_000_000, + platform: platforms.Platform{OS: "windows", Architecture: "amd64", OSVersion: "10.0.20348.2582"}, + manifestDigest: amd64Digest, + available: true, + }, + } + + assert.NilError(t, printer.printImageTree(img, candidates)) + + // Both rows render as windows/amd64, but only the one the container runs is flagged, and + // the order is stable because the sort uses the full platform. + expected := strings.Join([]string{ + "nginx:latest\t111111111111\t141MB\t49MB\tU", + "├─ windows/amd64\t222222222222\t72MB\t25MB\tU", + "└─ windows/amd64\t333333333333\t69MB\t24MB\t", + }, "\n") + "\n" + assert.Equal(t, buf.String(), expected) + }) + + t.Run("a platform listed by the index but never pulled has no size", func(t *testing.T) { + var buf bytes.Buffer + printer := &imagePrinter{ + w: &buf, + newView: true, + tree: true, + inUse: map[digest.Digest]bool{}, + inUseByPlatform: map[platformRef]bool{}, + } + candidates := map[string]*image{ + "linux/amd64": treeTestImage("linux", "amd64", amd64Digest, 25_000_000, 47_000_000), + // Listed by the index, but its content is not in the store: no config, no sizes. + "linux/arm64": { + platform: platforms.Platform{OS: "linux", Architecture: "arm64"}, + manifestDigest: arm64Digest, + }, + } + + assert.NilError(t, printer.printImageTree(img, candidates)) + + expected := strings.Join([]string{ + "nginx:latest\t111111111111\t72MB\t25MB\t", + "├─ linux/amd64\t222222222222\t72MB\t25MB\t", + "└─ linux/arm64\t333333333333\t0B\t0B\t", + }, "\n") + "\n" + assert.Equal(t, buf.String(), expected) + }) + + t.Run("single-platform image gets a single closing branch", func(t *testing.T) { + var buf bytes.Buffer + printer := &imagePrinter{ + w: &buf, + newView: true, + tree: true, + inUse: map[digest.Digest]bool{}, + inUseByPlatform: map[platformRef]bool{}, + } + candidates := map[string]*image{ + "linux/amd64": treeTestImage("linux", "amd64", targetDigest, 25_000_000, 47_000_000), + } + + assert.NilError(t, printer.printImageTree(img, candidates)) + + expected := strings.Join([]string{ + "nginx:latest\t111111111111\t72MB\t25MB\t", + "└─ linux/amd64\t111111111111\t72MB\t25MB\t", + }, "\n") + "\n" + assert.Equal(t, buf.String(), expected) + }) +} diff --git a/pkg/testutil/images.yaml b/pkg/testutil/images.yaml index 4e51e332237..2499fbb83f4 100644 --- a/pkg/testutil/images.yaml +++ b/pkg/testutil/images.yaml @@ -8,15 +8,32 @@ alpine: schemaversion: 2 mediatype: "application/vnd.docker.distribution.manifest.list.v2+json" digest: "sha256:ec14c7992a97fc11425907e908340c6c3d6ff602f5f13d899e6b7027c9b4133a" - variants: ["linux/amd64", "linux/arm64"] + variants: ["linux/386", "linux/amd64", "linux/arm/v6", "linux/arm/v7", "linux/arm64", "linux/ppc64le", "linux/s390x"] manifests: linux/amd64: mediatype: "application/vnd.docker.distribution.manifest.v2+json" manifest: "sha256:e103c1b4bf019dc290bcc7aca538dc2bf7a9d0fc836e186f5fa34945c5168310" config: "sha256:49f356fa4513676c5e22e3a8404aad6c7262cc7aaed15341458265320786c58c" raw: "ewogICAic2NoZW1hVmVyc2lvbiI6IDIsCiAgICJtZWRpYVR5cGUiOiAiYXBwbGljYXRpb24vdm5kLmRvY2tlci5kaXN0cmlidXRpb24ubWFuaWZlc3QudjIranNvbiIsCiAgICJjb25maWciOiB7CiAgICAgICJtZWRpYVR5cGUiOiAiYXBwbGljYXRpb24vdm5kLmRvY2tlci5jb250YWluZXIuaW1hZ2UudjEranNvbiIsCiAgICAgICJzaXplIjogMTQ3MiwKICAgICAgImRpZ2VzdCI6ICJzaGEyNTY6NDlmMzU2ZmE0NTEzNjc2YzVlMjJlM2E4NDA0YWFkNmM3MjYyY2M3YWFlZDE1MzQxNDU4MjY1MzIwNzg2YzU4YyIKICAgfSwKICAgImxheWVycyI6IFsKICAgICAgewogICAgICAgICAibWVkaWFUeXBlIjogImFwcGxpY2F0aW9uL3ZuZC5kb2NrZXIuaW1hZ2Uucm9vdGZzLmRpZmYudGFyLmd6aXAiLAogICAgICAgICAic2l6ZSI6IDI4MTE5NDcsCiAgICAgICAgICJkaWdlc3QiOiAic2hhMjU2OmNhM2NkNDJhN2M5NTI1ZjZjZTNkNjRjMWE3MDk4MjYxM2E4MjM1ZjBjYzA1N2VjOTI0NDA1MjkyMTg1M2VmMTUiCiAgICAgIH0KICAgXQp9" + contentsize: 2813947 linux/arm64: manifest: "sha256:071fa5de01a240dbef5be09d69f8fef2f89d68445d9175393773ee389b6f5935" + contentsize: 2713919 + linux/arm/v6: + manifest: "sha256:cba24b50b9d81704968f65455897a3a519568b236c174c9040135c5afee5dc54" + contentsize: 2624114 + linux/arm/v7: + manifest: "sha256:59b46c319f3b66dfda96faafd0c6959e9b2f409792d0236204f270dfd0235960" + contentsize: 2426106 + linux/386: + manifest: "sha256:e10c13a5af47b1f2f5e3fbb9355fa82bc1234567a83a549d61d15697131e6e66" + contentsize: 2820800 + linux/ppc64le: + manifest: "sha256:f3a907bc0278ea0de7ddafcbca3c9a63cee253a4698eb248a4416d46fc906dbd" + contentsize: 2815221 + linux/s390x: + manifest: "sha256:44f0cac18b69c3867be12e78766393adf801560a102fe0113bb4abc981acf9bf" + contentsize: 2604591 busybox: ref: "ghcr.io/containerd/busybox" diff --git a/pkg/testutil/images_linux.go b/pkg/testutil/images_linux.go index c566e6274e5..9141ecc7b43 100644 --- a/pkg/testutil/images_linux.go +++ b/pkg/testutil/images_linux.go @@ -19,6 +19,7 @@ package testutil import ( _ "embed" "fmt" + "slices" "sync" "go.yaml.in/yaml/v3" @@ -34,6 +35,9 @@ type manifestInfo struct { Manifest string `yaml:"manifest,omitempty"` MediaType string `yaml:"mediatype,omitempty"` Raw string `yaml:"raw,omitempty"` + // ContentSize is the sum of the blob sizes of this platform (its manifest, config and layers), + // which is what `nerdctl images` reports as CONTENT SIZE. + ContentSize int64 `yaml:"contentsize,omitempty"` } type TestImage struct { @@ -124,3 +128,26 @@ func GetTestImageRaw(key, platform string) string { } return pd.Raw } + +// GetTestImageContentSize returns the expected CONTENT SIZE of one platform of a test image, in +// bytes: the sum of the sizes of its manifest, config and layer blobs. +func GetTestImageContentSize(key, platform string) int64 { + im := lookup(key) + pd, ok := im.Manifests[platform] + if !ok { + panic(fmt.Sprintf("platform %s not found for image %s", platform, key)) + } + return pd.ContentSize +} + +// GetTestImagePlatforms returns the platforms declared for a test image, sorted, in the normalized +// form the image listing prints them (e.g. "linux/arm64", not "linux/arm64/v8"). +func GetTestImagePlatforms(key string) []string { + im := lookup(key) + platformz := make([]string, 0, len(im.Manifests)) + for platform := range im.Manifests { + platformz = append(platformz, platform) + } + slices.Sort(platformz) + return platformz +}