Merge pull request #6236 from thaJeztah/system_prune_register

system prune: refactor to use "register" functions
This commit is contained in:
Sebastiaan van Stijn
2025-08-05 23:25:51 +02:00
committed by GitHub
8 changed files with 348 additions and 75 deletions

View File

@ -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,
})
}

View File

@ -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,
})
}

View File

@ -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,
})
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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) {

View File

@ -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
}
}
}
}
}

View File

@ -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,
})
}