api: DownOptions.Images becomes a typed ImagePruneMode, validated upfront

The legal values of DownOptions.Images lived in pkg/compose
(image_pruner.go) while the field itself was a bare string in pkg/api,
and the only validation fired inside ImagesToPrune — after down had
already removed the project's containers, leaving the teardown half
done on a typo. The CLI validated its --rmi flag separately, so only
SDK callers were exposed.

ImagePruneMode and its three values now live in pkg/api next to the
field they constrain (type-aliased in pkg/compose for existing
consumers), down() rejects any other value before touching a single
resource, and the CLI check reuses the same definition. A unit test
runs Down with expectation-free mocks: one daemon call would fail it.

Epic #14074, B.6.

Signed-off-by: Nicolas De Loof <[email protected]>
This commit is contained in:
Nicolas De Loof
2026-10-06 11:31:22 +02:00
committed by Guillaume Lours
parent 42f48072bb
commit 53198ab17a
5 changed files with 66 additions and 15 deletions
+3 -5
View File
@@ -50,10 +50,8 @@ func downCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Backe
Short: "Stop and remove containers, networks",
PreRunE: AdaptCmd(func(ctx context.Context, cmd *cobra.Command, args []string) error {
opts.timeChanged = cmd.Flags().Changed("timeout")
if opts.images != "" {
if opts.images != "all" && opts.images != "local" {
return fmt.Errorf("invalid value for --rmi: %q", opts.images)
}
if !api.ImagePruneMode(opts.images).Valid() {
return fmt.Errorf("invalid value for --rmi: %q (legal values are %q, %q)", opts.images, api.ImagePruneLocal, api.ImagePruneAll)
}
return nil
}),
@@ -97,7 +95,7 @@ func runDown(ctx context.Context, dockerCli command.Cli, backendOptions *Backend
RemoveOrphans: opts.removeOrphans,
Project: project,
Timeout: timeout,
Images: opts.images,
Images: api.ImagePruneMode(opts.images),
Volumes: opts.volumes,
Services: services,
})
+30 -2
View File
@@ -403,6 +403,31 @@ type UpOptions struct {
Start StartOptions
}
// ImagePruneMode controls how aggressively `down` removes the images
// associated with the project.
type ImagePruneMode string
const (
// ImagePruneNone keeps all project images.
ImagePruneNone ImagePruneMode = ""
// ImagePruneLocal removes only the images built locally by Compose
// (no custom tag).
ImagePruneLocal ImagePruneMode = "local"
// ImagePruneAll removes every project-associated image, remote ones
// included.
ImagePruneAll ImagePruneMode = "all"
)
// Valid reports whether the mode is one of the declared values.
func (m ImagePruneMode) Valid() bool {
switch m {
case ImagePruneNone, ImagePruneLocal, ImagePruneAll:
return true
default:
return false
}
}
// DownOptions group options of the Down API
type DownOptions struct {
// RemoveOrphans will cleanup containers that are not declared on the compose model but own the same labels
@@ -411,8 +436,11 @@ type DownOptions struct {
Project *types.Project
// Timeout override container stop timeout
Timeout *time.Duration
// Images remove image used by services. 'all': Remove all images. 'local': Remove only images that don't have a tag
Images string
// Images removes images used by the services: ImagePruneAll removes
// every project image, ImagePruneLocal only those without a custom tag,
// ImagePruneNone (the zero value) keeps them all. Down rejects any other
// value before touching a single resource.
Images ImagePruneMode
// Volumes remove volumes, both declared in the `volumes` section and anonymous ones
Volumes bool
// Services passed in the command line to be stopped
+8 -2
View File
@@ -46,6 +46,12 @@ func (s *composeService) Down(ctx context.Context, projectName string, options a
}
func (s *composeService) down(ctx context.Context, projectName string, options api.DownOptions) error {
// validate before touching anything: failing on a bad image prune mode
// after containers are already removed would leave the teardown half done
if !options.Images.Valid() {
return fmt.Errorf("invalid image prune mode %q: legal values are %q, %q", options.Images, api.ImagePruneLocal, api.ImagePruneAll)
}
resourceToRemove := false
include := oneOffExclude
@@ -124,7 +130,7 @@ func (s *composeService) down(ctx context.Context, projectName string, options a
ops := s.ensureNetworksDown(ctx, project, limiter)
ops = append(ops, s.ensureRelayLinkNetworksDown(ctx, project, limiter)...)
if options.Images != "" {
if options.Images != api.ImagePruneNone {
ops = append(ops, s.ensureImagesDown(ctx, project, options, limiter)...)
}
@@ -171,7 +177,7 @@ func (s *composeService) ensureVolumesDown(ctx context.Context, project *types.P
func (s *composeService) ensureImagesDown(ctx context.Context, project *types.Project, options api.DownOptions, limiter *semaphore.Weighted) []downOp {
pruneOpts := ImagePruneOptions{
Mode: ImagePruneMode(options.Images),
Mode: options.Images,
RemoveOrphans: options.RemoveOrphans,
}
+18
View File
@@ -41,6 +41,24 @@ import (
"github.com/docker/compose/v5/pkg/mocks"
)
// An invalid image prune mode must be rejected before any resource is
// touched: the mocks carry no expectation, so a single daemon call would
// fail the test. Guards the down() precondition — validating mid-down,
// after containers were removed, would leave the teardown half done.
func TestDownRejectsInvalidImagePruneModeUpfront(t *testing.T) {
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
_, cli := prepareMocks(mockCtrl)
tested, err := NewComposeService(cli)
assert.NilError(t, err)
err = tested.Down(t.Context(), strings.ToLower(testProject), compose.DownOptions{
Images: "bogus",
})
assert.ErrorContains(t, err, `invalid image prune mode "bogus"`)
}
func TestDown(t *testing.T) {
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
+7 -6
View File
@@ -34,19 +34,20 @@ import (
"github.com/docker/compose/v5/pkg/api"
)
// ImagePruneMode controls how aggressively images associated with the project
// are removed from the engine.
type ImagePruneMode string
// ImagePruneMode aliases the pkg/api definition: the legal values of
// DownOptions.Images belong to the SDK surface, kept aliased here for the
// existing consumers of this package.
type ImagePruneMode = api.ImagePruneMode
const (
// ImagePruneNone indicates that no project images should be removed.
ImagePruneNone ImagePruneMode = ""
ImagePruneNone = api.ImagePruneNone
// ImagePruneLocal indicates that only images built locally by Compose
// should be removed.
ImagePruneLocal ImagePruneMode = "local"
ImagePruneLocal = api.ImagePruneLocal
// ImagePruneAll indicates that all project-associated images, including
// remote images should be removed.
ImagePruneAll ImagePruneMode = "all"
ImagePruneAll = api.ImagePruneAll
)
// ImagePruneOptions controls the behavior of image pruning.