From a31ddac284b75468f1a977700a49e1a65b7f426e Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Wed, 5 Aug 2026 21:23:48 +0300 Subject: [PATCH 1/2] fix(image): pin the image a container runs to its digest `nerdctl images` marks an image as in use by resolving the image name stored on the container, which follows the tag wherever it points now. After `nerdctl tag` moves a tag onto another image, the container gets attributed to an image it never ran: the U indicator lands on the wrong row. Record the image target digest on the container at creation time, in a new nerdctl/image-digest label, and use it for the in-use lookup. Containers created before this label existed, or created outside nerdctl, are still resolved by name; an unparsable value falls back the same way rather than dropping the container from the set. This also matters for the ACTIVE and RECLAIMABLE columns of `nerdctl system df`, which build on the same lookup. Signed-off-by: Eugene Kalinin --- cmd/nerdctl/image/image_list_test.go | 62 ++++++++++++++++++++++++++++ pkg/cmd/container/create.go | 12 ++++++ pkg/cmd/image/list.go | 44 ++++++++++++++++++-- pkg/cmd/image/list_test.go | 45 ++++++++++++++++++++ pkg/labels/labels.go | 5 +++ 5 files changed, 164 insertions(+), 4 deletions(-) diff --git a/cmd/nerdctl/image/image_list_test.go b/cmd/nerdctl/image/image_list_test.go index f9f5ad3a594..4281b0339a1 100644 --- a/cmd/nerdctl/image/image_list_test.go +++ b/cmd/nerdctl/image/image_list_test.go @@ -38,6 +38,17 @@ import ( "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" ) +// padRow widens a row of a table back to the width of its header, so that its last column can be +// read. tabutil indexes the columns by byte offset and slices without checking the bounds, and a +// row can be shorter than the header in two ways: the trailing column is empty, and the padding of +// the very last line is gone once the output has been trimmed. +func padRow(header, row string) string { + if pad := len(header) - len(row); pad > 0 { + return row + strings.Repeat(" ", pad) + } + return row +} + // TestNameFilterFor is a regression test for // https://github.com/containerd/nerdctl/issues/5113: `nerdctl image ls // myapp`, where myapp is a bare repository name, returned nothing unless @@ -207,6 +218,57 @@ func TestImages(t *testing.T) { } }, }, + { + Description: "In use survives a retag", + Setup: func(data test.Data, helpers test.Helpers) { + // Run a container off a private tag, then move that tag onto another image. + // The container still runs the original image, so that is the one that must + // stay marked as in use. + helpers.Ensure("tag", commonImage.String(), data.Identifier()+":moving") + helpers.Ensure("run", "-d", "--quiet", "--name", data.Identifier(), + data.Identifier()+":moving", "sleep", nerdtest.Infinity) + helpers.Ensure("tag", testutil.NginxAlpineImage, data.Identifier()+":moving") + + nginx, _ := referenceutil.Parse(testutil.NginxAlpineImage) + data.Labels().Set("retaggedTo", nginx.FamiliarName()+":"+nginx.Tag) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + helpers.Anyhow("rmi", "-f", data.Identifier()+":moving") + }, + Command: test.Command("images"), + 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) >= 2, "there should be at least two lines\n") + tab := tabutil.NewReader("IMAGE\tID\tDISK USAGE\tCONTENT SIZE\tEXTRA") + err := tab.ParseHeader(lines[0]) + assert.NilError(t, err, "ParseHeader should not fail\n") + + original := commonImage.FamiliarName() + ":" + commonImage.Tag + retagged := data.Labels().Get("retaggedTo") + seen := 0 + for _, line := range lines[1:] { + line = padRow(lines[0], line) + image, _ := tab.ReadRow(line, "IMAGE") + extra, _ := tab.ReadRow(line, "EXTRA") + switch image { + case original: + assert.Equal(t, extra, "U", + "the image the container runs must stay in use: "+image) + seen++ + case retagged: + assert.Equal(t, extra, "", + "the image the tag now points at is not in use: "+image) + seen++ + } + } + assert.Equal(t, seen, 2, "both images should be listed\n") + }, + } + }, + }, }, } diff --git a/pkg/cmd/container/create.go b/pkg/cmd/container/create.go index 83abc56ad0b..e2f89159d95 100644 --- a/pkg/cmd/container/create.go +++ b/pkg/cmd/container/create.go @@ -215,6 +215,12 @@ func Create(ctx context.Context, client *containerd.Client, args []string, netMa internalLabels.user = ensuredImage.ImageConfig.User } + // Pin the image the container is created from. containerd only records the image name, and a + // name can later be retagged onto a different image. + if ensuredImage != nil && ensuredImage.Image != nil { + internalLabels.imageDigest = ensuredImage.Image.Target().Digest.String() + } + // Override it if User is passed if options.User != "" { internalLabels.user = options.User @@ -811,6 +817,8 @@ type internalLabels struct { domainname string // automatically generated stateDir string + // the digest of the image target the container was created from + imageDigest string // network networks []string ipAddress string @@ -919,6 +927,10 @@ func withInternalLabels(internalLabels internalLabels) (containerd.NewContainerO return nil, err } + if internalLabels.imageDigest != "" { + m[labels.ImageDigest] = internalLabels.imageDigest + } + if len(internalLabels.mountPoints) > 0 { mounts := dockercompatMounts(internalLabels.mountPoints) jsonMountBytes, err := json.Marshal(mounts) diff --git a/pkg/cmd/image/list.go b/pkg/cmd/image/list.go index f87fce0a272..703baa6679a 100644 --- a/pkg/cmd/image/list.go +++ b/pkg/cmd/image/list.go @@ -48,6 +48,7 @@ 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" ) @@ -575,15 +576,50 @@ func imagesInUse(ctx context.Context, client *containerd.Client) map[digest.Dige return inUse } for _, container := range containerList { - image, err := container.Image(ctx) - if err != nil { - continue + if dgst, ok := containerImageDigest(ctx, container); ok { + inUse[dgst] = true } - inUse[image.Target().Digest] = true } return inUse } +// containerImageDigest returns the image target a container was created from. +// +// The digest is read from the label nerdctl records at creation time. Resolving the image name +// instead would follow the tag wherever it points now: after `nerdctl tag` moves a tag onto another +// image, the container would be attributed to an image it never ran. Containers created before this +// label existed, or outside nerdctl, still have to be resolved by name. +func containerImageDigest(ctx context.Context, container containerd.Container) (digest.Digest, bool) { + // The already-loaded metadata carries the labels, so this costs no extra round trip. + if info, err := container.Info(ctx, containerd.WithoutRefreshedMetadata); err == nil { + if dgst, ok := pinnedImageDigest(info.Labels); ok { + return dgst, true + } + } + + image, err := container.Image(ctx) + if err != nil { + return "", false + } + return image.Target().Digest, true +} + +// pinnedImageDigest returns the image target digest a container pinned at creation time. An +// unparsable value is treated as absent, so that a hand-edited label degrades to resolving the +// image by name rather than dropping the container from the in-use set. +func pinnedImageDigest(containerLabels map[string]string) (digest.Digest, bool) { + value := containerLabels[labels.ImageDigest] + if value == "" { + return "", false + } + dgst, err := digest.Parse(value) + if err != nil { + log.L.Debugf("ignoring invalid %s label value %q", labels.ImageDigest, value) + return "", false + } + return dgst, true +} + func isAttestationManifestDescriptor(desc ocispec.Descriptor) bool { const manifestReferenceType = "vnd.docker.reference.type" const attestationManifest = "attestation-manifest" diff --git a/pkg/cmd/image/list_test.go b/pkg/cmd/image/list_test.go index da83f8fc769..ccfa2a1338a 100644 --- a/pkg/cmd/image/list_test.go +++ b/pkg/cmd/image/list_test.go @@ -22,6 +22,8 @@ import ( "gotest.tools/v3/assert" "github.com/containerd/containerd/v2/core/images" + + "github.com/containerd/nerdctl/v2/pkg/labels" ) func TestNewViewImageRef(t *testing.T) { @@ -75,3 +77,46 @@ func TestSortByImageRef(t *testing.T) { assert.Equal(t, img.Name, expected[i]) } } + +func TestPinnedImageDigest(t *testing.T) { + t.Parallel() + + const pinned = "sha256:09538a1f51d3ec5af0449a1640937dfdf79b0e9b8c4da5b8a883086d5c1492ef" + + testCases := []struct { + name string + containerLabels map[string]string + expected string + }{ + { + name: "pinned at creation", + containerLabels: map[string]string{labels.ImageDigest: pinned}, + expected: pinned, + }, + { + // Containers created before the label existed, or outside nerdctl, have to be resolved + // by image name instead. + name: "no label", + containerLabels: map[string]string{labels.Platform: "linux/amd64"}, + }, + { + name: "empty label", + containerLabels: map[string]string{labels.ImageDigest: ""}, + }, + { + // Falling back to the name is better than dropping the container from the in-use set. + name: "unparsable label", + containerLabels: map[string]string{labels.ImageDigest: "not-a-digest"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + dgst, ok := pinnedImageDigest(tc.containerLabels) + assert.Equal(t, ok, tc.expected != "") + assert.Equal(t, string(dgst), tc.expected) + }) + } +} diff --git a/pkg/labels/labels.go b/pkg/labels/labels.go index dd32cca0616..9f689ff7cff 100644 --- a/pkg/labels/labels.go +++ b/pkg/labels/labels.go @@ -87,6 +87,11 @@ const ( // Platform is the normalized platform string like "linux/ppc64le". Platform = Prefix + "platform" + // ImageDigest is the digest of the image target the container was created from. The image name + // stored by containerd can be retagged to point at something else, so it is not enough to tell + // which image a container actually uses. + ImageDigest = Prefix + "image-digest" + // Mounts is the mount points for the container. Mounts = Prefix + "mounts" From 5f44c0359a9228a2c1571a6ee97c076d7e53f02e Mon Sep 17 00:00:00 2001 From: Eugene Kalinin Date: Fri, 7 Aug 2026 19:04:05 +0300 Subject: [PATCH 2/2] feat(system): add nerdctl system df Add `nerdctl system df`, the equivalent of `docker system df`, reporting how much disk space the images, containers and local volumes of the current namespace use, plus the BuildKit build cache. The sizes follow the Docker v29 definitions for the containerd image store: - the size of an image is the content present in the content store plus its unpacked snapshots, which is the same value `nerdctl images` shows as DISK USAGE, - the SIZE of the Images row counts every snapshot and every blob once, so it is the space really taken on disk rather than the sum of the image sizes, - an image is active when a container references it, and only the part of an unused image that no other image shares is reclaimable, - containers contribute their read-write layer only, and everything not running is reclaimable, - a volume is active when a container mounts it, counted once however many paths it is mounted at, as the LINKS column of Docker is a count of containers, - a build cache record is reclaimable when it is neither in use nor shared. An image reports when it was built, read from the config of the manifest the platform matcher of this host selects: the platforms of an index are not necessarily built together, and the creation time of the local record only says when the image was pulled or tagged. The record time stays as the fallback for the images that state nothing. Both `--format` and `-v/--verbose` are supported, including the Docker `table TEMPLATE` format, e.g. `table {{.Type}}\t{{.Size}}`: the literal \t and \n are expanded, the chosen columns get a header that names them whatever the template does to the values below, and the rows stay aligned. That helper lives in pkg/formatter so that the other commands, which all share this gap, can adopt it. Identifiers are shortened for the table output only, so that a custom format stays usable to look a resource up. The work is split the way `system prune` already is: `pkg/cmd/system` orchestrates, and each kind of resource is measured by its own package. Closes #3942 Signed-off-by: Eugene Kalinin --- cmd/nerdctl/system/system.go | 1 + cmd/nerdctl/system/system_df.go | 90 +++++ cmd/nerdctl/system/system_df_linux_test.go | 70 ++++ cmd/nerdctl/system/system_df_test.go | 275 +++++++++++++ docs/command-reference.md | 34 +- pkg/api/types/builder_types.go | 11 + pkg/api/types/diskusage_types.go | 118 ++++++ pkg/api/types/system_types.go | 14 + pkg/cmd/builder/df.go | 106 +++++ pkg/cmd/builder/df_test.go | 106 +++++ pkg/cmd/container/df.go | 158 ++++++++ pkg/cmd/image/df.go | 372 ++++++++++++++++++ pkg/cmd/image/df_test.go | 316 +++++++++++++++ pkg/cmd/image/list.go | 20 +- pkg/cmd/system/df.go | 413 +++++++++++++++++++ pkg/cmd/system/df_test.go | 435 +++++++++++++++++++++ pkg/cmd/volume/df.go | 76 ++++ pkg/cmd/volume/df_test.go | 77 ++++ pkg/cmd/volume/rm.go | 41 +- pkg/containerdutil/content.go | 40 ++ pkg/formatter/common.go | 33 ++ pkg/formatter/table_test.go | 93 +++++ 22 files changed, 2875 insertions(+), 24 deletions(-) create mode 100644 cmd/nerdctl/system/system_df.go create mode 100644 cmd/nerdctl/system/system_df_linux_test.go create mode 100644 cmd/nerdctl/system/system_df_test.go create mode 100644 pkg/api/types/diskusage_types.go create mode 100644 pkg/cmd/builder/df.go create mode 100644 pkg/cmd/builder/df_test.go create mode 100644 pkg/cmd/container/df.go create mode 100644 pkg/cmd/image/df.go create mode 100644 pkg/cmd/image/df_test.go create mode 100644 pkg/cmd/system/df.go create mode 100644 pkg/cmd/system/df_test.go create mode 100644 pkg/cmd/volume/df.go create mode 100644 pkg/cmd/volume/df_test.go create mode 100644 pkg/formatter/table_test.go diff --git a/cmd/nerdctl/system/system.go b/cmd/nerdctl/system/system.go index dee993f45f7..d460baac071 100644 --- a/cmd/nerdctl/system/system.go +++ b/cmd/nerdctl/system/system.go @@ -33,6 +33,7 @@ func Command() *cobra.Command { } // versionCommand is not here cmd.AddCommand( + dfCommand(), EventsCommand(), InfoCommand(), pruneCommand(), diff --git a/cmd/nerdctl/system/system_df.go b/cmd/nerdctl/system/system_df.go new file mode 100644 index 00000000000..467b812225a --- /dev/null +++ b/cmd/nerdctl/system/system_df.go @@ -0,0 +1,90 @@ +/* + 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 system + +import ( + "github.com/spf13/cobra" + + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/cmd/nerdctl/builder" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/cmd/system" +) + +func dfCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "df [flags]", + Short: "Show nerdctl disk usage", + Args: cobra.NoArgs, + RunE: dfAction, + SilenceUsage: true, + SilenceErrors: true, + } + cmd.Flags().BoolP("verbose", "v", false, "Show detailed information on space usage") + cmd.Flags().String("format", "", "Format the output using the given Go template, e.g, '{{json .}}'") + return cmd +} + +func dfOptions(cmd *cobra.Command) (types.SystemDfOptions, error) { + globalOptions, err := helpers.ProcessRootCmdFlags(cmd) + if err != nil { + return types.SystemDfOptions{}, err + } + + verbose, err := cmd.Flags().GetBool("verbose") + if err != nil { + return types.SystemDfOptions{}, err + } + + format, err := cmd.Flags().GetString("format") + if err != nil { + return types.SystemDfOptions{}, err + } + + buildkitHost, err := builder.GetBuildkitHost(cmd, globalOptions.Namespace) + if err != nil { + log.L.WithError(err).Warn("BuildKit is not running. The build cache usage will be reported as empty.") + buildkitHost = "" + } + + return types.SystemDfOptions{ + Stdout: cmd.OutOrStdout(), + Stderr: cmd.ErrOrStderr(), + GOptions: globalOptions, + Format: format, + Verbose: verbose, + BuildKitHost: buildkitHost, + }, nil +} + +func dfAction(cmd *cobra.Command, _ []string) error { + options, err := dfOptions(cmd) + if err != nil { + return err + } + + client, ctx, cancel, err := clientutil.NewClient(cmd.Context(), options.GOptions.Namespace, options.GOptions.Address) + if err != nil { + return err + } + defer cancel() + + return system.Df(ctx, client, options) +} diff --git a/cmd/nerdctl/system/system_df_linux_test.go b/cmd/nerdctl/system/system_df_linux_test.go new file mode 100644 index 00000000000..f6795d2ba44 --- /dev/null +++ b/cmd/nerdctl/system/system_df_linux_test.go @@ -0,0 +1,70 @@ +/* + 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 system + +import ( + "fmt" + "testing" + + "github.com/containerd/nerdctl/mod/tigron/test" + "github.com/containerd/nerdctl/mod/tigron/tig" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" +) + +// TestSystemDfVolumes covers the Local Volumes row, which the rest of TestSystemDf cannot: a volume +// is only counted once a container mounts it, and the target of a mount is written differently on +// each platform. +func TestSystemDfVolumes(t *testing.T) { + testCase := nerdtest.Setup() + + // The counts are only meaningful when nothing else is running against the same namespace. + testCase.NoParallel = true + + testCase.SubTests = []*test.Case{ + { + Description: "mounted volume is active", + Require: nerdtest.Private, + Setup: func(data test.Data, helpers test.Helpers) { + data.Labels().Set(baselineLabel, helpers.Capture("system", "df")) + helpers.Ensure("volume", "create", data.Identifier()) + helpers.Ensure("run", "-d", "--name", data.Identifier(), + "-v", fmt.Sprintf("%s:/volume", data.Identifier()), + testutil.CommonImage, "sleep", nerdtest.Infinity) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + helpers.Anyhow("volume", "rm", "-f", data.Identifier()) + }, + Command: test.Command("system", "df"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: func(stdout string, t tig.T) { + // The volume is created by this test and the container it runs mounts it, + // so both counts went up by it. + dfGrewBy(t, data, stdout, "Local Volumes", totalColumn, 1) + dfGrewBy(t, data, stdout, "Local Volumes", activeColumn, 1) + }, + } + }, + }, + } + + testCase.Run(t) +} diff --git a/cmd/nerdctl/system/system_df_test.go b/cmd/nerdctl/system/system_df_test.go new file mode 100644 index 00000000000..404e24c4766 --- /dev/null +++ b/cmd/nerdctl/system/system_df_test.go @@ -0,0 +1,275 @@ +/* + 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 system + +import ( + "encoding/json" + "strconv" + "strings" + "testing" + + "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/nerdctl/v2/pkg/testutil" + "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest" +) + +// dfRow returns the columns of the `nerdctl system df` summary row of the given type. The type is +// matched as a prefix because "Local Volumes" contains a space. +func dfRow(t tig.T, stdout, rowType string) []string { + for line := range strings.SplitSeq(stdout, "\n") { + if columns, ok := strings.CutPrefix(line, rowType); ok { + return strings.Fields(columns) + } + } + t.Log(stdout) + t.FailNow() + return nil +} + +// dfTotal and dfActive are the TOTAL and ACTIVE columns of a summary row. +func dfTotal(t tig.T, stdout, rowType string) string { + return dfRow(t, stdout, rowType)[0] +} + +func dfActive(t tig.T, stdout, rowType string) string { + return dfRow(t, stdout, rowType)[1] +} + +// baselineLabel holds the output of `system df` from before the test created anything. +const baselineLabel = "df-baseline" + +// dfGrewBy asserts that a column of a summary row went up by n since the baseline. Only the +// difference a test makes can be asserted: `nerdtest.Private` gives nerdctl a namespace of its own, +// but docker has none, so its daemon still holds whatever the other tests left behind. +func dfGrewBy(t tig.T, data test.Data, stdout, rowType string, column, n int) { + base := data.Labels().Get(baselineLabel) + before, err := strconv.Atoi(dfRow(t, base, rowType)[column]) + assert.NilError(t, err, base) + after, err := strconv.Atoi(dfRow(t, stdout, rowType)[column]) + assert.NilError(t, err, stdout) + assert.Equal(t, after, before+n, stdout) +} + +// The columns dfGrewBy counts, in the order `system df` prints them. +const ( + totalColumn = iota + activeColumn +) + +// dfReclaimable is the RECLAIMABLE column, which carries a percentage as a second field. +func dfReclaimable(t tig.T, stdout, rowType string) string { + return strings.Join(dfRow(t, stdout, rowType)[3:], " ") +} + +func TestSystemDf(t *testing.T) { + testCase := nerdtest.Setup() + + // The counts are only meaningful when nothing else is running against the same namespace. + testCase.NoParallel = true + + testCase.SubTests = []*test.Case{ + { + Description: "empty namespace", + // Docker has no namespaces, so there is no way to get a guaranteed empty daemon. + Require: require.All(nerdtest.Private, require.Not(nerdtest.Docker)), + Command: test.Command("system", "df"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: func(stdout string, t tig.T) { + assert.Assert(t, strings.Contains(stdout, "TYPE"), stdout) + for _, rowType := range []string{"Images", "Containers", "Local Volumes"} { + assert.Equal(t, dfTotal(t, stdout, rowType), "0", stdout) + assert.Equal(t, dfActive(t, stdout, rowType), "0", stdout) + } + // The build cache is not namespaced, so it is not asserted on here. + assert.Assert(t, strings.Contains(stdout, "Build Cache"), stdout) + }, + } + }, + }, + { + Description: "running container", + Require: nerdtest.Private, + Setup: func(data test.Data, helpers test.Helpers) { + data.Labels().Set(baselineLabel, helpers.Capture("system", "df")) + helpers.Ensure("run", "-d", "--name", data.Identifier(), + testutil.CommonImage, "sleep", nerdtest.Infinity) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: test.Command("system", "df"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: func(stdout string, t tig.T) { + // The container is created by this test, so it is the one the counts went + // up by. + dfGrewBy(t, data, stdout, "Containers", totalColumn, 1) + dfGrewBy(t, data, stdout, "Containers", activeColumn, 1) + + // The image the container runs is in use, so it is active and nothing of + // it can be reclaimed. Neither holds as a difference: the image may well + // have been pulled and in use already, which is what a shared daemon + // cannot be asked about. + if !nerdtest.IsDocker() { + assert.Equal(t, dfActive(t, stdout, "Images"), "1", stdout) + assert.Equal(t, dfReclaimable(t, stdout, "Images"), "0B (0%)", stdout) + } + }, + } + }, + }, + { + Description: "stopped container is reclaimable", + Require: nerdtest.Private, + Setup: func(data test.Data, helpers test.Helpers) { + data.Labels().Set(baselineLabel, helpers.Capture("system", "df")) + helpers.Ensure("run", "-d", "--name", data.Identifier(), + testutil.CommonImage, "sleep", nerdtest.Infinity) + helpers.Ensure("stop", data.Identifier()) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: test.Command("system", "df"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: func(stdout string, t tig.T) { + // A stopped container is still counted, it just is not active any more, + // so its space can be reclaimed. + dfGrewBy(t, data, stdout, "Containers", totalColumn, 1) + dfGrewBy(t, data, stdout, "Containers", activeColumn, 0) + + // The image is no longer held by a running container, but it is still + // referenced by it, so it stays active. + if !nerdtest.IsDocker() { + assert.Equal(t, dfActive(t, stdout, "Images"), "1", stdout) + } + }, + } + }, + }, + { + Description: "unused image is reclaimable", + Require: require.All(nerdtest.Private, require.Not(nerdtest.Docker)), + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("pull", "--quiet", testutil.CommonImage) + }, + Command: test.Command("system", "df"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: func(stdout string, t tig.T) { + assert.Equal(t, dfTotal(t, stdout, "Images"), "1", stdout) + assert.Equal(t, dfActive(t, stdout, "Images"), "0", stdout) + // Nothing else holds the layers, so practically the whole image can be + // reclaimed. It falls just short of the total rather than matching it, + // because the index listing the manifests is on disk, and Docker counts + // it in the total while charging no single image for it. + _, percent, ok := strings.Cut(dfReclaimable(t, stdout, "Images"), " ") + assert.Assert(t, ok, stdout) + value, err := strconv.Atoi(strings.Trim(percent, "(%)")) + assert.NilError(t, err, stdout) + assert.Assert(t, value >= 99, stdout) + }, + } + }, + }, + { + Description: "verbose", + Require: nerdtest.Private, + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("run", "-d", "--name", data.Identifier(), + testutil.CommonImage, "sleep", nerdtest.Infinity) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rm", "-f", data.Identifier()) + }, + Command: test.Command("system", "df", "--verbose"), + Expected: test.Expects(0, nil, expect.All( + expect.Contains("Images space usage:"), + expect.Contains("SHARED SIZE"), + expect.Contains("UNIQUE SIZE"), + expect.Contains("Containers space usage:"), + expect.Contains("LOCAL VOLUMES"), + expect.Contains("Local Volumes space usage:"), + expect.Contains("LINKS"), + expect.Contains("Build cache usage:"), + )), + }, + { + Description: "format json", + Require: nerdtest.Private, + Command: test.Command("system", "df", "--format", "json"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: func(stdout string, t tig.T) { + var types []string + for line := range strings.SplitSeq(strings.TrimSpace(stdout), "\n") { + row := map[string]string{} + assert.NilError(t, json.Unmarshal([]byte(line), &row), line) + types = append(types, row["Type"]) + } + assert.DeepEqual(t, types, + []string{"Images", "Containers", "Local Volumes", "Build Cache"}) + }, + } + }, + }, + { + Description: "format template", + Require: nerdtest.Private, + Command: test.Command("system", "df", "--format", "{{.Type}}"), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: expect.Equals("Images\nContainers\nLocal Volumes\nBuild Cache\n"), + } + }, + }, + { + Description: "format table template", + Require: nerdtest.Private, + Command: test.Command("system", "df", "--format", `table {{.Type}}\t{{.Size}}`), + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: func(stdout string, t tig.T) { + lines := strings.Split(strings.TrimSpace(stdout), "\n") + assert.Equal(t, len(lines), 5, stdout) + // The header names only the requested columns, and the \t the shell passed + // through literally became a real column separator. + assert.Equal(t, strings.Join(strings.Fields(lines[0]), " "), "TYPE SIZE", stdout) + assert.Equal(t, strings.Fields(lines[1])[0], "Images", stdout) + }, + } + }, + }, + } + + testCase.Run(t) +} diff --git a/docs/command-reference.md b/docs/command-reference.md index 20239a0f3b0..27fe91e5e99 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -97,6 +97,7 @@ - [:whale: nerdctl events](#whale-nerdctl-events) - [:whale: nerdctl info](#whale-nerdctl-info) - [:whale: nerdctl version](#whale-nerdctl-version) + - [:whale: nerdctl system df](#whale-nerdctl-system-df) - [:whale: nerdctl system prune](#whale-nerdctl-system-prune) - [Stats](#stats) - [:whale: nerdctl stats](#whale-nerdctl-stats) @@ -1579,6 +1580,38 @@ Flags: - :whale: `-f, --format`: Format the output using the given Go template, e.g, `{{json .}}` +### :whale: nerdctl system df + +Show nerdctl disk usage + +Usage: `nerdctl system df [OPTIONS]` + +Flags: + +- :whale: `-v, --verbose`: Show detailed information on space usage +- :whale: `--format`: Format the output using the given Go template, e.g, `{{json .}}`. + `table` prints the default columns, and `table TEMPLATE` (e.g. `table {{.Type}}\t{{.Size}}`) + prints the columns of the template with a header and aligned columns. + +The images, containers and volumes are reported for the current namespace only. The build cache is +not namespaced by containerd; it is reported for the BuildKit host associated with the namespace, +and shows up as empty when BuildKit is not running. + +The sizes follow Docker v29: the size of an image is the content present in the content store plus +its unpacked snapshots, and the `SIZE` column of the `Images` row counts anything shared between +images only once, so it is smaller than the sum of the individual image sizes. + +Example: + +```console +$ nerdctl system df +TYPE TOTAL ACTIVE SIZE RECLAIMABLE +Images 17 1 18.25GB 17.26GB (94%) +Containers 3 3 169.2MB 0B (0%) +Local Volumes 4 3 798.6GB 22.62MB (0%) +Build Cache 44 0 0B 0B +``` + ### :whale: nerdctl system prune Remove unused data @@ -2040,7 +2073,6 @@ Builder: Others: -- `docker system df` - `docker context` - Swarm commands are unimplemented and will not be implemented: `docker swarm|node|service|config|secret|stack *` - Plugin commands are unimplemented and will not be implemented: `docker plugin *` diff --git a/pkg/api/types/builder_types.go b/pkg/api/types/builder_types.go index 0d9445be505..944c7a5f8a0 100644 --- a/pkg/api/types/builder_types.go +++ b/pkg/api/types/builder_types.go @@ -78,6 +78,17 @@ type BuilderBuildOptions struct { SourcePolicyFile string } +// BuilderDiskUsageOptions specifies options for querying the build cache disk usage. +type BuilderDiskUsageOptions struct { + Stderr io.Writer + // GOptions is the global options + GOptions GlobalCommandOptions + // BuildKitHost is the buildkit host + BuildKitHost string + // Verbose requests the individual build cache records, not just the totals + Verbose bool +} + // BuilderPruneOptions specifies options for `nerdctl builder prune`. type BuilderPruneOptions struct { Stderr io.Writer diff --git a/pkg/api/types/diskusage_types.go b/pkg/api/types/diskusage_types.go new file mode 100644 index 00000000000..26430cdcb2e --- /dev/null +++ b/pkg/api/types/diskusage_types.go @@ -0,0 +1,118 @@ +/* + 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 types + +import "time" + +// DiskUsage is the disk usage of a single containerd namespace, as reported by `nerdctl system df`. +// The build cache is not namespaced by containerd; it is scoped by the BuildKit host instead. +type DiskUsage struct { + Images ImageDiskUsage + Containers ContainerDiskUsage + Volumes VolumeDiskUsage + BuildCache BuildCacheDiskUsage +} + +// ImageDiskUsage is the disk usage of the images of a namespace. +// +// TotalSize is the deduplicated total: every snapshot and every content blob is counted once, even +// when it is shared by several images. It is therefore not the sum of the Size of Items. +type ImageDiskUsage struct { + TotalCount int64 + ActiveCount int64 + TotalSize int64 + Reclaimable int64 + // Items is only populated when the verbose output was requested + Items []ImageDiskUsageItem +} + +// ImageDiskUsageItem is the disk usage of a single image. +type ImageDiskUsageItem struct { + ID string + Repository string + Tag string + CreatedAt time.Time + // Size is the content present in the content store plus the unpacked snapshots + Size int64 + // SharedSize is the part of Size that is also used by at least one other image + SharedSize int64 + // Containers is the number of containers created from this image + Containers int64 +} + +// ContainerDiskUsage is the disk usage of the containers of a namespace. +type ContainerDiskUsage struct { + TotalCount int64 + ActiveCount int64 + TotalSize int64 + Reclaimable int64 + // Items is only populated when the verbose output was requested + Items []ContainerDiskUsageItem +} + +// ContainerDiskUsageItem is the disk usage of a single container. +type ContainerDiskUsageItem struct { + ID string + Image string + Command string + LocalVolumes int64 + // SizeRw is the size of the read-write layer, without the size of the image + SizeRw int64 + CreatedAt time.Time + Status string + Names string +} + +// VolumeDiskUsage is the disk usage of the local volumes of a namespace. +type VolumeDiskUsage struct { + TotalCount int64 + ActiveCount int64 + TotalSize int64 + Reclaimable int64 + // Items is only populated when the verbose output was requested + Items []VolumeDiskUsageItem +} + +// VolumeDiskUsageItem is the disk usage of a single volume. +type VolumeDiskUsageItem struct { + Name string + // Links is the number of containers referencing this volume + Links int64 + Size int64 +} + +// BuildCacheDiskUsage is the disk usage of the BuildKit build cache. +type BuildCacheDiskUsage struct { + TotalCount int64 + ActiveCount int64 + TotalSize int64 + Reclaimable int64 + // Items is only populated when the verbose output was requested + Items []BuildCacheDiskUsageItem +} + +// BuildCacheDiskUsageItem is the disk usage of a single build cache record. +type BuildCacheDiskUsageItem struct { + ID string + CacheType string + Size int64 + CreatedAt time.Time + LastUsedAt *time.Time + UsageCount int + InUse bool + Shared bool +} diff --git a/pkg/api/types/system_types.go b/pkg/api/types/system_types.go index bfadba7a057..4a0aeaa873e 100644 --- a/pkg/api/types/system_types.go +++ b/pkg/api/types/system_types.go @@ -41,6 +41,20 @@ type SystemEventsOptions struct { Filters []string } +// SystemDfOptions specifies options for `nerdctl system df`. +type SystemDfOptions struct { + Stdout io.Writer + Stderr io.Writer + // GOptions is the global options + GOptions GlobalCommandOptions + // Format the output using the given Go template, e.g, '{{json .}} + Format string + // Verbose shows detailed information on space usage + Verbose bool + // BuildKitHost the address of BuildKit host + BuildKitHost string +} + // SystemPruneOptions specifies options for `nerdctl system prune`. type SystemPruneOptions struct { Stdout io.Writer diff --git a/pkg/cmd/builder/df.go b/pkg/cmd/builder/df.go new file mode 100644 index 00000000000..c5de945572b --- /dev/null +++ b/pkg/cmd/builder/df.go @@ -0,0 +1,106 @@ +/* + 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 builder + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/buildkitutil" +) + +// DiskUsage reports how much disk space the BuildKit build cache uses. +// +// A record is active while a build holds it, and a record can be reclaimed when it is neither in use +// nor shared with another BuildKit worker, which is what `nerdctl builder prune` would free. +func DiskUsage(ctx context.Context, options types.BuilderDiskUsageOptions) (types.BuildCacheDiskUsage, error) { + records, err := diskUsageRecords(ctx, options) + if err != nil { + return types.BuildCacheDiskUsage{}, err + } + return aggregateDiskUsage(records, options.Verbose), nil +} + +// aggregateDiskUsage totals the build cache records the way Docker does. +func aggregateDiskUsage(records []buildkitutil.UsageInfo, verbose bool) types.BuildCacheDiskUsage { + du := types.BuildCacheDiskUsage{} + + for _, record := range records { + du.TotalCount++ + du.TotalSize += record.Size + if record.InUse { + du.ActiveCount++ + } + if !record.InUse && !record.Shared { + du.Reclaimable += record.Size + } + + if verbose { + du.Items = append(du.Items, types.BuildCacheDiskUsageItem{ + ID: record.ID, + CacheType: string(record.RecordType), + Size: record.Size, + CreatedAt: record.CreatedAt, + LastUsedAt: record.LastUsedAt, + UsageCount: record.UsageCount, + InUse: record.InUse, + Shared: record.Shared, + }) + } + } + + return du +} + +// diskUsageRecords runs `buildctl du` and decodes its output. Unlike `buildctl prune`, which streams +// one JSON object per pruned record, `buildctl du` applies the template to the whole result at once, +// so the output is a single JSON array. +func diskUsageRecords(ctx context.Context, options types.BuilderDiskUsageOptions) ([]buildkitutil.UsageInfo, error) { + buildctlBinary, err := buildkitutil.BuildctlBinary() + if err != nil { + return nil, err + } + buildctlArgs := buildkitutil.BuildctlBaseArgs(options.BuildKitHost) + buildctlArgs = append(buildctlArgs, "du", "--format={{json .}}") + + buildctlCmd := exec.CommandContext(ctx, buildctlBinary, buildctlArgs...) + log.G(ctx).Debugf("running %v", buildctlCmd.Args) + buildctlCmd.Stderr = options.Stderr + stdout := &bytes.Buffer{} + buildctlCmd.Stdout = stdout + if err := buildctlCmd.Run(); err != nil { + return nil, fmt.Errorf("failed to run %v: %w", buildctlCmd.Args, err) + } + + return parseDiskUsageRecords(stdout.Bytes()) +} + +// parseDiskUsageRecords decodes the JSON array `buildctl du --format={{json .}}` prints. An empty +// build cache is rendered as "null", which decodes into no records at all. +func parseDiskUsageRecords(output []byte) ([]buildkitutil.UsageInfo, error) { + var records []buildkitutil.UsageInfo + if err := json.Unmarshal(bytes.TrimSpace(output), &records); err != nil { + return nil, fmt.Errorf("failed to decode the output of buildctl du: %w", err) + } + return records, nil +} diff --git a/pkg/cmd/builder/df_test.go b/pkg/cmd/builder/df_test.go new file mode 100644 index 00000000000..d788bcf34f2 --- /dev/null +++ b/pkg/cmd/builder/df_test.go @@ -0,0 +1,106 @@ +/* + 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 builder + +import ( + "testing" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/buildkitutil" +) + +// buildctl du renders the whole result with one template pass, so the output is a JSON array, not +// the stream of objects buildctl prune produces. +const buildctlDuOutput = `[{"id":"n3vkjqf4tzxkgxwjdgm0e5vpm","mutable":false,"inUse":true,"size":102400,` + + `"createdAt":"2026-08-01T10:00:00Z","lastUsedAt":"2026-08-04T10:00:00Z","usageCount":2,` + + `"description":"pulled from docker.io/library/alpine:latest","recordType":"regular","shared":false},` + + `{"id":"xk3f4tzqjn0e5vpmgxwjdgm0e","mutable":false,"inUse":false,"size":2048,` + + `"createdAt":"2026-08-02T10:00:00Z","lastUsedAt":null,"usageCount":0,` + + `"description":"local source for context","recordType":"source.local","shared":false},` + + `{"id":"gm0e5vpmxk3f4tzqjn0egxwj","mutable":false,"inUse":false,"size":4096,` + + `"createdAt":"2026-08-03T10:00:00Z","lastUsedAt":null,"usageCount":0,` + + `"description":"shared with another worker","recordType":"regular","shared":true}] +` + +func TestParseDiskUsageRecords(t *testing.T) { + t.Parallel() + + records, err := parseDiskUsageRecords([]byte(buildctlDuOutput)) + assert.NilError(t, err) + assert.Equal(t, len(records), 3) + assert.Equal(t, records[0].ID, "n3vkjqf4tzxkgxwjdgm0e5vpm") + assert.Equal(t, records[0].InUse, true) + assert.Equal(t, records[0].UsageCount, 2) + assert.Assert(t, records[0].LastUsedAt != nil) + assert.Equal(t, string(records[1].RecordType), "source.local") + assert.Assert(t, records[1].LastUsedAt == nil) + assert.Equal(t, records[2].Shared, true) +} + +func TestParseDiskUsageRecordsEmpty(t *testing.T) { + t.Parallel() + + // An empty build cache is rendered by the Go template as "null". + records, err := parseDiskUsageRecords([]byte("null\n")) + assert.NilError(t, err) + assert.Equal(t, len(records), 0) +} + +func TestParseDiskUsageRecordsInvalid(t *testing.T) { + t.Parallel() + + _, err := parseDiskUsageRecords([]byte("not json")) + assert.ErrorContains(t, err, "buildctl du") +} + +func TestBuildCacheDiskUsageAggregation(t *testing.T) { + t.Parallel() + + records, err := parseDiskUsageRecords([]byte(buildctlDuOutput)) + assert.NilError(t, err) + + du := aggregateDiskUsage(records, true) + assert.Equal(t, du.TotalCount, int64(3)) + assert.Equal(t, du.TotalSize, int64(102400+2048+4096)) + // Only the record a build holds is active. + assert.Equal(t, du.ActiveCount, int64(1)) + // Neither the in-use record nor the one shared with another worker can be reclaimed. + assert.Equal(t, du.Reclaimable, int64(2048)) + assert.Equal(t, len(du.Items), 3) + assert.Equal(t, du.Items[0].CacheType, "regular") +} + +func TestBuildCacheDiskUsageWithoutVerbose(t *testing.T) { + t.Parallel() + + records, err := parseDiskUsageRecords([]byte(buildctlDuOutput)) + assert.NilError(t, err) + + du := aggregateDiskUsage(records, false) + assert.Equal(t, du.TotalCount, int64(3)) + // The individual records are only carried when they are going to be printed. + assert.Equal(t, len(du.Items), 0) +} + +func TestBuildCacheDiskUsageNoRecords(t *testing.T) { + t.Parallel() + + du := aggregateDiskUsage([]buildkitutil.UsageInfo{}, true) + assert.DeepEqual(t, du, types.BuildCacheDiskUsage{}) +} diff --git a/pkg/cmd/container/df.go b/pkg/cmd/container/df.go new file mode 100644 index 00000000000..1b098e5a92b --- /dev/null +++ b/pkg/cmd/container/df.go @@ -0,0 +1,158 @@ +/* + 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 container + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/snapshots" + "github.com/containerd/errdefs" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/containerdutil" + "github.com/containerd/nerdctl/v2/pkg/containerutil" + "github.com/containerd/nerdctl/v2/pkg/formatter" + "github.com/containerd/nerdctl/v2/pkg/imgutil" + "github.com/containerd/nerdctl/v2/pkg/inspecttypes/dockercompat" + "github.com/containerd/nerdctl/v2/pkg/labels" + "github.com/containerd/nerdctl/v2/pkg/mountutil" +) + +// DiskUsage reports how much disk space the containers of the current namespace use. +// +// Like Docker, only the read-write layer is counted: the layers coming from the image belong to the +// image, not to the container. Every container is counted, including the stopped ones, and the space +// of everything that is not running can be reclaimed. +func DiskUsage(ctx context.Context, client *containerd.Client, _ types.GlobalCommandOptions, verbose bool) (types.ContainerDiskUsage, error) { + du := types.ContainerDiskUsage{} + + containers, err := client.Containers(ctx) + if err != nil { + return du, err + } + + snapshottersCache := map[string]snapshots.Snapshotter{} + for _, c := range containers { + info, err := c.Info(ctx, containerd.WithoutRefreshedMetadata) + if err != nil { + // There is no guarantee that a container we just listed still exists. + if errdefs.IsNotFound(err) { + log.G(ctx).Debugf("container %q is gone - ignoring", c.ID()) + continue + } + return du, err + } + + snapshotter, ok := snapshottersCache[info.Snapshotter] + if !ok { + snapshotter = containerdutil.SnapshotService(client, info.Snapshotter) + snapshottersCache[info.Snapshotter] = snapshotter + } + + var sizeRw int64 + if info.SnapshotKey != "" { + rw, _, err := imgutil.ResourceUsage(ctx, snapshotter, info.SnapshotKey) + if err != nil { + // ResourceUsage walks the whole snapshot chain, so a NotFound may mean the container + // was removed while we were measuring it, but it may just as well mean a missing + // parent snapshot of a container that is still there. Only the container store can + // tell the two apart, and dropping a live container would understate the report as + // silently as counting it as empty would. + if errdefs.IsNotFound(err) && containerIsGone(ctx, client, c.ID()) { + log.G(ctx).Debugf("container %q is gone - ignoring", c.ID()) + continue + } + return du, fmt.Errorf("failed to get the size of container %q: %w", c.ID(), err) + } + sizeRw = rw.Size + } + + status := formatter.ContainerStatus(ctx, c) + + du.TotalCount++ + du.TotalSize += sizeRw + if isActiveStatus(status) { + du.ActiveCount++ + } else { + du.Reclaimable += sizeRw + } + + if verbose { + item := types.ContainerDiskUsageItem{ + ID: c.ID(), + Image: info.Image, + LocalVolumes: localVolumes(ctx, info.Labels), + SizeRw: sizeRw, + CreatedAt: info.CreatedAt, + Status: status, + Names: containerutil.GetContainerName(info.Labels), + } + if spec, err := c.Spec(ctx); err != nil { + log.G(ctx).WithError(err).Debugf("failed to get the spec of container %q", c.ID()) + } else { + item.Command = formatter.InspectContainerCommand(spec, true, true) + } + du.Items = append(du.Items, item) + } + } + + return du, nil +} + +// containerIsGone reports whether a container no longer exists, asking the container store rather +// than any metadata that was read before. +func containerIsGone(ctx context.Context, client *containerd.Client, id string) bool { + _, err := client.ContainerService().Get(ctx, id) + return errdefs.IsNotFound(err) +} + +// isActiveStatus reports whether a container occupies space that cannot be reclaimed. Docker treats +// the running, paused and restarting containers as active; the status strings are the ones produced +// by formatter.ContainerStatus. +func isActiveStatus(status string) bool { + for _, prefix := range []string{"Up", "Paused", "Pausing", "Restarting"} { + if strings.HasPrefix(status, prefix) { + return true + } + } + return false +} + +// localVolumes returns the number of named and anonymous volumes a container mounts. +func localVolumes(ctx context.Context, containerLabels map[string]string) int64 { + mountsJSON := labels.GetMount(containerLabels) + if mountsJSON == "" { + return 0 + } + var mounts []dockercompat.MountPoint + if err := json.Unmarshal([]byte(mountsJSON), &mounts); err != nil { + log.G(ctx).WithError(err).Debug("failed to parse the mounts of a container") + return 0 + } + var count int64 + for _, m := range mounts { + if m.Type == mountutil.Volume { + count++ + } + } + return count +} diff --git a/pkg/cmd/image/df.go b/pkg/cmd/image/df.go new file mode 100644 index 00000000000..80f97a4934e --- /dev/null +++ b/pkg/cmd/image/df.go @@ -0,0 +1,372 @@ +/* + 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 ( + "context" + "encoding/json" + "maps" + "time" + + "github.com/opencontainers/go-digest" + "github.com/opencontainers/image-spec/identity" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/containerd/v2/core/content" + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/containerd/v2/core/snapshots" + "github.com/containerd/errdefs" + "github.com/containerd/log" + "github.com/containerd/platforms" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/containerdutil" + "github.com/containerd/nerdctl/v2/pkg/imgutil" +) + +// DiskUsage reports how much disk space the images of the current namespace use. +// +// The definitions follow Docker v29 with the containerd image store +// (moby/moby daemon/disk_usage.go and daemon/containerd/service.go): +// +// - the size of an image is the content present in the content store plus its unpacked snapshots, +// - TotalSize counts every snapshot and every blob once, even when several images share it, +// - an image is active when at least one container references it, +// - the reclaimable space is the size that is unique to the images no container references. +func DiskUsage(ctx context.Context, client *containerd.Client, gOptions types.GlobalCommandOptions, verbose bool) (types.ImageDiskUsage, error) { + du := types.ImageDiskUsage{} + + imageList, err := List(ctx, client, nil, nil) + if err != nil { + return du, err + } + + var ( + contentStore = client.ContentStore() + provider = containerdutil.NewProvider(client) + snapshotter = containerdutil.SnapshotService(client, gOptions.Snapshotter) + containers = imagesInUse(ctx, client) + ) + + // The image store may hold several names for the same target (repo:tag, repo@digest, and under + // the k8s.io namespace the config digest as well). Docker reports one entry per target, so + // collapse them here, otherwise every count and every size would be multiplied. + uniqueImages := uniqueByTarget(imageList) + + collected := make([]*imageContents, 0, len(uniqueImages)) + index := newDiskUsageIndex() + + for _, img := range uniqueImages { + contents, err := readImageContents(ctx, contentStore, provider, img) + if err != nil { + log.G(ctx).WithError(err).Warnf("failed to compute the disk usage of image %q", img.Name) + continue + } + collected = append(collected, contents) + index.add(contents, func(chainID digest.Digest) int64 { + return snapshotUsage(ctx, snapshotter, chainID) + }) + } + + du.TotalCount = int64(len(collected)) + du.TotalSize = index.total() + + for _, contents := range collected { + size, sharedSize := index.sizes(contents) + + inUse := containers[contents.image.Target.Digest] + if inUse > 0 { + du.ActiveCount++ + } else { + du.Reclaimable += size - sharedSize + } + + if verbose { + repository, tag := imgutil.ParseRepoTag(contents.image.Name) + du.Items = append(du.Items, types.ImageDiskUsageItem{ + ID: contents.image.Target.Digest.String(), + Repository: repository, + Tag: tag, + CreatedAt: contents.createdAt(), + Size: size, + SharedSize: sharedSize, + Containers: inUse, + }) + } + } + + return du, nil +} + +// imageContents is what a single image occupies on disk: the chain IDs of its unpacked snapshots +// (across every platform) and the blobs of its content that are locally present. +type imageContents struct { + image images.Image + chainIDs []digest.Digest + // blobs is the content of the manifests of the image. Docker sizes an image by walking its + // manifests, so the index listing them is not part of what a single image is charged for. + blobs map[digest.Digest]int64 + // indexBlobs is the content of those indexes. It is on disk, so the total counts it, but no + // image is charged for it. + indexBlobs map[digest.Digest]int64 + // created is when the image was built, as stated by its config. It is nil when no config says. + created *time.Time +} + +// createdAt is when the image was built. Docker reports the "created" of the image config; the +// creation time of the local image record only says when it was pulled or tagged, which would show +// an old image as brand new. It is the fallback for the images that do not state one. +func (contents *imageContents) createdAt() time.Time { + if contents.created != nil { + return *contents.created + } + return contents.image.CreatedAt +} + +// diskUsageIndex records, for every snapshot and every blob, how many images hold it and how large +// it is. That is what makes the deduplicated total and the per-image shared size computable without +// a second pass over the content store. +type diskUsageIndex struct { + layerCount map[digest.Digest]int + blobCount map[digest.Digest]int + layerSize map[digest.Digest]int64 + blobSize map[digest.Digest]int64 + // indexSize is the content no single image is charged for. It is deduplicated by digest like + // the rest, it just never contributes to a per-image size, so it needs no count. + indexSize map[digest.Digest]int64 +} + +func newDiskUsageIndex() *diskUsageIndex { + return &diskUsageIndex{ + layerCount: map[digest.Digest]int{}, + blobCount: map[digest.Digest]int{}, + layerSize: map[digest.Digest]int64{}, + blobSize: map[digest.Digest]int64{}, + indexSize: map[digest.Digest]int64{}, + } +} + +// add accounts for one image. usage is only called the first time a snapshot is seen, so a snapshot +// shared by many images is measured once. +func (index *diskUsageIndex) add(contents *imageContents, usage func(digest.Digest) int64) { + for _, chainID := range contents.chainIDs { + index.layerCount[chainID]++ + if _, ok := index.layerSize[chainID]; !ok { + index.layerSize[chainID] = usage(chainID) + } + } + for dgst, size := range contents.blobs { + index.blobCount[dgst]++ + index.blobSize[dgst] = size + } + maps.Copy(index.indexSize, contents.indexBlobs) +} + +// total is the disk space the images take together, counting everything they share only once. +func (index *diskUsageIndex) total() int64 { + var total int64 + for chainID := range index.layerCount { + total += index.layerSize[chainID] + } + for dgst := range index.blobCount { + total += index.blobSize[dgst] + } + for _, size := range index.indexSize { + total += size + } + return total +} + +// sizes returns what one image occupies, and how much of that is also held by another image. +func (index *diskUsageIndex) sizes(contents *imageContents) (size, sharedSize int64) { + for _, chainID := range contents.chainIDs { + size += index.layerSize[chainID] + if index.layerCount[chainID] > 1 { + sharedSize += index.layerSize[chainID] + } + } + for dgst, blob := range contents.blobs { + size += blob + if index.blobCount[dgst] > 1 { + sharedSize += blob + } + } + return size, sharedSize +} + +// readImageContents walks everything reachable from the image target that is present in the content +// store, collecting the blobs on the way and deriving the chain IDs from the image configs. +func readImageContents(ctx context.Context, store content.Store, provider content.Provider, img images.Image) (*imageContents, error) { + contents := &imageContents{ + image: img, + blobs: map[digest.Digest]int64{}, + indexBlobs: map[digest.Digest]int64{}, + } + + var manifestDescs []ocispec.Descriptor + if err := containerdutil.WalkPresentChildren(ctx, store, img.Target, func(_ context.Context, desc ocispec.Descriptor) error { + if images.IsIndexType(desc.MediaType) { + contents.indexBlobs[desc.Digest] = desc.Size + return nil + } + contents.blobs[desc.Digest] = desc.Size + if images.IsManifestType(desc.MediaType) { + manifestDescs = append(manifestDescs, desc) + } + return nil + }); err != nil { + return nil, err + } + + seen := map[digest.Digest]struct{}{} + var built []buildTime + for _, desc := range manifestDescs { + config, err := readConfig(ctx, provider, desc) + if err != nil { + // Attestation manifests and manifests whose config we cannot read carry no rootfs. + // Their content is still accounted for above, they just contribute no snapshot. + log.G(ctx).WithError(err).Debugf("no rootfs for manifest %q of image %q", desc.Digest, img.Name) + continue + } + if !isAttestationManifestDescriptor(desc) { + built = append(built, buildTime{ + platform: manifestPlatform(desc, config), + created: config.Created, + }) + } + for _, chainID := range identity.ChainIDs(config.RootFS.DiffIDs) { + if _, ok := seen[chainID]; ok { + continue + } + seen[chainID] = struct{}{} + contents.chainIDs = append(contents.chainIDs, chainID) + } + } + contents.created = hostBuildTime(built) + + return contents, nil +} + +// buildTime is when one platform of an image was built. created is nil when the config of that +// platform states no build time, which it is free not to. +type buildTime struct { + platform ocispec.Platform + created *time.Time +} + +// hostBuildTime picks the build time to report for a multi-platform image. The platforms of an +// index are not necessarily built together, so report the one this host would run, as Docker does +// by reading the config of the manifest its platform matcher selects. The platform decides which +// config answers, so a host manifest saying nothing is an answer too: it leaves the caller with the +// creation time of the local record rather than with the build time of another architecture. +func hostBuildTime(built []buildTime) *time.Time { + matcher := platforms.Default() + best := -1 + for i, candidate := range built { + if !matcher.Match(candidate.platform) { + continue + } + if best == -1 || matcher.Less(candidate.platform, built[best].platform) { + best = i + } + } + if best >= 0 { + return built[best].created + } + + // No platform of the image runs here (an image pulled for another architecture, say). Any + // build time describes the image better than none. + for _, candidate := range built { + if candidate.created != nil { + return candidate.created + } + } + return nil +} + +// manifestPlatform reports the platform of a manifest. The descriptor is authoritative: it is what +// an index selects a platform by, and it can be more specific than the config, which may declare a +// bare "linux/arm" for what the index calls linux/arm/v6 and linux/arm/v7. +func manifestPlatform(desc ocispec.Descriptor, config *ocispec.Image) ocispec.Platform { + if desc.Platform != nil { + return platforms.Normalize(*desc.Platform) + } + return platforms.Normalize(ocispec.Platform{ + OS: config.OS, + Architecture: config.Architecture, + Variant: config.Variant, + }) +} + +// readConfig returns the image config referenced by the given manifest. +func readConfig(ctx context.Context, provider content.Provider, desc ocispec.Descriptor) (*ocispec.Image, error) { + manifestData, err := containerdutil.ReadBlob(ctx, provider, desc) + if err != nil { + return nil, err + } + var manifest ocispec.Manifest + if err := json.Unmarshal(manifestData, &manifest); err != nil { + return nil, err + } + + configData, err := containerdutil.ReadBlob(ctx, provider, manifest.Config) + if err != nil { + return nil, err + } + var config ocispec.Image + if err := json.Unmarshal(configData, &config); err != nil { + return nil, err + } + + return &config, nil +} + +// snapshotUsage returns the size of a single snapshot, or 0 when the image is not unpacked. +func snapshotUsage(ctx context.Context, snapshotter snapshots.Snapshotter, chainID digest.Digest) int64 { + usage, err := snapshotter.Usage(ctx, chainID.String()) + if err != nil { + if !errdefs.IsNotFound(err) { + log.G(ctx).WithError(err).Debugf("failed to get the usage of snapshot %q", chainID) + } + return 0 + } + return usage.Size +} + +// uniqueByTarget collapses the names pointing at the same target into a single image, preferring a +// tagged name so that the verbose output shows something more useful than " ". +func uniqueByTarget(imageList []images.Image) []images.Image { + var ( + unique = make([]images.Image, 0, len(imageList)) + index = map[digest.Digest]int{} + ) + for _, img := range imageList { + i, ok := index[img.Target.Digest] + if !ok { + index[img.Target.Digest] = len(unique) + unique = append(unique, img) + continue + } + if _, tag := imgutil.ParseRepoTag(unique[i].Name); tag == "" { + if _, tag := imgutil.ParseRepoTag(img.Name); tag != "" { + unique[i] = img + } + } + } + return unique +} diff --git a/pkg/cmd/image/df_test.go b/pkg/cmd/image/df_test.go new file mode 100644 index 00000000000..72d66714313 --- /dev/null +++ b/pkg/cmd/image/df_test.go @@ -0,0 +1,316 @@ +/* + 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 ( + "slices" + "testing" + "time" + + "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" +) + +func testDigest(name string) digest.Digest { + return digest.FromString(name) +} + +// newTestContents builds an image whose snapshots are named by chainIDs and whose blobs are the +// given name/size pairs. +func newTestContents(name string, chainIDs []string, blobs map[string]int64) *imageContents { + contents := &imageContents{ + image: images.Image{ + Name: name, + Target: ocispec.Descriptor{Digest: testDigest(name)}, + }, + blobs: map[digest.Digest]int64{}, + } + for _, chainID := range chainIDs { + contents.chainIDs = append(contents.chainIDs, testDigest(chainID)) + } + for blob, size := range blobs { + contents.blobs[testDigest(blob)] = size + } + return contents +} + +// snapshotSizes turns a name-keyed table into the usage callback diskUsageIndex.add expects. +func snapshotSizes(sizes map[string]int64) func(digest.Digest) int64 { + byDigest := map[digest.Digest]int64{} + for name, size := range sizes { + byDigest[testDigest(name)] = size + } + return func(chainID digest.Digest) int64 { + return byDigest[chainID] + } +} + +func TestDiskUsageIndexSingleImage(t *testing.T) { + t.Parallel() + + usage := snapshotSizes(map[string]int64{"layer-a": 100, "layer-b": 200}) + contents := newTestContents("solo", []string{"layer-a", "layer-b"}, map[string]int64{ + "manifest": 5, + "config": 10, + }) + + index := newDiskUsageIndex() + index.add(contents, usage) + + size, sharedSize := index.sizes(contents) + assert.Equal(t, size, int64(315)) + // Nothing is shared when there is only one image. + assert.Equal(t, sharedSize, int64(0)) + assert.Equal(t, index.total(), int64(315)) +} + +func TestDiskUsageIndexChargesNoImageForTheIndex(t *testing.T) { + t.Parallel() + + usage := snapshotSizes(map[string]int64{"layer-a": 100}) + contents := newTestContents("multi", []string{"layer-a"}, map[string]int64{ + "manifest": 5, + "config": 10, + }) + contents.indexBlobs = map[digest.Digest]int64{testDigest("index"): 2} + + index := newDiskUsageIndex() + index.add(contents, usage) + + // The index listing the manifests is on disk, so the total counts it, but Docker sizes an + // image by walking its manifests and never charges it for the index that lists them. + size, sharedSize := index.sizes(contents) + assert.Equal(t, size, int64(115)) + assert.Equal(t, sharedSize, int64(0)) + assert.Equal(t, index.total(), int64(117)) +} + +func TestDiskUsageIndexSharedLayers(t *testing.T) { + t.Parallel() + + usage := snapshotSizes(map[string]int64{"base": 1000, "top-a": 30, "top-b": 40}) + // Two images built on the same base layer, sharing the base blob too. + first := newTestContents("first", []string{"base", "top-a"}, map[string]int64{ + "base-blob": 500, + "config-a": 7, + "manifest-a": 3, + }) + second := newTestContents("second", []string{"base", "top-b"}, map[string]int64{ + "base-blob": 500, + "config-b": 9, + "manifest-b": 4, + }) + + index := newDiskUsageIndex() + index.add(first, usage) + index.add(second, usage) + + firstSize, firstShared := index.sizes(first) + assert.Equal(t, firstSize, int64(1000+30+500+7+3)) + assert.Equal(t, firstShared, int64(1000+500)) + + secondSize, secondShared := index.sizes(second) + assert.Equal(t, secondSize, int64(1000+40+500+9+4)) + assert.Equal(t, secondShared, int64(1000+500)) + + // The shared base layer and the shared blob are counted once in the total. + assert.Equal(t, index.total(), int64(1000+30+40+500+7+3+9+4)) + // The total is what is really on disk, so it is less than the sum of the image sizes. + assert.Assert(t, index.total() < firstSize+secondSize) + + // Only the unique part of an unused image can be reclaimed. + assert.Equal(t, firstSize-firstShared, int64(30+7+3)) +} + +func TestDiskUsageIndexNotUnpacked(t *testing.T) { + t.Parallel() + + // An image that was pulled but never unpacked has no snapshots, so only its content counts. + usage := snapshotSizes(nil) + contents := newTestContents("packed", []string{"layer-a"}, map[string]int64{"config": 12}) + + index := newDiskUsageIndex() + index.add(contents, usage) + + size, sharedSize := index.sizes(contents) + assert.Equal(t, size, int64(12)) + assert.Equal(t, sharedSize, int64(0)) + assert.Equal(t, index.total(), int64(12)) +} + +func TestDiskUsageIndexMeasuresSnapshotsOnce(t *testing.T) { + t.Parallel() + + // A snapshot shared by several images must not be measured again for each of them: on a real + // snapshotter that lookup is a disk walk. + var calls int + usage := func(digest.Digest) int64 { + calls++ + return 100 + } + + index := newDiskUsageIndex() + index.add(newTestContents("first", []string{"base"}, nil), usage) + index.add(newTestContents("second", []string{"base"}, nil), usage) + + assert.Equal(t, calls, 1) + assert.Equal(t, index.total(), int64(100)) +} + +func TestImageContentsCreatedAt(t *testing.T) { + t.Parallel() + + built := time.Date(2020, 3, 1, 12, 0, 0, 0, time.UTC) + pulled := time.Date(2026, 8, 5, 12, 0, 0, 0, time.UTC) + + contents := newTestContents("dated", nil, nil) + contents.image.CreatedAt = pulled + + // Without a config saying otherwise, all we know is when the record appeared locally. + assert.Equal(t, contents.createdAt(), pulled) + + // The config is authoritative: pulling an old image must not make it look brand new. + contents.created = &built + assert.Equal(t, contents.createdAt(), built) +} + +// foreignPlatform returns a platform this host does not run. nerdctl is released for linux/s390x +// among others, so no architecture can be hardcoded as the foreign one: a host matches at most one +// of the two candidates below, and a host running neither Linux nor a Linux runtime matches none. +func foreignPlatform(t *testing.T) ocispec.Platform { + t.Helper() + + matcher := platforms.Default() + for _, candidate := range []ocispec.Platform{ + {OS: "linux", Architecture: "amd64"}, + {OS: "linux", Architecture: "arm64"}, + } { + if !matcher.Match(candidate) { + return candidate + } + } + t.Fatalf("no foreign platform for %q", platforms.Format(platforms.DefaultSpec())) + return ocispec.Platform{} +} + +func TestHostBuildTime(t *testing.T) { + t.Parallel() + + var ( + host = platforms.DefaultSpec() + foreign = foreignPlatform(t) + older = time.Date(2020, 3, 1, 0, 0, 0, 0, time.UTC) + newer = time.Date(2024, 9, 1, 0, 0, 0, 0, time.UTC) + ) + + t.Run("no manifest at all", func(t *testing.T) { + t.Parallel() + assert.Assert(t, hostBuildTime(nil) == nil) + }) + + t.Run("the platform of the host wins", func(t *testing.T) { + t.Parallel() + // The platforms of an index are not necessarily built together, and the order of the + // descriptors says nothing, so the host platform must be picked whatever its position. + built := []buildTime{ + {platform: foreign, created: &older}, + {platform: host, created: &newer}, + } + assert.Equal(t, *hostBuildTime(built), newer) + + slices.Reverse(built) + assert.Equal(t, *hostBuildTime(built), newer) + }) + + t.Run("the platform of the host states no build time", func(t *testing.T) { + t.Parallel() + // The build time is optional. Once the host platform has answered, the answer stands: + // reporting the build time of another architecture would be worse than reporting none. + built := []buildTime{ + {platform: foreign, created: &older}, + {platform: host}, + } + assert.Assert(t, hostBuildTime(built) == nil) + }) + + t.Run("no platform runs here", func(t *testing.T) { + t.Parallel() + // An image pulled for another architecture still describes itself better with a build time + // than with none, wherever among its platforms that time is stated. + built := []buildTime{ + {platform: foreign}, + {platform: foreign, created: &older}, + } + assert.Equal(t, *hostBuildTime(built), older) + + assert.Assert(t, hostBuildTime([]buildTime{{platform: foreign}}) == nil) + }) +} + +func TestManifestPlatform(t *testing.T) { + t.Parallel() + + // alpine ships linux/arm/v6 and linux/arm/v7 manifests whose configs both declare a bare + // "linux/arm", so the descriptor of the index is the one to believe. + desc := ocispec.Descriptor{Platform: &ocispec.Platform{OS: "linux", Architecture: "arm", Variant: "v6"}} + config := &ocispec.Image{Platform: ocispec.Platform{OS: "linux", Architecture: "arm"}} + assert.Equal(t, platforms.Format(manifestPlatform(desc, config)), "linux/arm/v6") + + // A single-platform image has no index to declare a platform, so the config answers. + assert.Equal(t, platforms.Format(manifestPlatform(ocispec.Descriptor{}, &ocispec.Image{ + Platform: ocispec.Platform{OS: "linux", Architecture: "amd64"}, + })), "linux/amd64") +} + +func TestUniqueByTarget(t *testing.T) { + t.Parallel() + + shared := ocispec.Descriptor{Digest: testDigest("shared")} + other := ocispec.Descriptor{Digest: testDigest("other")} + + imageList := []images.Image{ + // The same target under a digest reference, a tag, and a bare config digest, as the k8s.io + // namespace ends up storing it. + {Name: "example.com/foo@" + shared.Digest.String(), Target: shared}, + {Name: "example.com/foo:latest", Target: shared}, + {Name: shared.Digest.String(), Target: shared}, + {Name: "example.com/bar:v1", Target: other}, + } + + unique := uniqueByTarget(imageList) + assert.Equal(t, len(unique), 2) + // A tagged name is preferred, so the verbose output is not needlessly " ". + assert.Equal(t, unique[0].Name, "example.com/foo:latest") + assert.Equal(t, unique[1].Name, "example.com/bar:v1") +} + +func TestUniqueByTargetKeepsUntagged(t *testing.T) { + t.Parallel() + + dangling := ocispec.Descriptor{Digest: testDigest("dangling")} + imageList := []images.Image{ + {Name: "example.com/foo@" + dangling.Digest.String(), Target: dangling}, + } + + unique := uniqueByTarget(imageList) + assert.Equal(t, len(unique), 1) + assert.Equal(t, unique[0].Name, imageList[0].Name) +} diff --git a/pkg/cmd/image/list.go b/pkg/cmd/image/list.go index 703baa6679a..d9d0adfe1ac 100644 --- a/pkg/cmd/image/list.go +++ b/pkg/cmd/image/list.go @@ -220,7 +220,7 @@ 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 inUse map[digest.Digest]int64 if newView { inUse = imagesInUse(ctx, client) sortByImageRef(finalImageList) @@ -254,7 +254,7 @@ 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 + inUse map[digest.Digest]int64 // image target -> number of containers referencing it tmpl *template.Template client *containerd.Client provider content.Provider @@ -476,7 +476,7 @@ func (x *imagePrinter) printImageCollapsed(img images.Image, candidateImages map } extra := "" - if x.inUse[img.Target.Digest] { + if x.inUse[img.Target.Digest] > 0 { extra = "U" } @@ -564,12 +564,12 @@ 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 { - inUse := map[digest.Digest]bool{} +// imagesInUse returns, per image target digest, the number of containers (in any state) referencing +// it. It is used to render the Docker v29 "In Use" (U) indicator and the CONTAINERS column of +// `nerdctl system df --verbose`. Docker matches containers to images by digest, so every name +// pointing at the same target is counted, not just the one the container was created from. +func imagesInUse(ctx context.Context, client *containerd.Client) map[digest.Digest]int64 { + inUse := map[digest.Digest]int64{} containerList, err := client.Containers(ctx) if err != nil { log.G(ctx).WithError(err).Warn("failed to list containers for image in-use detection") @@ -577,7 +577,7 @@ func imagesInUse(ctx context.Context, client *containerd.Client) map[digest.Dige } for _, container := range containerList { if dgst, ok := containerImageDigest(ctx, container); ok { - inUse[dgst] = true + inUse[dgst]++ } } return inUse diff --git a/pkg/cmd/system/df.go b/pkg/cmd/system/df.go new file mode 100644 index 00000000000..35c302b7bb8 --- /dev/null +++ b/pkg/cmd/system/df.go @@ -0,0 +1,413 @@ +/* + 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 system + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "strconv" + "strings" + "text/tabwriter" + "text/template" + + "github.com/docker/go-units" + + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/log" + + "github.com/containerd/nerdctl/v2/pkg/api/types" + "github.com/containerd/nerdctl/v2/pkg/cmd/builder" + "github.com/containerd/nerdctl/v2/pkg/cmd/container" + "github.com/containerd/nerdctl/v2/pkg/cmd/image" + "github.com/containerd/nerdctl/v2/pkg/cmd/volume" + "github.com/containerd/nerdctl/v2/pkg/formatter" + "github.com/containerd/nerdctl/v2/pkg/idgen" +) + +// Df shows how much disk space containerd uses for the images, containers and volumes of the current +// namespace, plus the BuildKit build cache. +func Df(ctx context.Context, client *containerd.Client, options types.SystemDfOptions) error { + du, err := DiskUsage(ctx, client, options) + if err != nil { + return err + } + return printDiskUsage(du, options) +} + +// DiskUsage collects the disk usage of every kind of resource nerdctl manages. +func DiskUsage(ctx context.Context, client *containerd.Client, options types.SystemDfOptions) (types.DiskUsage, error) { + du := types.DiskUsage{} + + var err error + if du.Images, err = image.DiskUsage(ctx, client, options.GOptions, options.Verbose); err != nil { + return du, err + } + if du.Containers, err = container.DiskUsage(ctx, client, options.GOptions, options.Verbose); err != nil { + return du, err + } + if du.Volumes, err = volume.DiskUsage(ctx, client, options.GOptions, options.Verbose); err != nil { + return du, err + } + + // BuildKit is optional. When it is not reachable, the build cache is reported as empty rather + // than omitted, so that the shape of the output does not depend on the daemons that happen to + // be running. + if options.BuildKitHost != "" { + du.BuildCache, err = builder.DiskUsage(ctx, types.BuilderDiskUsageOptions{ + Stderr: options.Stderr, + GOptions: options.GOptions, + BuildKitHost: options.BuildKitHost, + Verbose: options.Verbose, + }) + if err != nil { + log.G(ctx).WithError(err).Warn("failed to get the build cache disk usage") + du.BuildCache = types.BuildCacheDiskUsage{} + } + } + + return du, nil +} + +// dfPrintable is a row of the summary table. +type dfPrintable struct { + Type string + TotalCount string + Active string + Size string + Reclaimable string +} + +// dfVerbosePrintable is what a `--format` template gets in verbose mode, mirroring Docker. +type dfVerbosePrintable struct { + Images []dfImagePrintable + Containers []dfContainerPrintable + Volumes []dfVolumePrintable + BuildCache []dfBuildCachePrintable +} + +type dfImagePrintable struct { + Repository string + Tag string + ID string + CreatedSince string + Size string + SharedSize string + UniqueSize string + Containers string +} + +type dfContainerPrintable struct { + ID string + Image string + Command string + LocalVolumes string + Size string + RunningFor string + Status string + Names string +} + +type dfVolumePrintable struct { + Name string + Links string + Size string +} + +type dfBuildCachePrintable struct { + ID string + CacheType string + Size string + CreatedSince string + LastUsedSince string + UsageCount string + InUse string + Shared string +} + +// dfFormat is how the output was asked to be rendered. +type dfFormat struct { + // tmpl is the template of a `--format`, or nil for the default columns. + tmpl *template.Template + // header renders the column labels of tmpl, leaving them intact. + header *template.Template + // table tells whether the output is a table: its columns are aligned under a header, and its + // identifiers are shortened because it is meant to be read rather than parsed. + table bool +} + +func printDiskUsage(du types.DiskUsage, options types.SystemDfOptions) error { + var ( + format dfFormat + err error + ) + switch { + case options.Format == "", options.Format == "table": + // The default columns, rendered below. + format.table = true + case options.Format == "raw": + return errors.New("unsupported format: \"raw\"") + case formatter.IsTableFormat(options.Format): + // `table {{.Type}}\t{{.Size}}` picks the columns but keeps the header and the alignment. + format.table = true + format.tmpl, format.header, err = formatter.ParseTableTemplate(options.Format) + default: + format.tmpl, err = formatter.ParseTemplate(options.Format) + } + if err != nil { + return err + } + + if options.Verbose { + return printVerbose(du, options.Stdout, format) + } + return printSummary(du, options.Stdout, format) +} + +// dfHeader labels the columns of the summary. A table format renders its header by running the very +// same template over it, so that the header always describes the columns that were asked for. +var dfHeader = dfPrintable{ + Type: "TYPE", + TotalCount: "TOTAL", + Active: "ACTIVE", + Size: "SIZE", + Reclaimable: "RECLAIMABLE", +} + +func printSummary(du types.DiskUsage, stdout io.Writer, format dfFormat) error { + rows := []dfPrintable{ + { + Type: "Images", + TotalCount: strconv.FormatInt(du.Images.TotalCount, 10), + Active: strconv.FormatInt(du.Images.ActiveCount, 10), + Size: humanSize(du.Images.TotalSize), + Reclaimable: humanReclaimable(du.Images.Reclaimable, du.Images.TotalSize), + }, + { + Type: "Containers", + TotalCount: strconv.FormatInt(du.Containers.TotalCount, 10), + Active: strconv.FormatInt(du.Containers.ActiveCount, 10), + Size: humanSize(du.Containers.TotalSize), + Reclaimable: humanReclaimable(du.Containers.Reclaimable, du.Containers.TotalSize), + }, + { + Type: "Local Volumes", + TotalCount: strconv.FormatInt(du.Volumes.TotalCount, 10), + Active: strconv.FormatInt(du.Volumes.ActiveCount, 10), + Size: humanSize(du.Volumes.TotalSize), + Reclaimable: humanReclaimable(du.Volumes.Reclaimable, du.Volumes.TotalSize), + }, + { + Type: "Build Cache", + TotalCount: strconv.FormatInt(du.BuildCache.TotalCount, 10), + Active: strconv.FormatInt(du.BuildCache.ActiveCount, 10), + Size: humanSize(du.BuildCache.TotalSize), + // Unlike the other kinds, Docker never shows a percentage for the build cache. + Reclaimable: humanSize(du.BuildCache.Reclaimable), + }, + } + + if format.tmpl != nil && !format.table { + for _, row := range rows { + if err := executeTemplate(stdout, format.tmpl, row); err != nil { + return err + } + } + return nil + } + + w := newTabWriter(stdout) + if format.tmpl != nil { + if err := executeTemplate(w, format.header, dfHeader); err != nil { + return err + } + for _, row := range rows { + if err := executeTemplate(w, format.tmpl, row); err != nil { + return err + } + } + return w.Flush() + } + + fmt.Fprintln(w, "TYPE\tTOTAL\tACTIVE\tSIZE\tRECLAIMABLE") + for _, row := range rows { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", row.Type, row.TotalCount, row.Active, row.Size, row.Reclaimable) + } + return w.Flush() +} + +func printVerbose(du types.DiskUsage, stdout io.Writer, format dfFormat) error { + verbose := dfVerbosePrintable{} + + // Docker only shortens the identifiers for its table output (see Format.IsTable in + // docker/cli), so that a custom format stays usable to look a resource up. A table format is + // still a table, whatever columns it asks for. + trunc := format.table + + for _, item := range du.Images.Items { + repository, tag := item.Repository, item.Tag + if repository == "" { + repository = "" + } + if tag == "" { + tag = "" + } + verbose.Images = append(verbose.Images, dfImagePrintable{ + Repository: repository, + Tag: tag, + ID: displayID(item.ID, trunc), + CreatedSince: formatter.TimeSinceInHuman(item.CreatedAt), + Size: humanSize(item.Size), + SharedSize: humanSize(item.SharedSize), + UniqueSize: humanSize(item.Size - item.SharedSize), + Containers: strconv.FormatInt(item.Containers, 10), + }) + } + + for _, item := range du.Containers.Items { + verbose.Containers = append(verbose.Containers, dfContainerPrintable{ + ID: displayID(item.ID, trunc), + Image: item.Image, + Command: item.Command, + LocalVolumes: strconv.FormatInt(item.LocalVolumes, 10), + Size: humanSize(item.SizeRw), + RunningFor: formatter.TimeSinceInHuman(item.CreatedAt), + Status: item.Status, + Names: item.Names, + }) + } + + for _, item := range du.Volumes.Items { + verbose.Volumes = append(verbose.Volumes, dfVolumePrintable{ + Name: item.Name, + Links: strconv.FormatInt(item.Links, 10), + Size: humanSize(item.Size), + }) + } + + for _, item := range du.BuildCache.Items { + lastUsedSince := "" + if item.LastUsedAt != nil { + lastUsedSince = formatter.TimeSinceInHuman(*item.LastUsedAt) + } + // Docker has no column for it, it marks the ID of a record in use with a star instead. + id := displayID(item.ID, trunc) + if item.InUse { + id += "*" + } + verbose.BuildCache = append(verbose.BuildCache, dfBuildCachePrintable{ + ID: id, + CacheType: item.CacheType, + Size: humanSize(item.Size), + CreatedSince: formatter.TimeSinceInHuman(item.CreatedAt), + LastUsedSince: lastUsedSince, + UsageCount: strconv.Itoa(item.UsageCount), + InUse: strconv.FormatBool(item.InUse), + Shared: strconv.FormatBool(item.Shared), + }) + } + + if format.tmpl != nil { + return executeTemplate(stdout, format.tmpl, verbose) + } + + fmt.Fprint(stdout, "Images space usage:\n\n") + w := newTabWriter(stdout) + fmt.Fprintln(w, "REPOSITORY\tTAG\tIMAGE ID\tCREATED\tSIZE\tSHARED SIZE\tUNIQUE SIZE\tCONTAINERS") + for _, p := range verbose.Images { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + p.Repository, p.Tag, p.ID, p.CreatedSince, p.Size, p.SharedSize, p.UniqueSize, p.Containers) + } + if err := w.Flush(); err != nil { + return err + } + + fmt.Fprint(stdout, "\nContainers space usage:\n\n") + w = newTabWriter(stdout) + fmt.Fprintln(w, "CONTAINER ID\tIMAGE\tCOMMAND\tLOCAL VOLUMES\tSIZE\tCREATED\tSTATUS\tNAMES") + for _, p := range verbose.Containers { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + p.ID, p.Image, p.Command, p.LocalVolumes, p.Size, p.RunningFor, p.Status, p.Names) + } + if err := w.Flush(); err != nil { + return err + } + + fmt.Fprint(stdout, "\nLocal Volumes space usage:\n\n") + w = newTabWriter(stdout) + fmt.Fprintln(w, "VOLUME NAME\tLINKS\tSIZE") + for _, p := range verbose.Volumes { + fmt.Fprintf(w, "%s\t%s\t%s\n", p.Name, p.Links, p.Size) + } + if err := w.Flush(); err != nil { + return err + } + + fmt.Fprintf(stdout, "\nBuild cache usage: %s\n\n", humanSize(du.BuildCache.TotalSize)) + w = newTabWriter(stdout) + fmt.Fprintln(w, "CACHE ID\tCACHE TYPE\tSIZE\tCREATED\tLAST USED\tUSAGE\tSHARED") + for _, p := range verbose.BuildCache { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + p.ID, p.CacheType, p.Size, p.CreatedSince, p.LastUsedSince, p.UsageCount, p.Shared) + } + return w.Flush() +} + +func newTabWriter(w io.Writer) *tabwriter.Writer { + return tabwriter.NewWriter(w, 4, 8, 4, ' ', 0) +} + +func executeTemplate(w io.Writer, tmpl *template.Template, data any) error { + var b bytes.Buffer + if err := tmpl.Execute(&b, data); err != nil { + return err + } + _, err := fmt.Fprintln(w, b.String()) + return err +} + +func humanSize(size int64) string { + return units.HumanSize(float64(size)) +} + +// humanReclaimable renders the reclaimable space the way Docker does: as a share of the total, when +// there is a total to compare it against. +func humanReclaimable(reclaimable, totalSize int64) string { + if totalSize > 0 { + return fmt.Sprintf("%s (%v%%)", humanSize(reclaimable), (reclaimable*100)/totalSize) + } + return humanSize(reclaimable) +} + +// displayID shortens an identifier for the table output only. A custom format is meant to be +// consumed by something else, and the full identifier is what makes the resource addressable. +func displayID(id string, trunc bool) string { + if !trunc { + return id + } + return truncateID(id) +} + +// truncateID shortens an identifier for display, dropping the digest algorithm when there is one. +func truncateID(id string) string { + if _, hex, ok := strings.Cut(id, ":"); ok { + id = hex + } + return idgen.TruncateID(id) +} diff --git a/pkg/cmd/system/df_test.go b/pkg/cmd/system/df_test.go new file mode 100644 index 00000000000..0edf263cbf2 --- /dev/null +++ b/pkg/cmd/system/df_test.go @@ -0,0 +1,435 @@ +/* + 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 system + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + "time" + + "gotest.tools/v3/assert" + + "github.com/containerd/nerdctl/v2/pkg/api/types" +) + +func TestHumanReclaimable(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + reclaimable int64 + totalSize int64 + expected string + }{ + { + name: "share of the total", + reclaimable: 940, + totalSize: 1000, + expected: "940B (94%)", + }, + { + name: "nothing reclaimable", + reclaimable: 0, + totalSize: 1000, + expected: "0B (0%)", + }, + { + name: "no total to compare against", + reclaimable: 0, + totalSize: 0, + expected: "0B", + }, + { + name: "everything reclaimable", + reclaimable: 2000, + totalSize: 2000, + expected: "2kB (100%)", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, humanReclaimable(tc.reclaimable, tc.totalSize), tc.expected) + }) + } +} + +func TestTruncateID(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + id string + expected string + }{ + { + name: "digest", + id: "sha256:09538a1f51d3ec5af0449a1640937dfdf79b0e9b8c4da5b8a883086d5c1492ef", + expected: "09538a1f51d3", + }, + { + name: "opaque buildkit id", + id: "n3vkjqf4tzxkgxwjdgm0e5vpm", + expected: "n3vkjqf4tzxk", + }, + { + name: "shorter than the short id length", + id: "abc", + expected: "abc", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, truncateID(tc.id), tc.expected) + }) + } +} + +// fieldsOfRowContaining returns the single-space-joined fields of the first line of out holding +// needle, so that assertions do not depend on how the tabwriter pads the columns. +func fieldsOfRowContaining(out, needle string) string { + for line := range strings.SplitSeq(out, "\n") { + if strings.Contains(line, needle) { + return strings.Join(strings.Fields(line), " ") + } + } + return "" +} + +func testDiskUsage() types.DiskUsage { + createdAt := time.Now().Add(-time.Hour) + lastUsedAt := time.Now().Add(-time.Minute) + + return types.DiskUsage{ + Images: types.ImageDiskUsage{ + TotalCount: 2, + ActiveCount: 1, + TotalSize: 1000, + Reclaimable: 400, + Items: []types.ImageDiskUsageItem{ + { + ID: "sha256:09538a1f51d3ec5af0449a1640937dfdf79b0e9b8c4da5b8a883086d5c1492ef", + Repository: "example.com/foo", + Tag: "latest", + CreatedAt: createdAt, + Size: 800, + SharedSize: 200, + Containers: 1, + }, + { + ID: "sha256:0168606be2317b0d6a3c0b1a2a5b1a2a5b1a2a5b1a2a5b1a2a5b1a2a5b1a2a5b", + CreatedAt: createdAt, + Size: 600, + // A dangling image with no container: everything unique to it is reclaimable. + SharedSize: 200, + }, + }, + }, + Containers: types.ContainerDiskUsage{ + TotalCount: 1, + ActiveCount: 0, + TotalSize: 100, + Reclaimable: 100, + Items: []types.ContainerDiskUsageItem{ + { + ID: "6d3f1c1c1a5b6d3f1c1c1a5b6d3f1c1c1a5b6d3f1c1c1a5b6d3f1c1c1a5b6d3f", + Image: "example.com/foo:latest", + Command: `"sleep 3600"`, + LocalVolumes: 1, + SizeRw: 100, + CreatedAt: createdAt, + Status: "Exited (0) 1 minute ago", + Names: "sleeper", + }, + }, + }, + Volumes: types.VolumeDiskUsage{ + TotalCount: 2, + ActiveCount: 1, + TotalSize: 300, + Reclaimable: 100, + Items: []types.VolumeDiskUsageItem{ + {Name: "data", Links: 1, Size: 200}, + {Name: "orphan", Links: 0, Size: 100}, + }, + }, + BuildCache: types.BuildCacheDiskUsage{ + TotalCount: 1, + ActiveCount: 0, + TotalSize: 500, + Reclaimable: 500, + Items: []types.BuildCacheDiskUsageItem{ + { + ID: "n3vkjqf4tzxkgxwjdgm0e5vpm", + CacheType: "regular", + Size: 500, + CreatedAt: createdAt, + LastUsedAt: &lastUsedAt, + UsageCount: 3, + }, + }, + }, + } +} + +func TestPrintDiskUsageSummary(t *testing.T) { + t.Parallel() + + stdout := &bytes.Buffer{} + err := printDiskUsage(testDiskUsage(), types.SystemDfOptions{Stdout: stdout}) + assert.NilError(t, err) + + lines := strings.Split(strings.TrimSuffix(stdout.String(), "\n"), "\n") + assert.Equal(t, len(lines), 5) + + assert.Assert(t, strings.HasPrefix(lines[0], "TYPE"), lines[0]) + assert.Assert(t, strings.Contains(lines[0], "RECLAIMABLE"), lines[0]) + + assert.Equal(t, strings.Join(strings.Fields(lines[1]), " "), "Images 2 1 1kB 400B (40%)") + assert.Equal(t, strings.Join(strings.Fields(lines[2]), " "), "Containers 1 0 100B 100B (100%)") + assert.Equal(t, strings.Join(strings.Fields(lines[3]), " "), "Local Volumes 2 1 300B 100B (33%)") + // The build cache never gets a percentage, matching Docker. + assert.Equal(t, strings.Join(strings.Fields(lines[4]), " "), "Build Cache 1 0 500B 500B") +} + +func TestPrintDiskUsageEmpty(t *testing.T) { + t.Parallel() + + stdout := &bytes.Buffer{} + err := printDiskUsage(types.DiskUsage{}, types.SystemDfOptions{Stdout: stdout}) + assert.NilError(t, err) + + lines := strings.Split(strings.TrimSuffix(stdout.String(), "\n"), "\n") + assert.Equal(t, len(lines), 5) + for _, line := range lines[1:] { + // Without a total there is nothing to take a percentage of. + assert.Assert(t, strings.HasSuffix(line, "0B"), line) + } +} + +func TestPrintDiskUsageFormat(t *testing.T) { + t.Parallel() + + stdout := &bytes.Buffer{} + err := printDiskUsage(testDiskUsage(), types.SystemDfOptions{ + Stdout: stdout, + Format: "{{.Type}}={{.Size}}", + }) + assert.NilError(t, err) + + assert.Equal(t, stdout.String(), strings.Join([]string{ + "Images=1kB", + "Containers=100B", + "Local Volumes=300B", + "Build Cache=500B", + "", + }, "\n")) +} + +func TestPrintDiskUsageTableFormat(t *testing.T) { + t.Parallel() + + stdout := &bytes.Buffer{} + err := printDiskUsage(testDiskUsage(), types.SystemDfOptions{ + Stdout: stdout, + // The \t is what a shell passes through literally, so it has to be expanded here. + Format: `table {{.Type}}\t{{.Size}}`, + }) + assert.NilError(t, err) + + lines := strings.Split(strings.TrimSuffix(stdout.String(), "\n"), "\n") + assert.Equal(t, len(lines), 5) + // The header is the same template over the column labels, so it names the chosen columns only. + assert.Equal(t, strings.Join(strings.Fields(lines[0]), " "), "TYPE SIZE") + assert.Equal(t, strings.Join(strings.Fields(lines[1]), " "), "Images 1kB") + assert.Equal(t, strings.Join(strings.Fields(lines[4]), " "), "Build Cache 500B") + // The columns are aligned, unlike a bare template. + assert.Assert(t, strings.Contains(lines[1], " "), lines[1]) +} + +func TestPrintDiskUsageTableFormatHeader(t *testing.T) { + t.Parallel() + + stdout := &bytes.Buffer{} + err := printDiskUsage(testDiskUsage(), types.SystemDfOptions{ + Stdout: stdout, + Format: `table {{lower .Type}}\t{{truncate .Size 2}}`, + }) + assert.NilError(t, err) + + lines := strings.Split(strings.TrimSuffix(stdout.String(), "\n"), "\n") + // The header names the columns whatever the template does to the values under them, so the + // functions that transform a value are not applied to the labels. + assert.Equal(t, strings.Join(strings.Fields(lines[0]), " "), "TYPE SIZE") + assert.Equal(t, strings.Join(strings.Fields(lines[1]), " "), "images 1k") +} + +func TestPrintDiskUsageBareTableFormat(t *testing.T) { + t.Parallel() + + // A bare "table" keeps the default columns. + stdout := &bytes.Buffer{} + err := printDiskUsage(testDiskUsage(), types.SystemDfOptions{Stdout: stdout, Format: "table"}) + assert.NilError(t, err) + + lines := strings.Split(strings.TrimSuffix(stdout.String(), "\n"), "\n") + assert.Equal(t, strings.Join(strings.Fields(lines[0]), " "), "TYPE TOTAL ACTIVE SIZE RECLAIMABLE") + assert.Equal(t, len(lines), 5) +} + +func TestPrintDiskUsageFormatJSON(t *testing.T) { + t.Parallel() + + stdout := &bytes.Buffer{} + err := printDiskUsage(testDiskUsage(), types.SystemDfOptions{Stdout: stdout, Format: "json"}) + assert.NilError(t, err) + + lines := strings.Split(strings.TrimSuffix(stdout.String(), "\n"), "\n") + assert.Equal(t, len(lines), 4) + for _, line := range lines { + var row dfPrintable + assert.NilError(t, json.Unmarshal([]byte(line), &row)) + assert.Assert(t, row.Type != "", line) + } +} + +func TestPrintDiskUsageRawIsUnsupported(t *testing.T) { + t.Parallel() + + err := printDiskUsage(testDiskUsage(), types.SystemDfOptions{Stdout: &bytes.Buffer{}, Format: "raw"}) + assert.ErrorContains(t, err, "raw") +} + +func TestPrintDiskUsageVerbose(t *testing.T) { + t.Parallel() + + stdout := &bytes.Buffer{} + err := printDiskUsage(testDiskUsage(), types.SystemDfOptions{Stdout: stdout, Verbose: true}) + assert.NilError(t, err) + + out := stdout.String() + for _, section := range []string{ + "Images space usage:", + "Containers space usage:", + "Local Volumes space usage:", + "Build cache usage: 500B", + } { + assert.Assert(t, strings.Contains(out, section), out) + } + + // UNIQUE SIZE is what is left once the shared part is taken out of the size. + assert.Equal(t, fieldsOfRowContaining(out, "example.com/foo"), + "example.com/foo latest 09538a1f51d3 About an hour ago 800B 200B 600B 1") + // An image with neither repository nor tag is shown the Docker way. + assert.Equal(t, fieldsOfRowContaining(out, "0168606be231"), + " 0168606be231 About an hour ago 600B 200B 400B 0") + // The build cache record keeps its opaque identifier, only truncated. + assert.Equal(t, fieldsOfRowContaining(out, "n3vkjqf4tzxk"), + "n3vkjqf4tzxk regular 500B About an hour ago About a minute ago 3 false") +} + +func TestPrintDiskUsageVerboseMarksBuildCacheInUse(t *testing.T) { + t.Parallel() + + du := testDiskUsage() + du.BuildCache.Items[0].InUse = true + + stdout := &bytes.Buffer{} + err := printDiskUsage(du, types.SystemDfOptions{Stdout: stdout, Verbose: true}) + assert.NilError(t, err) + + // Docker gives it no column of its own: a record in use is the one whose ID carries a star. + assert.Equal(t, fieldsOfRowContaining(stdout.String(), "n3vkjqf4tzxk"), + "n3vkjqf4tzxk* regular 500B About an hour ago About a minute ago 3 false") + + // A custom format can still ask for it by name. + formatted := &bytes.Buffer{} + err = printDiskUsage(du, types.SystemDfOptions{ + Stdout: formatted, + Verbose: true, + Format: `{{range .BuildCache}}{{.InUse}}{{end}}`, + }) + assert.NilError(t, err) + assert.Equal(t, strings.TrimSpace(formatted.String()), "true") +} + +func TestPrintDiskUsageVerboseKeepsFullIDsForFormat(t *testing.T) { + t.Parallel() + + du := testDiskUsage() + + table := &bytes.Buffer{} + err := printDiskUsage(du, types.SystemDfOptions{Stdout: table, Verbose: true}) + assert.NilError(t, err) + // The table is for reading, so the identifiers are shortened. + assert.Assert(t, strings.Contains(table.String(), "09538a1f51d3"), table.String()) + assert.Assert(t, !strings.Contains(table.String(), du.Images.Items[0].ID), table.String()) + + formatted := &bytes.Buffer{} + err = printDiskUsage(du, types.SystemDfOptions{Stdout: formatted, Verbose: true, Format: "json"}) + assert.NilError(t, err) + + // A custom format is for machines, so the identifiers stay addressable. + var verbose dfVerbosePrintable + assert.NilError(t, json.Unmarshal([]byte(strings.TrimSpace(formatted.String())), &verbose)) + assert.Equal(t, verbose.Images[0].ID, du.Images.Items[0].ID) + assert.Equal(t, verbose.Containers[0].ID, du.Containers.Items[0].ID) + assert.Equal(t, verbose.BuildCache[0].ID, du.BuildCache.Items[0].ID) +} + +func TestPrintDiskUsageVerboseTableFormatShortensIDs(t *testing.T) { + t.Parallel() + + du := testDiskUsage() + stdout := &bytes.Buffer{} + err := printDiskUsage(du, types.SystemDfOptions{ + Stdout: stdout, + Verbose: true, + Format: `table {{range .Images}}{{.ID}}{{end}}`, + }) + assert.NilError(t, err) + + // A table format is still a table, whatever columns it asks for, so Docker shortens its + // identifiers just like those of the default one. + assert.Assert(t, strings.Contains(stdout.String(), "09538a1f51d3"), stdout.String()) + assert.Assert(t, !strings.Contains(stdout.String(), du.Images.Items[0].ID), stdout.String()) +} + +func TestPrintDiskUsageVerboseFormat(t *testing.T) { + t.Parallel() + + stdout := &bytes.Buffer{} + err := printDiskUsage(testDiskUsage(), types.SystemDfOptions{ + Stdout: stdout, + Verbose: true, + Format: "json", + }) + assert.NilError(t, err) + + var verbose dfVerbosePrintable + assert.NilError(t, json.Unmarshal([]byte(strings.TrimSpace(stdout.String())), &verbose)) + assert.Equal(t, len(verbose.Images), 2) + assert.Equal(t, len(verbose.Containers), 1) + assert.Equal(t, len(verbose.Volumes), 2) + assert.Equal(t, len(verbose.BuildCache), 1) + assert.Equal(t, verbose.Images[0].UniqueSize, "600B") +} diff --git a/pkg/cmd/volume/df.go b/pkg/cmd/volume/df.go new file mode 100644 index 00000000000..caadf04bc34 --- /dev/null +++ b/pkg/cmd/volume/df.go @@ -0,0 +1,76 @@ +/* + 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 volume + +import ( + "context" + "slices" + "strings" + + containerd "github.com/containerd/containerd/v2/client" + + "github.com/containerd/nerdctl/v2/pkg/api/types" +) + +// DiskUsage reports how much disk space the local volumes of the current namespace use. +// +// A volume is active when at least one container mounts it, and the space of the volumes no +// container mounts can be reclaimed. Note that, unlike `nerdctl volume prune`, this counts the named +// volumes too: Docker reports what `docker volume prune --all` would free. +func DiskUsage(ctx context.Context, client *containerd.Client, gOptions types.GlobalCommandOptions, verbose bool) (types.VolumeDiskUsage, error) { + du := types.VolumeDiskUsage{} + + // The size is what we are after here, so it is always requested. + vols, err := Volumes(gOptions.Namespace, gOptions.DataRoot, gOptions.Address, true, nil) + if err != nil { + return du, err + } + + containers, err := client.Containers(ctx) + if err != nil { + return du, err + } + links, err := usedVolumes(ctx, containers) + if err != nil { + return du, err + } + + for _, v := range vols { + du.TotalCount++ + du.TotalSize += v.Size + if links[v.Name] > 0 { + du.ActiveCount++ + } else { + du.Reclaimable += v.Size + } + + if verbose { + du.Items = append(du.Items, types.VolumeDiskUsageItem{ + Name: v.Name, + Links: links[v.Name], + Size: v.Size, + }) + } + } + + // Volumes comes from a map, so give the verbose output a stable order. + slices.SortFunc(du.Items, func(a, b types.VolumeDiskUsageItem) int { + return strings.Compare(a.Name, b.Name) + }) + + return du, nil +} diff --git a/pkg/cmd/volume/df_test.go b/pkg/cmd/volume/df_test.go new file mode 100644 index 00000000000..8bf794e794d --- /dev/null +++ b/pkg/cmd/volume/df_test.go @@ -0,0 +1,77 @@ +/* + 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 volume + +import ( + "testing" + + "gotest.tools/v3/assert" +) + +func TestMountedVolumes(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + mountsJSON string + expected []string + }{ + { + name: "no mounts", + mountsJSON: "", + }, + { + name: "a named volume and a bind", + mountsJSON: `[{"Type":"volume","Name":"data","Destination":"/data"},` + + `{"Type":"bind","Source":"/host","Destination":"/host"}]`, + expected: []string{"data"}, + }, + { + name: "the same volume at two paths counts once", + mountsJSON: `[{"Type":"volume","Name":"data","Destination":"/data"},` + + `{"Type":"volume","Name":"data","Destination":"/backup"}]`, + expected: []string{"data"}, + }, + { + name: "two volumes", + mountsJSON: `[{"Type":"volume","Name":"data","Destination":"/data"},` + + `{"Type":"volume","Name":"logs","Destination":"/logs"}]`, + expected: []string{"data", "logs"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + names, err := mountedVolumes(tc.mountsJSON) + assert.NilError(t, err) + assert.Equal(t, len(names), len(tc.expected)) + for _, name := range tc.expected { + _, ok := names[name] + assert.Assert(t, ok, name) + } + }) + } +} + +func TestMountedVolumesInvalidJSON(t *testing.T) { + t.Parallel() + + _, err := mountedVolumes(`[{"Type":"volume"`) + assert.Assert(t, err != nil) +} diff --git a/pkg/cmd/volume/rm.go b/pkg/cmd/volume/rm.go index 0a41b3f23a5..0930b9ce16d 100644 --- a/pkg/cmd/volume/rm.go +++ b/pkg/cmd/volume/rm.go @@ -79,8 +79,10 @@ func Remove(ctx context.Context, client *containerd.Client, volumes []string, op return nil } -func usedVolumes(ctx context.Context, containers []containerd.Container) (map[string]struct{}, error) { - usedVolumesList := make(map[string]struct{}) +// usedVolumes returns, per volume name, how many containers mount it. Callers that only care about +// whether a volume is used at all can test for the presence of the key. +func usedVolumes(ctx context.Context, containers []containerd.Container) (map[string]int64, error) { + usedVolumesList := make(map[string]int64) for _, c := range containers { l, err := c.Labels(ctx) if err != nil { @@ -93,21 +95,34 @@ func usedVolumes(ctx context.Context, containers []containerd.Container) (map[st return nil, err } - mountsJSON := labels.GetMount(l) - if mountsJSON == "" { - continue - } - - var mounts []dockercompat.MountPoint - err = json.Unmarshal([]byte(mountsJSON), &mounts) + names, err := mountedVolumes(labels.GetMount(l)) if err != nil { return nil, err } - for _, m := range mounts { - if m.Type == mountutil.Volume { - usedVolumesList[m.Name] = struct{}{} - } + for name := range names { + usedVolumesList[name]++ } } return usedVolumesList, nil } + +// mountedVolumes returns the distinct volume names of a container, from the JSON-marshalled mounts +// it carries in its labels. The names are deduplicated: a container mounting the same volume at +// several paths is still one reference to it, which is how Docker counts the links of a volume. +func mountedVolumes(mountsJSON string) (map[string]struct{}, error) { + names := make(map[string]struct{}) + if mountsJSON == "" { + return names, nil + } + + var mounts []dockercompat.MountPoint + if err := json.Unmarshal([]byte(mountsJSON), &mounts); err != nil { + return nil, err + } + for _, m := range mounts { + if m.Type == mountutil.Volume { + names[m.Name] = struct{}{} + } + } + return names, nil +} diff --git a/pkg/containerdutil/content.go b/pkg/containerdutil/content.go index 929e60951c9..80c364e00b1 100644 --- a/pkg/containerdutil/content.go +++ b/pkg/containerdutil/content.go @@ -27,8 +27,48 @@ import ( containerd "github.com/containerd/containerd/v2/client" "github.com/containerd/containerd/v2/core/content" + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/errdefs" ) +// WalkPresentChildren calls f for target and for every descriptor reachable from it that is present +// in the content store. Descriptors that are only referenced but not stored locally (for instance +// the layers of an image that was pulled for another platform) are skipped, so that sizes computed +// from the visited descriptors reflect what is actually on disk. +func WalkPresentChildren(ctx context.Context, store content.Store, target ocispec.Descriptor, f func(context.Context, ocispec.Descriptor) error) error { + return images.Walk(ctx, presentChildrenHandler(store, func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) { + return nil, f(ctx, desc) + }), target) +} + +// presentChildrenHandler wraps h so that it is only called for descriptors present in the store, and +// so that the walk descends into the children of those descriptors. +func presentChildrenHandler(store content.Store, h images.HandlerFunc) images.HandlerFunc { + return func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) { + if _, err := store.Info(ctx, desc.Digest); err != nil { + if errdefs.IsNotFound(err) { + return nil, images.ErrSkipDesc + } + return nil, err + } + + children, err := h(ctx, desc) + if err != nil { + return nil, err + } + + c, err := images.Children(ctx, store, desc) + if err != nil { + if errdefs.IsNotFound(err) { + return nil, images.ErrSkipDesc + } + return nil, err + } + + return append(children, c...), nil + } +} + // ContentStore should be called to get a Provider with caching func NewProvider(client *containerd.Client) content.Provider { return &providerWithCache{ diff --git a/pkg/formatter/common.go b/pkg/formatter/common.go index e418dd7d8ef..edf432633e6 100644 --- a/pkg/formatter/common.go +++ b/pkg/formatter/common.go @@ -22,6 +22,7 @@ import ( "errors" "fmt" "io" + "strings" "text/template" "github.com/docker/cli/templates" @@ -32,6 +33,38 @@ type Flusher interface { Flush() error } +// tableFormatKey introduces the Docker table formats, e.g. `table {{.Type}}\t{{.Size}}`, which +// render a header and aligned columns rather than the raw output of the template. +const tableFormatKey = "table" + +// IsTableFormat reports whether format is a Docker table format: either the bare "table", which +// selects the default columns of a command, or "table " followed by a template. +func IsTableFormat(format string) bool { + return format == tableFormatKey || strings.HasPrefix(format, tableFormatKey+" ") +} + +// ParseTableTemplate parses the template carried by a Docker table format. Like docker/cli, it +// expands the literal `\t` and `\n` a shell would otherwise have to produce itself. +// +// It returns a second template for the header row. A header is rendered by running the very same +// template over the column labels, so a function that transforms a value would rewrite the label +// too and `table {{lower .Type}}` would name the column "type" instead of TYPE. The header template +// therefore replaces those functions by ones leaving their argument alone, as docker/cli does. Only +// `pad` is kept as it is, so that the header stays aligned with its column. +func ParseTableTemplate(format string) (rows, header *template.Template, err error) { + format = strings.TrimSpace(strings.TrimPrefix(format, tableFormatKey)) + format = strings.ReplaceAll(format, `\t`, "\t") + format = strings.ReplaceAll(format, `\n`, "\n") + + if rows, err = ParseTemplate(format); err != nil { + return nil, nil, err + } + if header, err = rows.Clone(); err != nil { + return nil, nil, err + } + return rows, header.Funcs(templates.HeaderFunctions), nil +} + // FormatSlice formats the slice with `--format` flag. // // --format="" (default): JSON diff --git a/pkg/formatter/table_test.go b/pkg/formatter/table_test.go new file mode 100644 index 00000000000..94c744a65a5 --- /dev/null +++ b/pkg/formatter/table_test.go @@ -0,0 +1,93 @@ +/* + 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 formatter + +import ( + "bytes" + "testing" + + "gotest.tools/v3/assert" +) + +func TestIsTableFormat(t *testing.T) { + t.Parallel() + + testCases := []struct { + format string + expected bool + }{ + {format: "table", expected: true}, + {format: `table {{.Type}}`, expected: true}, + {format: `table {{.Type}}\t{{.Size}}`, expected: true}, + {format: "", expected: false}, + {format: "json", expected: false}, + {format: `{{.Type}}`, expected: false}, + // A template that merely starts with the word is not a table format. + {format: `{{.Type}} table`, expected: false}, + {format: "tabled", expected: false}, + } + + for _, tc := range testCases { + t.Run(tc.format, func(t *testing.T) { + t.Parallel() + assert.Equal(t, IsTableFormat(tc.format), tc.expected) + }) + } +} + +func TestParseTableTemplate(t *testing.T) { + t.Parallel() + + // The shell passes \t and \n through literally, so they arrive as two characters and have to + // be expanded, the way docker/cli does. + rows, _, err := ParseTableTemplate(`table {{.A}}\t{{.B}}\n`) + assert.NilError(t, err) + + var b bytes.Buffer + err = rows.Execute(&b, struct{ A, B string }{A: "one", B: "two"}) + assert.NilError(t, err) + assert.Equal(t, b.String(), "one\ttwo\n") +} + +func TestParseTableTemplateHeader(t *testing.T) { + t.Parallel() + + // The functions that transform a value must leave the column labels alone, otherwise the + // header of `table {{lower .A}}` would read "a" instead of naming the column. + rows, header, err := ParseTableTemplate(`table {{lower .A}}\t{{truncate .B 2}}\t{{upper .C}}`) + assert.NilError(t, err) + + type row struct{ A, B, C string } + + var headerOut bytes.Buffer + err = header.Execute(&headerOut, row{A: "NAME", B: "SIZE", C: "Status"}) + assert.NilError(t, err) + assert.Equal(t, headerOut.String(), "NAME\tSIZE\tStatus") + + // The rows themselves still go through the functions they asked for. + var rowOut bytes.Buffer + err = rows.Execute(&rowOut, row{A: "Foo", B: "100B", C: "up"}) + assert.NilError(t, err) + assert.Equal(t, rowOut.String(), "foo\t10\tUP") +} + +func TestParseTableTemplateInvalid(t *testing.T) { + t.Parallel() + + _, _, err := ParseTableTemplate(`table {{.Unclosed`) + assert.Assert(t, err != nil) +}