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/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/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/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 f87fce0a272..d9d0adfe1ac 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" ) @@ -219,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) @@ -253,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 @@ -475,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" } @@ -563,27 +564,62 @@ 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") return inUse } for _, container := range containerList { - image, err := container.Image(ctx) - if err != nil { - continue + if dgst, ok := containerImageDigest(ctx, container); ok { + inUse[dgst]++ } - 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/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) +} 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"