diff --git a/commands/diskusage.go b/commands/diskusage.go index a1f8224d7..62c621785 100644 --- a/commands/diskusage.go +++ b/commands/diskusage.go @@ -4,8 +4,6 @@ import ( "context" "fmt" "io" - "os" - "strings" "text/tabwriter" "time" @@ -13,20 +11,77 @@ import ( "github.com/docker/buildx/util/cobrautil/completion" "github.com/docker/cli/cli" "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/command/formatter" "github.com/docker/cli/opts" "github.com/docker/go-units" "github.com/moby/buildkit/client" + "github.com/pkg/errors" "github.com/spf13/cobra" "golang.org/x/sync/errgroup" ) +const ( + duIDHeader = "ID" + duParentsHeader = "PARENTS" + duCreatedAtHeader = "CREATED AT" + duMutableHeader = "MUTABLE" + duReclaimHeader = "RECLAIMABLE" + duSharedHeader = "SHARED" + duSizeHeader = "SIZE" + duDescriptionHeader = "DESCRIPTION" + duUsageHeader = "USAGE COUNT" + duLastUsedAtHeader = "LAST ACCESSED" + duTypeHeader = "TYPE" + + duDefaultTableFormat = "table {{.ID}}\t{{.Reclaimable}}\t{{.Size}}\t{{.LastUsedAt}}" + + duDefaultPrettyTemplate = `ID: {{.ID}} +{{- if .Parents }} +Parents: +{{- range .Parents }} + - {{.}} +{{- end }} +{{- end }} +Created at: {{.CreatedAt}} +Mutable: {{.Mutable}} +Reclaimable: {{.Reclaimable}} +Shared: {{.Shared}} +Size: {{.Size}} +{{- if .Description}} +Description: {{ .Description }} +{{- end }} +Usage count: {{.UsageCount}} +{{- if .LastUsedAt}} +Last used: {{ .LastUsedAt }} +{{- end }} +{{- if .Type}} +Type: {{ .Type }} +{{- end }} +` +) + type duOptions struct { builder string filter opts.FilterOpt verbose bool + format string } func runDiskUsage(ctx context.Context, dockerCli command.Cli, opts duOptions) error { + if opts.format != "" && opts.verbose { + return errors.New("--format and --verbose cannot be used together") + } else if opts.format == "" { + if opts.verbose { + opts.format = duDefaultPrettyTemplate + } else { + opts.format = duDefaultTableFormat + } + } else if opts.format == formatter.PrettyFormatKey { + opts.format = duDefaultPrettyTemplate + } else if opts.format == formatter.TableFormatKey { + opts.format = duDefaultTableFormat + } + pi, err := toBuildkitPruneInfo(opts.filter.Value()) if err != nil { return err @@ -74,33 +129,53 @@ func runDiskUsage(ctx context.Context, dockerCli command.Cli, opts duOptions) er return err } - tw := tabwriter.NewWriter(os.Stdout, 1, 8, 1, '\t', 0) - first := true + fctx := formatter.Context{ + Output: dockerCli.Out(), + Format: formatter.Format(opts.format), + } + + var dus []*client.UsageInfo for _, du := range out { - if du == nil { - continue - } - if opts.verbose { - printVerbose(tw, du) - } else { - if first { - printTableHeader(tw) - first = false - } - for _, di := range du { - printTableRow(tw, di) - } - - tw.Flush() + if du != nil { + dus = append(dus, du...) } } - if opts.filter.Value().Len() == 0 { - printSummary(tw, out) + render := func(format func(subContext formatter.SubContext) error) error { + for _, du := range dus { + if err := format(&diskusageContext{ + format: fctx.Format, + du: du, + }); err != nil { + return err + } + } + return nil } - tw.Flush() - return nil + duCtx := diskusageContext{} + duCtx.Header = formatter.SubHeaderContext{ + "ID": duIDHeader, + "Parents": duParentsHeader, + "CreatedAt": duCreatedAtHeader, + "Mutable": duMutableHeader, + "Reclaimable": duReclaimHeader, + "Shared": duSharedHeader, + "Size": duSizeHeader, + "Description": duDescriptionHeader, + "UsageCount": duUsageHeader, + "LastUsedAt": duLastUsedAtHeader, + "Type": duTypeHeader, + } + + defer func() { + if (fctx.Format != duDefaultTableFormat && fctx.Format != duDefaultPrettyTemplate) || fctx.Format.IsJSON() || opts.filter.Value().Len() > 0 { + return + } + printSummary(dockerCli.Out(), out) + }() + + return fctx.Write(&duCtx, render) } func duCmd(dockerCli command.Cli, rootOpts *rootOptions) *cobra.Command { @@ -119,64 +194,78 @@ func duCmd(dockerCli command.Cli, rootOpts *rootOptions) *cobra.Command { flags := cmd.Flags() flags.Var(&options.filter, "filter", "Provide filter values") - flags.BoolVar(&options.verbose, "verbose", false, "Provide a more verbose output") + flags.BoolVar(&options.verbose, "verbose", false, `Shorthand for "--format=pretty"`) + flags.StringVar(&options.format, "format", "", "Format the output") return cmd } -func printKV(w io.Writer, k string, v any) { - fmt.Fprintf(w, "%s:\t%v\n", k, v) +type diskusageContext struct { + formatter.HeaderContext + format formatter.Format + du *client.UsageInfo } -func printVerbose(tw *tabwriter.Writer, du []*client.UsageInfo) { - for _, di := range du { - printKV(tw, "ID", di.ID) - if len(di.Parents) != 0 { - printKV(tw, "Parent", strings.Join(di.Parents, ",")) - } - printKV(tw, "Created at", di.CreatedAt) - printKV(tw, "Mutable", di.Mutable) - printKV(tw, "Reclaimable", !di.InUse) - printKV(tw, "Shared", di.Shared) - printKV(tw, "Size", units.HumanSize(float64(di.Size))) - if di.Description != "" { - printKV(tw, "Description", di.Description) - } - printKV(tw, "Usage count", di.UsageCount) - if di.LastUsedAt != nil { - printKV(tw, "Last used", units.HumanDuration(time.Since(*di.LastUsedAt))+" ago") - } - if di.RecordType != "" { - printKV(tw, "Type", di.RecordType) - } - - fmt.Fprintf(tw, "\n") - } - - tw.Flush() +func (d *diskusageContext) MarshalJSON() ([]byte, error) { + return formatter.MarshalJSON(d) } -func printTableHeader(tw *tabwriter.Writer) { - fmt.Fprintln(tw, "ID\tRECLAIMABLE\tSIZE\tLAST ACCESSED") -} - -func printTableRow(tw *tabwriter.Writer, di *client.UsageInfo) { - id := di.ID - if di.Mutable { +func (d *diskusageContext) ID() string { + id := d.du.ID + if d.format.IsTable() && d.du.Mutable { id += "*" } - size := units.HumanSize(float64(di.Size)) - if di.Shared { - size += "*" - } - lastAccessed := "" - if di.LastUsedAt != nil { - lastAccessed = units.HumanDuration(time.Since(*di.LastUsedAt)) + " ago" - } - fmt.Fprintf(tw, "%-40s\t%-5v\t%-10s\t%s\n", id, !di.InUse, size, lastAccessed) + return id } -func printSummary(tw *tabwriter.Writer, dus [][]*client.UsageInfo) { +func (d *diskusageContext) Parents() []string { + return d.du.Parents +} + +func (d *diskusageContext) CreatedAt() string { + return d.du.CreatedAt.String() +} + +func (d *diskusageContext) Mutable() bool { + return d.du.Mutable +} + +func (d *diskusageContext) Reclaimable() bool { + return !d.du.InUse +} + +func (d *diskusageContext) Shared() bool { + return d.du.Shared +} + +func (d *diskusageContext) Size() string { + size := units.HumanSize(float64(d.du.Size)) + if d.format.IsTable() && d.du.Shared { + size += "*" + } + return size +} + +func (d *diskusageContext) Description() string { + return d.du.Description +} + +func (d *diskusageContext) UsageCount() int { + return d.du.UsageCount +} + +func (d *diskusageContext) LastUsedAt() string { + if d.du.LastUsedAt != nil { + return units.HumanDuration(time.Since(*d.du.LastUsedAt)) + " ago" + } + return "" +} + +func (d *diskusageContext) Type() string { + return string(d.du.RecordType) +} + +func printSummary(w io.Writer, dus [][]*client.UsageInfo) { total := int64(0) reclaimable := int64(0) shared := int64(0) @@ -195,11 +284,11 @@ func printSummary(tw *tabwriter.Writer, dus [][]*client.UsageInfo) { } } + tw := tabwriter.NewWriter(w, 1, 8, 1, '\t', 0) if shared > 0 { fmt.Fprintf(tw, "Shared:\t%s\n", units.HumanSize(float64(shared))) fmt.Fprintf(tw, "Private:\t%s\n", units.HumanSize(float64(total-shared))) } - fmt.Fprintf(tw, "Reclaimable:\t%s\n", units.HumanSize(float64(reclaimable))) fmt.Fprintf(tw, "Total:\t%s\n", units.HumanSize(float64(total))) tw.Flush() diff --git a/commands/prune.go b/commands/prune.go index d739fac5b..24574f92a 100644 --- a/commands/prune.go +++ b/commands/prune.go @@ -3,6 +3,7 @@ package commands import ( "context" "fmt" + "io" "os" "strings" "text/tabwriter" @@ -241,3 +242,55 @@ func toBuildkitPruneInfo(f filters.Args) (*client.PruneInfo, error) { Filter: []string{strings.Join(filters, ",")}, }, nil } + +func printKV(w io.Writer, k string, v any) { + fmt.Fprintf(w, "%s:\t%v\n", k, v) +} + +func printVerbose(tw *tabwriter.Writer, du []*client.UsageInfo) { + for _, di := range du { + printKV(tw, "ID", di.ID) + if len(di.Parents) != 0 { + printKV(tw, "Parent", strings.Join(di.Parents, ",")) + } + printKV(tw, "Created at", di.CreatedAt) + printKV(tw, "Mutable", di.Mutable) + printKV(tw, "Reclaimable", !di.InUse) + printKV(tw, "Shared", di.Shared) + printKV(tw, "Size", units.HumanSize(float64(di.Size))) + if di.Description != "" { + printKV(tw, "Description", di.Description) + } + printKV(tw, "Usage count", di.UsageCount) + if di.LastUsedAt != nil { + printKV(tw, "Last used", units.HumanDuration(time.Since(*di.LastUsedAt))+" ago") + } + if di.RecordType != "" { + printKV(tw, "Type", di.RecordType) + } + + fmt.Fprintf(tw, "\n") + } + + tw.Flush() +} + +func printTableHeader(tw *tabwriter.Writer) { + fmt.Fprintln(tw, "ID\tRECLAIMABLE\tSIZE\tLAST ACCESSED") +} + +func printTableRow(tw *tabwriter.Writer, di *client.UsageInfo) { + id := di.ID + if di.Mutable { + id += "*" + } + size := units.HumanSize(float64(di.Size)) + if di.Shared { + size += "*" + } + lastAccessed := "" + if di.LastUsedAt != nil { + lastAccessed = units.HumanDuration(time.Since(*di.LastUsedAt)) + " ago" + } + fmt.Fprintf(tw, "%-40s\t%-5v\t%-10s\t%s\n", id, !di.InUse, size, lastAccessed) +} diff --git a/docs/reference/buildx_du.md b/docs/reference/buildx_du.md index 298782726..d80498c10 100644 --- a/docs/reference/buildx_du.md +++ b/docs/reference/buildx_du.md @@ -14,7 +14,8 @@ Disk usage | [`--builder`](#builder) | `string` | | Override the configured builder instance | | `-D`, `--debug` | `bool` | | Enable debug logging | | `--filter` | `filter` | | Provide filter values | -| [`--verbose`](#verbose) | `bool` | | Provide a more verbose output | +| [`--format`](#format) | `string` | | Format the output | +| [`--verbose`](#verbose) | `bool` | | Shorthand for `--format=pretty` | @@ -50,7 +51,7 @@ If `RECLAIMABLE` is false, the `docker buildx du prune` command won't delete the record, even if you use `--all`. That's because the record is actively in use by some component of the builder. -The asterisks (\*) in the default output indicate the following: +The asterisks (\*) in the default output format indicate the following: - An asterisk next to an ID (`zu7m6evdpebh5h8kfkpw9dlf2*`) indicates that the record is mutable. The size of the record may change, or another build can take ownership of @@ -61,33 +62,146 @@ The asterisks (\*) in the default output indicate the following: If you prune such a record then you will lose build cache but only metadata will be deleted as the image still needs to actual storage layers. +### Format the output (--format) + +The formatting options (`--format`) pretty-prints usage information output +using a Go template. + +Valid placeholders for the Go template are: + +* `.ID` +* `.Parents` +* `.CreatedAt` +* `.Mutable` +* `.Reclaimable` +* `.Shared` +* `.Size` +* `.Description` +* `.UsageCount` +* `.LastUsedAt` +* `.Type` + +When using the `--format` option, the `du` command will either output the data +exactly as the template declares. + +The `pretty` format is useful for inspecting the disk usage records in more +detail. It shows the mutable and shared states more clearly, as well as +additional information about the corresponding layer: + +```console +$ docker buildx du --format=pretty +... +ID: 6wqu0v6hjdwvhh8yjozrepaof +Parents: + - bqx15bcewecz4wcg14b7iodvp +Created at: 2025-06-12 15:44:02.715795569 +0000 UTC +Mutable: false +Reclaimable: true +Shared: true +Size: 1.653GB +Description: [build-base 4/4] COPY . . +Usage count: 1 +Last used: 2 months ago +Type: regular + +Shared: 35.57GB +Private: 97.94GB +Reclaimable: 131.5GB +Total: 133.5GB +``` + +The following example uses a template without headers and outputs the +`ID` and `Size` entries separated by a colon (`:`): + +```console +$ docker buildx du --format "{{.ID}}: {{.Size}}" +6wqu0v6hjdwvhh8yjozrepaof: 1.653GB +4m8061kctvjyh9qleus8rgpgx: 1.723GB +fcm9mlz2641u8r5eicjqdhy1l: 1.841GB +z2qu1swvo3afzd9mhihi3l5k0: 1.873GB +nmi6asc00aa3ja6xnt6o7wbrr: 2.027GB +0qlam41jxqsq6i27yqllgxed3: 2.495GB +3w9qhzzskq5jc262snfu90bfz: 2.617GB +``` + +The following example uses a `table` template and outputs the `ID` and +`Description`: + +```console +$ docker buildx du --format "table {{.ID}} {{.Descirption}}" +lu76wm07lk5u7fe9nul93o95o [integration-tests 1/1] COPY . . +v6zmkcmgujv34vnys9eszttnv [dev 1/1] COPY --link . . +nj4fwb6qxznswmij3fg30sns2 mount / from exec /bin/sh -c rpm-init $DISTRO_NAME +``` + +JSON output is also supported and will print as newline delimited JSON: + +```console +$ docker buildx du --format=json +{"CreatedAt":"2025-07-29T12:36:01Z","Description":"pulled from docker.io/library/rust:1.85.1-bookworm@sha256:e51d0265072d2d9d5d320f6a44dde6b9ef13653b035098febd68cce8fa7c0bc4","ID":"ic1gfidvev5nciupzz53alel4","LastUsedAt":"2025-07-29T12:36:01Z","Mutable":false,"Parents":["hmpdhm4sjrfpmae4xm2y3m0ra"],"Reclaimable":true,"Shared":false,"Size":"829889526","Type":"regular","UsageCount":1} +{"CreatedAt":"2025-08-05T09:24:09Z","Description":"pulled from docker.io/library/node:22@sha256:3218f0d1b9e4b63def322e9ae362d581fbeac1ef21b51fc502ef91386667ce92","ID":"jsw7fx09l5zsda3bri1z4mwk5","LastUsedAt":"2025-08-05T09:24:09Z","Mutable":false,"Parents":["098jsj5ebbv1w47ikqigeuurs"],"Reclaimable":true,"Shared":true,"Size":"829898832","Type":"regular","UsageCount":1} +``` + +You can use `jq` to pretty-print the JSON output: + +```console +$ docker buildx du --format=json | jq . +{ + "CreatedAt": "2025-07-29T12:36:01Z", + "Description": "pulled from docker.io/library/rust:1.85.1-bookworm@sha256:e51d0265072d2d9d5d320f6a44dde6b9ef13653b035098febd68cce8fa7c0bc4", + "ID": "ic1gfidvev5nciupzz53alel4", + "LastUsedAt": "2025-07-29T12:36:01Z", + "Mutable": false, + "Parents": [ + "hmpdhm4sjrfpmae4xm2y3m0ra" + ], + "Reclaimable": true, + "Shared": false, + "Size": "829889526", + "Type": "regular", + "UsageCount": 1 +} +{ + "CreatedAt": "2025-08-05T09:24:09Z", + "Description": "pulled from docker.io/library/node:22@sha256:3218f0d1b9e4b63def322e9ae362d581fbeac1ef21b51fc502ef91386667ce92", + "ID": "jsw7fx09l5zsda3bri1z4mwk5", + "LastUsedAt": "2025-08-05T09:24:09Z", + "Mutable": false, + "Parents": [ + "098jsj5ebbv1w47ikqigeuurs" + ], + "Reclaimable": true, + "Shared": true, + "Size": "829898832", + "Type": "regular", + "UsageCount": 1 +} +``` + ### Use verbose output (--verbose) -The verbose output of the `docker buildx du` command is useful for inspecting -the disk usage records in more detail. The verbose output shows the mutable and -shared states more clearly, as well as additional information about the -corresponding layer. +Shorthand for [`--format=pretty`](#format): ```console $ docker buildx du --verbose ... -Last used: 2 days ago -Type: regular +ID: 6wqu0v6hjdwvhh8yjozrepaof +Parents: + - bqx15bcewecz4wcg14b7iodvp +Created at: 2025-06-12 15:44:02.715795569 +0000 UTC +Mutable: false +Reclaimable: true +Shared: true +Size: 1.653GB +Description: [build-base 4/4] COPY . . +Usage count: 1 +Last used: 2 months ago +Type: regular -ID: 05d0elirb4mmvpmnzbrp3ssrg -Parent: e8sfdn4mygrg7msi9ak1dy6op -Created at: 2023-11-20 09:53:30.881558721 +0000 UTC -Mutable: false -Reclaimable: true -Shared: false -Size: 0B -Description: [gobase 3/3] WORKDIR /src -Usage count: 3 -Last used: 24 hours ago -Type: regular - -Reclaimable: 4.453GB -Total: 4.453GB +Shared: 35.57GB +Private: 97.94GB +Reclaimable: 131.5GB +Total: 133.5GB ``` ### Override the configured builder instance (--builder) diff --git a/tests/diskusage.go b/tests/diskusage.go new file mode 100644 index 000000000..8cfb4caa9 --- /dev/null +++ b/tests/diskusage.go @@ -0,0 +1,51 @@ +package tests + +import ( + "testing" + + "github.com/moby/buildkit/util/testutil/integration" + "github.com/stretchr/testify/require" +) + +var diskusageTests = []func(t *testing.T, sb integration.Sandbox){ + testDiskusage, + testDiskusageVerbose, + testDiskusageVerboseFormatError, + testDiskusageFormatJSON, + testDiskusageFormatGoTemplate, +} + +func testDiskusage(t *testing.T, sb integration.Sandbox) { + buildTestProject(t, sb) + cmd := buildxCmd(sb, withArgs("du")) + out, err := cmd.Output() + require.NoError(t, err, string(out)) +} + +func testDiskusageVerbose(t *testing.T, sb integration.Sandbox) { + buildTestProject(t, sb) + cmd := buildxCmd(sb, withArgs("du", "--verbose")) + out, err := cmd.Output() + require.NoError(t, err, string(out)) +} + +func testDiskusageVerboseFormatError(t *testing.T, sb integration.Sandbox) { + buildTestProject(t, sb) + cmd := buildxCmd(sb, withArgs("du", "--verbose", "--format=json")) + out, err := cmd.Output() + require.Error(t, err, string(out)) +} + +func testDiskusageFormatJSON(t *testing.T, sb integration.Sandbox) { + buildTestProject(t, sb) + cmd := buildxCmd(sb, withArgs("du", "--format=json")) + out, err := cmd.Output() + require.NoError(t, err, string(out)) +} + +func testDiskusageFormatGoTemplate(t *testing.T, sb integration.Sandbox) { + buildTestProject(t, sb) + cmd := buildxCmd(sb, withArgs("du", "--format={{.ID}}: {{.Size}}")) + out, err := cmd.Output() + require.NoError(t, err, string(out)) +} diff --git a/tests/integration_test.go b/tests/integration_test.go index 44375b45a..e77a1a434 100644 --- a/tests/integration_test.go +++ b/tests/integration_test.go @@ -33,6 +33,7 @@ func TestIntegration(t *testing.T) { tests = append(tests, rmTests...) tests = append(tests, dialstdioTests...) tests = append(tests, composeTests...) + tests = append(tests, diskusageTests...) testIntegration(t, tests...) }