diff --git a/cli/command/builder/prune.go b/cli/command/builder/prune.go index af83aa450..2e57aa2fd 100644 --- a/cli/command/builder/prune.go +++ b/cli/command/builder/prune.go @@ -9,13 +9,22 @@ import ( "github.com/docker/cli/cli" "github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command/completion" + "github.com/docker/cli/cli/command/system/pruner" "github.com/docker/cli/internal/prompt" "github.com/docker/cli/opts" "github.com/docker/go-units" "github.com/moby/moby/api/types/build" + "github.com/moby/moby/api/types/versions" "github.com/spf13/cobra" ) +func init() { + // Register the prune command to run as part of "docker system prune" + if err := pruner.Register(pruner.TypeBuildCache, pruneFn); err != nil { + panic(err) + } +} + type pruneOptions struct { force bool all bool @@ -104,7 +113,30 @@ type cancelledErr struct{ error } func (cancelledErr) Cancelled() {} -// CachePrune executes a prune command for build cache -func CachePrune(ctx context.Context, dockerCli command.Cli, all bool, filter opts.FilterOpt) (uint64, string, error) { - return runPrune(ctx, dockerCli, pruneOptions{force: true, all: all, filter: filter}) +type errNotImplemented struct{ error } + +func (errNotImplemented) NotImplemented() {} + +// pruneFn prunes the build cache for use in "docker system prune" and +// returns the amount of space reclaimed and a detailed output string. +func pruneFn(ctx context.Context, dockerCLI command.Cli, options pruner.PruneOptions) (uint64, string, error) { + if ver := dockerCLI.Client().ClientVersion(); ver != "" && versions.LessThan(ver, "1.31") { + // Not supported on older daemons. + return 0, "", errNotImplemented{errors.New("builder prune requires API version 1.31 or greater")} + } + if !options.Confirmed { + // Dry-run: perform validation and produce confirmation before pruning. + var confirmMsg string + if options.All { + confirmMsg = "all build cache" + } else { + confirmMsg = "unused build cache" + } + return 0, confirmMsg, cancelledErr{errors.New("builder prune has been cancelled")} + } + return runPrune(ctx, dockerCLI, pruneOptions{ + force: true, + all: options.All, + filter: options.Filter, + }) } diff --git a/cli/command/container/prune.go b/cli/command/container/prune.go index d75338718..82c319ccd 100644 --- a/cli/command/container/prune.go +++ b/cli/command/container/prune.go @@ -7,6 +7,7 @@ import ( "github.com/docker/cli/cli" "github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command/completion" + "github.com/docker/cli/cli/command/system/pruner" "github.com/docker/cli/internal/prompt" "github.com/docker/cli/opts" "github.com/docker/go-units" @@ -14,6 +15,13 @@ import ( "github.com/spf13/cobra" ) +func init() { + // Register the prune command to run as part of "docker system prune" + if err := pruner.Register(pruner.TypeContainer, pruneFn); err != nil { + panic(err) + } +} + type pruneOptions struct { force bool filter opts.FilterOpt @@ -85,8 +93,16 @@ type cancelledErr struct{ error } func (cancelledErr) Cancelled() {} -// RunPrune calls the Container Prune API -// This returns the amount of space reclaimed and a detailed output string -func RunPrune(ctx context.Context, dockerCli command.Cli, _ bool, filter opts.FilterOpt) (uint64, string, error) { - return runPrune(ctx, dockerCli, pruneOptions{force: true, filter: filter}) +// pruneFn calls the Container Prune API for use in "docker system prune", +// and returns the amount of space reclaimed and a detailed output string. +func pruneFn(ctx context.Context, dockerCLI command.Cli, options pruner.PruneOptions) (uint64, string, error) { + if !options.Confirmed { + // Dry-run: perform validation and produce confirmation before pruning. + confirmMsg := "all stopped containers" + return 0, confirmMsg, cancelledErr{errors.New("containers prune has been cancelled")} + } + return runPrune(ctx, dockerCLI, pruneOptions{ + force: true, + filter: options.Filter, + }) } diff --git a/cli/command/image/prune.go b/cli/command/image/prune.go index eec9b1c0b..7673a1199 100644 --- a/cli/command/image/prune.go +++ b/cli/command/image/prune.go @@ -9,6 +9,7 @@ import ( "github.com/docker/cli/cli" "github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command/completion" + "github.com/docker/cli/cli/command/system/pruner" "github.com/docker/cli/internal/prompt" "github.com/docker/cli/opts" "github.com/docker/go-units" @@ -16,6 +17,13 @@ import ( "github.com/spf13/cobra" ) +func init() { + // Register the prune command to run as part of "docker system prune" + if err := pruner.Register(pruner.TypeImage, pruneFn); err != nil { + panic(err) + } +} + type pruneOptions struct { force bool all bool @@ -109,8 +117,22 @@ type cancelledErr struct{ error } func (cancelledErr) Cancelled() {} -// RunPrune calls the Image Prune API -// This returns the amount of space reclaimed and a detailed output string -func RunPrune(ctx context.Context, dockerCli command.Cli, all bool, filter opts.FilterOpt) (uint64, string, error) { - return runPrune(ctx, dockerCli, pruneOptions{force: true, all: all, filter: filter}) +// pruneFn calls the Image Prune API for use in "docker system prune", +// and returns the amount of space reclaimed and a detailed output string. +func pruneFn(ctx context.Context, dockerCLI command.Cli, options pruner.PruneOptions) (uint64, string, error) { + if !options.Confirmed { + // Dry-run: perform validation and produce confirmation before pruning. + var confirmMsg string + if options.All { + confirmMsg = "all images without at least one container associated to them" + } else { + confirmMsg = "all dangling images" + } + return 0, confirmMsg, cancelledErr{errors.New("image prune has been cancelled")} + } + return runPrune(ctx, dockerCLI, pruneOptions{ + force: true, + all: options.All, + filter: options.Filter, + }) } diff --git a/cli/command/network/prune.go b/cli/command/network/prune.go index fce7adf3e..b2b230a60 100644 --- a/cli/command/network/prune.go +++ b/cli/command/network/prune.go @@ -7,11 +7,19 @@ import ( "github.com/docker/cli/cli" "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/command/system/pruner" "github.com/docker/cli/internal/prompt" "github.com/docker/cli/opts" "github.com/spf13/cobra" ) +func init() { + // Register the prune command to run as part of "docker system prune" + if err := pruner.Register(pruner.TypeNetwork, pruneFn); err != nil { + panic(err) + } +} + type pruneOptions struct { force bool filter opts.FilterOpt @@ -80,9 +88,17 @@ type cancelledErr struct{ error } func (cancelledErr) Cancelled() {} -// RunPrune calls the Network Prune API -// This returns the amount of space reclaimed and a detailed output string -func RunPrune(ctx context.Context, dockerCli command.Cli, _ bool, filter opts.FilterOpt) (uint64, string, error) { - output, err := runPrune(ctx, dockerCli, pruneOptions{force: true, filter: filter}) +// pruneFn calls the Network Prune API for use in "docker system prune" +// and returns the amount of space reclaimed and a detailed output string. +func pruneFn(ctx context.Context, dockerCLI command.Cli, options pruner.PruneOptions) (uint64, string, error) { + if !options.Confirmed { + // Dry-run: perform validation and produce confirmation before pruning. + confirmMsg := "all networks not used by at least one container" + return 0, confirmMsg, cancelledErr{errors.New("network prune has been cancelled")} + } + output, err := runPrune(ctx, dockerCLI, pruneOptions{ + force: true, + filter: options.Filter, + }) return 0, output, err } diff --git a/cli/command/system/prune.go b/cli/command/system/prune.go index c6eb9f263..c6e77f41e 100644 --- a/cli/command/system/prune.go +++ b/cli/command/system/prune.go @@ -3,33 +3,28 @@ package system import ( "bytes" "context" + "errors" "fmt" "sort" "text/template" + "github.com/containerd/errdefs" "github.com/docker/cli/cli" "github.com/docker/cli/cli/command" - "github.com/docker/cli/cli/command/builder" "github.com/docker/cli/cli/command/completion" - "github.com/docker/cli/cli/command/container" - "github.com/docker/cli/cli/command/image" - "github.com/docker/cli/cli/command/network" - "github.com/docker/cli/cli/command/volume" + "github.com/docker/cli/cli/command/system/pruner" "github.com/docker/cli/internal/prompt" "github.com/docker/cli/opts" "github.com/docker/go-units" "github.com/fvbommel/sortorder" - "github.com/moby/moby/api/types/versions" - "github.com/pkg/errors" "github.com/spf13/cobra" ) type pruneOptions struct { - force bool - all bool - pruneVolumes bool - pruneBuildCache bool - filter opts.FilterOpt + force bool + all bool + pruneVolumes bool + filter opts.FilterOpt } // newPruneCommand creates a new cobra.Command for `docker prune` @@ -41,7 +36,6 @@ func newPruneCommand(dockerCli command.Cli) *cobra.Command { Short: "Remove unused data", Args: cli.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - options.pruneBuildCache = versions.GreaterThanOrEqualTo(dockerCli.Client().ClientVersion(), "1.31") return runPrune(cmd.Context(), dockerCli, options) }, Annotations: map[string]string{"version": "1.25"}, @@ -72,35 +66,44 @@ const confirmationTemplate = `WARNING! This will remove: Are you sure you want to continue?` func runPrune(ctx context.Context, dockerCli command.Cli, options pruneOptions) error { - // TODO version this once "until" filter is supported for volumes - if options.pruneVolumes && options.filter.Value().Contains("until") { - return errors.New(`ERROR: The "until" filter is not supported with "--volumes"`) + // prune requires either force, or a user to confirm after prompting. + confirmed := options.force + + // Validate the given options for each pruner and construct a confirmation-message. + confirmationMessage, err := dryRun(ctx, dockerCli, options) + if err != nil { + return err } - if !options.force { - r, err := prompt.Confirm(ctx, dockerCli.In(), dockerCli.Out(), confirmationMessage(dockerCli, options)) + if !confirmed { + var err error + confirmed, err = prompt.Confirm(ctx, dockerCli.In(), dockerCli.Out(), confirmationMessage) if err != nil { return err } - if !r { + if !confirmed { return cancelledErr{errors.New("system prune has been cancelled")} } } - pruneFuncs := []func(ctx context.Context, dockerCli command.Cli, all bool, filter opts.FilterOpt) (uint64, string, error){ - container.RunPrune, - network.RunPrune, - } - if options.pruneVolumes { - pruneFuncs = append(pruneFuncs, volume.RunPrune) - } - pruneFuncs = append(pruneFuncs, image.RunPrune) - if options.pruneBuildCache { - pruneFuncs = append(pruneFuncs, builder.CachePrune) - } var spaceReclaimed uint64 - for _, pruneFn := range pruneFuncs { - spc, output, err := pruneFn(ctx, dockerCli, options.all, options.filter) - if err != nil { + for contentType, pruneFn := range pruner.List() { + switch contentType { + case pruner.TypeVolume: + if !options.pruneVolumes { + continue + } + case pruner.TypeContainer, pruner.TypeNetwork, pruner.TypeImage, pruner.TypeBuildCache: + // no special handling; keeping the "exhaustive" linter happy. + default: + // other pruners; no special handling; keeping the "exhaustive" linter happy. + } + + spc, output, err := pruneFn(ctx, dockerCli, pruner.PruneOptions{ + Confirmed: confirmed, + All: options.all, + Filter: options.filter, + }) + if err != nil && !errdefs.IsNotImplemented(err) { return err } spaceReclaimed += spc @@ -118,28 +121,42 @@ type cancelledErr struct{ error } func (cancelledErr) Cancelled() {} -// confirmationMessage constructs a confirmation message that depends on the cli options. -func confirmationMessage(dockerCli command.Cli, options pruneOptions) string { - t := template.Must(template.New("confirmation message").Parse(confirmationTemplate)) - - warnings := []string{ - "all stopped containers", - "all networks not used by at least one container", - } - if options.pruneVolumes { - warnings = append(warnings, "all anonymous volumes not used by at least one container") - } - if options.all { - warnings = append(warnings, "all images without at least one container associated to them") - } else { - warnings = append(warnings, "all dangling images") - } - if options.pruneBuildCache { - if options.all { - warnings = append(warnings, "all build cache") - } else { - warnings = append(warnings, "unused build cache") +// dryRun validates the given options for each prune-function and constructs +// a confirmation message that depends on the cli options. +func dryRun(ctx context.Context, dockerCli command.Cli, options pruneOptions) (string, error) { + var ( + errs []error + warnings []string + ) + for contentType, pruneFn := range pruner.List() { + switch contentType { + case pruner.TypeVolume: + if !options.pruneVolumes { + continue + } + case pruner.TypeContainer, pruner.TypeNetwork, pruner.TypeImage, pruner.TypeBuildCache: + // no special handling; keeping the "exhaustive" linter happy. + default: + // other pruners; no special handling; keeping the "exhaustive" linter happy. } + // Always run with "[pruner.PruneOptions.Confirmed] = false" + // to perform validation of the given options and produce + // a confirmation message for the pruner. + _, confirmMsg, err := pruneFn(ctx, dockerCli, pruner.PruneOptions{ + All: options.all, + Filter: options.filter, + }) + // A "canceled" error is expected in dry-run mode; any other error + // must be returned as a "fatal" error. + if err != nil && !errdefs.IsCanceled(err) && !errdefs.IsNotImplemented(err) { + errs = append(errs, err) + } + if confirmMsg != "" { + warnings = append(warnings, confirmMsg) + } + } + if len(errs) > 0 { + return "", errors.Join(errs...) } var filters []string @@ -158,6 +175,7 @@ func confirmationMessage(dockerCli command.Cli, options pruneOptions) string { } var buffer bytes.Buffer - t.Execute(&buffer, map[string][]string{"warnings": warnings, "filters": filters}) - return buffer.String() + t := template.Must(template.New("confirmation message").Parse(confirmationTemplate)) + _ = t.Execute(&buffer, map[string][]string{"warnings": warnings, "filters": filters}) + return buffer.String(), nil } diff --git a/cli/command/system/prune_test.go b/cli/command/system/prune_test.go index ad00a1958..3b47c4d45 100644 --- a/cli/command/system/prune_test.go +++ b/cli/command/system/prune_test.go @@ -13,6 +13,13 @@ import ( "github.com/moby/moby/api/types/network" "gotest.tools/v3/assert" is "gotest.tools/v3/assert/cmp" + + // Make sure pruners are registered for tests (they're included automatically when building). + _ "github.com/docker/cli/cli/command/builder" + _ "github.com/docker/cli/cli/command/container" + _ "github.com/docker/cli/cli/command/image" + _ "github.com/docker/cli/cli/command/network" + _ "github.com/docker/cli/cli/command/volume" ) func TestPrunePromptPre131DoesNotIncludeBuildCache(t *testing.T) { diff --git a/cli/command/system/pruner/pruner.go b/cli/command/system/pruner/pruner.go new file mode 100644 index 000000000..f82857b92 --- /dev/null +++ b/cli/command/system/pruner/pruner.go @@ -0,0 +1,140 @@ +// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16: +//go:build go1.23 + +// Package pruner registers "prune" functions to be included as part of +// "docker system prune". +package pruner + +import ( + "context" + "errors" + "fmt" + "iter" + "maps" + "slices" + + "github.com/docker/cli/cli/command" + "github.com/docker/cli/opts" +) + +// ContentType is an identifier for content that can be pruned. +type ContentType string + +// Pre-defined content-types to prune. Additional types can be registered, +// and will be pruned after the list of pre-defined types. +const ( + TypeContainer ContentType = "container" + TypeNetwork ContentType = "network" + TypeImage ContentType = "image" + TypeVolume ContentType = "volume" + TypeBuildCache ContentType = "buildcache" +) + +// pruneOrder is the order in which ContentType must be pruned. The order +// in which pruning happens is important to make sure that resources are +// released before pruning (e.g., a "container" can use a "network" and +// "volume", so containers must be pruned before networks and volumes). +var pruneOrder = []ContentType{ + TypeContainer, + TypeNetwork, + TypeVolume, + TypeImage, + TypeBuildCache, +} + +// PruneFunc is the signature for prune-functions. The action performed +// depends on the [PruneOptions.Confirmed] field. +// +// - If [PruneOptions.Confirmed] is "false", the PruneFunc must be run +// in "dry-run" mode and return a short description of what content +// will be pruned (for example, "all stopped containers") instead of +// executing the prune. This summary is presented to the user as a +// confirmation message. It may return a [ErrCancelled] to indicate +// the operation was canceled or a [ErrNotImplemented] if the prune +// function is not implemented for the daemon's API version. Any +// other error is considered a validation error for the given options +// (such as a filter that is not supported). +// - If [PruneOptions.Confirmed] is "true", the PruneFunc must execute +// the prune with the given options. +// +// After a successful prune the PruneFunc must return details about the +// content pruned; +// +// - spaceReclaimed is the amount of data removed (in bytes), if any. +// - details is arbitrary information about the content pruned to be +// presented to the user. +// +// [ErrCancelled]: https://pkg.go.dev/github.com/docker/docker@v28.3.3+incompatible/errdefs#ErrCancelled +// [ErrNotImplemented]: https://pkg.go.dev/github.com/docker/docker@v28.3.3+incompatible/errdefs#ErrNotImplemented +type PruneFunc func(ctx context.Context, dockerCLI command.Cli, pruneOpts PruneOptions) (spaceReclaimed uint64, details string, _ error) + +type PruneOptions struct { + // Confirmed indicates whether pruning was confirmed (or "forced") + // by the user. If not set, the PruneFunc must be run in "dry-run" + // mode and return a short description of what content will be pruned + // (for example, "all stopped containers") instead of executing the + // prune. This summary is presented to the user as a confirmation message. + Confirmed bool + All bool // Remove all unused content not just dangling (exact meaning differs per content-type). + Filter opts.FilterOpt +} + +// registered holds a map of PruneFunc functions registered through [Register]. +// It is considered immutable after startup. +var registered map[ContentType]PruneFunc + +// Register registers a [PruneFunc] under the given name to be included in +// "docker system prune". It is designed to be called in an init function +// and is not safe for concurrent use. +// +// For example: +// +// func init() { +// // Register the prune command to run as part of "docker system prune". +// if err := prune.Register(prune.TypeImage, prunerFn); err != nil { +// panic(err) +// } +// } +func Register(name ContentType, pruneFunc PruneFunc) error { + if name == "" { + return errors.New("error registering pruner: invalid prune type: cannot be empty") + } + if pruneFunc == nil { + return errors.New("error registering pruner: prune function is nil for " + string(name)) + } + if registered == nil { + registered = make(map[ContentType]PruneFunc) + } + if _, exists := registered[name]; exists { + return fmt.Errorf("error registering pruner: content-type %s is already registered", name) + } + registered[name] = pruneFunc + return nil +} + +// List iterates over all registered pruners, starting with known pruners +// in their predefined order, followed by any others (sorted alphabetically). +func List() iter.Seq2[ContentType, PruneFunc] { + all := maps.Clone(registered) + ordered := make([]ContentType, 0, len(all)) + for _, ct := range pruneOrder { + if _, ok := all[ct]; ok { + ordered = append(ordered, ct) + delete(all, ct) + } + } + // append any remaining content-types (if any) that may be registered. + if len(all) > 0 { + ordered = append(ordered, slices.Sorted(maps.Keys(all))...) + } + + return func(yield func(ContentType, PruneFunc) bool) { + for _, ct := range ordered { + if fn := registered[ct]; fn != nil { + if !yield(ct, fn) { + return + } + } + } + } +} diff --git a/cli/command/volume/prune.go b/cli/command/volume/prune.go index 1eb3e7b02..dd93aa09b 100644 --- a/cli/command/volume/prune.go +++ b/cli/command/volume/prune.go @@ -8,6 +8,7 @@ import ( "github.com/docker/cli/cli" "github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command/completion" + "github.com/docker/cli/cli/command/system/pruner" "github.com/docker/cli/internal/prompt" "github.com/docker/cli/opts" "github.com/docker/go-units" @@ -15,6 +16,13 @@ import ( "github.com/spf13/cobra" ) +func init() { + // Register the prune command to run as part of "docker system prune" + if err := pruner.Register(pruner.TypeVolume, pruneFn); err != nil { + panic(err) + } +} + type pruneOptions struct { all bool force bool @@ -110,8 +118,22 @@ type cancelledErr struct{ error } func (cancelledErr) Cancelled() {} -// RunPrune calls the Volume Prune API -// This returns the amount of space reclaimed and a detailed output string -func RunPrune(ctx context.Context, dockerCli command.Cli, _ bool, filter opts.FilterOpt) (uint64, string, error) { - return runPrune(ctx, dockerCli, pruneOptions{force: true, filter: filter}) +// pruneFn calls the Volume Prune API for use in "docker system prune", +// and returns the amount of space reclaimed and a detailed output string. +func pruneFn(ctx context.Context, dockerCli command.Cli, options pruner.PruneOptions) (uint64, string, error) { + // TODO version this once "until" filter is supported for volumes + // Ideally, this check wasn't done on the CLI because the list of + // filters that is supported by the daemon may evolve over time. + if options.Filter.Value().Contains("until") { + return 0, "", errors.New(`ERROR: The "until" filter is not supported with "--volumes"`) + } + if !options.Confirmed { + // Dry-run: perform validation and produce confirmation before pruning. + confirmMsg := "all anonymous volumes not used by at least one container" + return 0, confirmMsg, cancelledErr{errors.New("volume prune has been cancelled")} + } + return runPrune(ctx, dockerCli, pruneOptions{ + force: true, + filter: options.Filter, + }) }