diff --git a/cli/command/builder/prune.go b/cli/command/builder/prune.go index 98bc793059..3748228e6d 100644 --- a/cli/command/builder/prune.go +++ b/cli/command/builder/prune.go @@ -122,6 +122,16 @@ func CachePrune(ctx context.Context, dockerCli command.Cli, all bool, filter opt // 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 !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, diff --git a/cli/command/container/prune.go b/cli/command/container/prune.go index 3903d08cd4..7b03c15338 100644 --- a/cli/command/container/prune.go +++ b/cli/command/container/prune.go @@ -104,6 +104,11 @@ func RunPrune(ctx context.Context, dockerCli command.Cli, _ bool, filter opts.Fi // 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 e0411f5ddd..f4b7476e12 100644 --- a/cli/command/image/prune.go +++ b/cli/command/image/prune.go @@ -125,9 +125,19 @@ func RunPrune(ctx context.Context, dockerCli command.Cli, all bool, filter opts. return runPrune(ctx, dockerCli, pruneOptions{force: true, all: all, filter: filter}) } -// pruneFn calls the Container Prune API for use in "docker system prune", +// 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, diff --git a/cli/command/network/prune.go b/cli/command/network/prune.go index 123c11ebf4..f388ac9669 100644 --- a/cli/command/network/prune.go +++ b/cli/command/network/prune.go @@ -100,6 +100,11 @@ func RunPrune(ctx context.Context, dockerCli command.Cli, _ bool, filter opts.Fi // 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, diff --git a/cli/command/system/prune.go b/cli/command/system/prune.go index 828b0c4d26..f067724178 100644 --- a/cli/command/system/prune.go +++ b/cli/command/system/prune.go @@ -3,10 +3,12 @@ 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/completion" @@ -16,7 +18,6 @@ import ( "github.com/docker/go-units" "github.com/fvbommel/sortorder" "github.com/moby/moby/api/types/versions" - "github.com/pkg/errors" "github.com/spf13/cobra" ) @@ -68,16 +69,21 @@ 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")} } } @@ -100,8 +106,9 @@ func runPrune(ctx context.Context, dockerCli command.Cli, options pruneOptions) } spc, output, err := pruneFn(ctx, dockerCli, pruner.PruneOptions{ - All: options.all, - Filter: options.filter, + Confirmed: confirmed, + All: options.all, + Filter: options.filter, }) if err != nil { return err @@ -121,28 +128,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.TypeBuildCache: + if !options.pruneBuildCache { + continue + } } + // 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) { + errs = append(errs, err) + } + if confirmMsg != "" { + warnings = append(warnings, confirmMsg) + } + } + if len(errs) > 0 { + return "", errors.Join(errs...) } var filters []string @@ -161,6 +182,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/pruner/pruner.go b/cli/command/system/pruner/pruner.go index f3701430dc..e8a4e6fcd5 100644 --- a/cli/command/system/pruner/pruner.go +++ b/cli/command/system/pruner/pruner.go @@ -42,16 +42,39 @@ var pruneOrder = []ContentType{ TypeBuildCache, } -// PruneFunc is the signature for prune-functions. It returns details about -// the content pruned; +// PruneFunc is the signature for prune-functions. The action performed +// depends on the [PruneOptions.Confirmed] field. // -// - spaceReclaimed is the amount of data removed (in bytes). -// - details is arbitrary information about the content pruned. +// - 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. Any other error is considered a +// validation error of 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 type PruneFunc func(ctx context.Context, dockerCLI command.Cli, pruneOpts PruneOptions) (spaceReclaimed uint64, details string, _ error) type PruneOptions struct { - All bool - Filter opts.FilterOpt + // 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]. diff --git a/cli/command/volume/prune.go b/cli/command/volume/prune.go index e617529f03..d80eaebd0b 100644 --- a/cli/command/volume/prune.go +++ b/cli/command/volume/prune.go @@ -129,6 +129,17 @@ func RunPrune(ctx context.Context, dockerCli command.Cli, _ bool, filter opts.Fi // 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,