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 +}