diff --git a/cmd/compose/down.go b/cmd/compose/down.go index d74c8175292..571cfc98c96 100644 --- a/cmd/compose/down.go +++ b/cmd/compose/down.go @@ -22,6 +22,8 @@ import ( "os" "time" + composecli "github.com/compose-spec/compose-go/v2/cli" + "github.com/compose-spec/compose-go/v2/types" "github.com/docker/cli/cli/command" "github.com/sirupsen/logrus" "github.com/spf13/cobra" @@ -34,6 +36,7 @@ import ( type downOptions struct { *ProjectOptions + all bool removeOrphans bool timeChanged bool timeout int @@ -48,6 +51,24 @@ func downCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Backe downCmd := &cobra.Command{ Use: "down [OPTIONS] [SERVICES]", Short: "Stop and remove containers, networks", + Long: `Stops containers and removes containers, networks, volumes, and images created by up. + +By default, the only things removed are: + +- Containers for services defined in the Compose file. +- Networks defined in the networks section of the Compose file. +- The default network, if one is used. + +Networks and volumes defined as external are never removed. + +Anonymous volumes are not removed by default. However, as they don't have a stable name, they are not automatically +mounted by a subsequent up. For data that needs to persist between updates, use explicit paths as bind mounts or +named volumes. + +Use --all to remove every resource for the project, including services from inactive profiles and orphan containers.`, + Example: `docker compose down +docker compose down -v --remove-orphans +docker compose down --all -v`, PreRunE: AdaptCmd(func(ctx context.Context, cmd *cobra.Command, args []string) error { opts.timeChanged = cmd.Flags().Changed("timeout") if opts.images != "" { @@ -55,6 +76,9 @@ func downCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Backe return fmt.Errorf("invalid value for --rmi: %q", opts.images) } } + if opts.all && len(args) > 0 { + return fmt.Errorf("cannot combine --all with service arguments") + } return nil }), RunE: Adapt(func(ctx context.Context, args []string) error { @@ -63,6 +87,7 @@ func downCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Backe ValidArgsFunction: completeServiceNames(dockerCli, p), } flags := downCmd.Flags() + flags.BoolVar(&opts.all, "all", false, "Remove all resources for the project, including inactive profile services and orphan containers") removeOrphans := utils.StringToBool(os.Getenv(ComposeRemoveOrphans)) flags.BoolVar(&opts.removeOrphans, "remove-orphans", removeOrphans, "Remove containers for services not defined in the Compose file") flags.IntVarP(&opts.timeout, "timeout", "t", 0, "Specify a shutdown timeout in seconds") @@ -79,7 +104,12 @@ func downCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Backe } func runDown(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, opts downOptions, services []string) error { - project, name, err := opts.projectOrName(ctx, dockerCli, services...) + backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...) + if err != nil { + return err + } + + project, name, err := getDownProjectOrName(ctx, dockerCli, backend, opts, services) if err != nil { return err } @@ -89,11 +119,9 @@ func runDown(ctx context.Context, dockerCli command.Cli, backendOptions *Backend timeoutValue := time.Duration(opts.timeout) * time.Second timeout = &timeoutValue } - backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...) - if err != nil { - return err - } + return backend.Down(ctx, name, api.DownOptions{ + All: opts.all, RemoveOrphans: opts.removeOrphans, Project: project, Timeout: timeout, @@ -102,3 +130,32 @@ func runDown(ctx context.Context, dockerCli command.Cli, backendOptions *Backend Services: services, }) } + +func getDownProjectOrName(ctx context.Context, dockerCli command.Cli, backend api.Compose, opts downOptions, services []string) (*types.Project, string, error) { + if !opts.all { + return opts.projectOrName(ctx, dockerCli, services...) + } + + allProjectOpts := *opts.ProjectOptions + allProjectOpts.Profiles = []string{"*"} + allProjectOpts.All = true + + project, _, err := allProjectOpts.ToProject(ctx, dockerCli, backend, nil, composecli.WithDiscardEnvFile, composecli.WithoutEnvironmentResolution) + if err == nil { + return project, project.Name, nil + } + + if len(allProjectOpts.ConfigPaths) > 0 { + return nil, "", err + } + + name := allProjectOpts.ProjectName + if name == "" { + name = os.Getenv(ComposeProjectName) + } + if name != "" { + return nil, name, nil + } + + return nil, "", err +} diff --git a/cmd/compose/down_test.go b/cmd/compose/down_test.go new file mode 100644 index 00000000000..2eea9e95401 --- /dev/null +++ b/cmd/compose/down_test.go @@ -0,0 +1,31 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package compose + +import ( + "testing" + + "gotest.tools/v3/assert" +) + +func TestDownCommandHasAllFlag(t *testing.T) { + cmd := downCommand(&ProjectOptions{}, nil, &BackendOptions{}) + + flag := cmd.Flags().Lookup("all") + assert.Assert(t, flag != nil) + assert.Equal(t, flag.Usage, "Remove all resources for the project, including inactive profile services and orphan containers") +} diff --git a/docs/reference/compose_down.md b/docs/reference/compose_down.md index 2ac0bf2da42..03e09cacffa 100644 --- a/docs/reference/compose_down.md +++ b/docs/reference/compose_down.md @@ -15,10 +15,21 @@ Anonymous volumes are not removed by default. However, as they don’t have a st mounted by a subsequent `up`. For data that needs to persist between updates, use explicit paths as bind mounts or named volumes. +Use `--all` to remove every resource for the project, including services from inactive profiles and orphan containers. + +### Examples + +```console +$ docker compose down +$ docker compose down -v --remove-orphans +$ docker compose down --all -v +``` + ### Options | Name | Type | Default | Description | |:-------------------|:---------|:--------|:------------------------------------------------------------------------------------------------------------------------| +| `--all` | `bool` | | Remove all resources for the project, including inactive profile services and orphan containers | | `--dry-run` | `bool` | | Execute command in dry run mode | | `--remove-orphans` | `bool` | | Remove containers for services not defined in the Compose file | | `--rmi` | `string` | | Remove images used by services. "local" remove only images that don't have a custom tag ("local"\|"all") | @@ -43,3 +54,5 @@ Networks and volumes defined as external are never removed. Anonymous volumes are not removed by default. However, as they don’t have a stable name, they are not automatically mounted by a subsequent `up`. For data that needs to persist between updates, use explicit paths as bind mounts or named volumes. + +Use `--all` to remove every resource for the project, including services from inactive profiles and orphan containers. diff --git a/docs/reference/docker_compose_down.yaml b/docs/reference/docker_compose_down.yaml index 77bf526289b..74b3f4a4921 100644 --- a/docs/reference/docker_compose_down.yaml +++ b/docs/reference/docker_compose_down.yaml @@ -14,10 +14,26 @@ long: |- Anonymous volumes are not removed by default. However, as they don’t have a stable name, they are not automatically mounted by a subsequent `up`. For data that needs to persist between updates, use explicit paths as bind mounts or named volumes. + + Use `--all` to remove every resource for the project, including services from inactive profiles and orphan containers. usage: docker compose down [OPTIONS] [SERVICES] pname: docker compose plink: docker_compose.yaml +examples: |- + docker compose down + docker compose down -v --remove-orphans + docker compose down --all -v options: + - option: all + value_type: bool + default_value: "false" + description: Remove all resources for the project, including inactive profile services and orphan containers + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false - option: remove-orphans value_type: bool default_value: "false" @@ -78,4 +94,3 @@ experimental: false experimentalcli: false kubernetes: false swarm: false - diff --git a/pkg/api/api.go b/pkg/api/api.go index deefc1e52e9..1bb571fb55c 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -339,6 +339,8 @@ type UpOptions struct { // DownOptions group options of the Down API type DownOptions struct { + // All performs a complete project cleanup, including inactive profile services and orphan containers + All bool // RemoveOrphans will cleanup containers that are not declared on the compose model but own the same labels RemoveOrphans bool // Project is the compose project used to define this app. Might be nil if user ran `down` just with project name diff --git a/pkg/compose/down.go b/pkg/compose/down.go index 758b9868a28..cb3a6865d65 100644 --- a/pkg/compose/down.go +++ b/pkg/compose/down.go @@ -44,6 +44,11 @@ func (s *composeService) Down(ctx context.Context, projectName string, options a func (s *composeService) down(ctx context.Context, projectName string, options api.DownOptions) error { //nolint:gocyclo resourceToRemove := false + if options.All { + options.RemoveOrphans = true + options.Services = nil + } + include := oneOffExclude if options.RemoveOrphans { include = oneOffInclude @@ -60,6 +65,12 @@ func (s *composeService) down(ctx context.Context, projectName string, options a return err } } + if options.All { + project, err = project.WithProfiles([]string{"*"}) + if err != nil { + return err + } + } // Check requested services exists in model services, err := checkSelectedServices(options, project) diff --git a/pkg/compose/down_test.go b/pkg/compose/down_test.go index c52f736b1de..4a91ffacc42 100644 --- a/pkg/compose/down_test.go +++ b/pkg/compose/down_test.go @@ -270,6 +270,82 @@ func TestDownRemoveVolumes(t *testing.T) { assert.NilError(t, err) } +func TestDownAllRemovesInactiveProfileServicesAndOrphans(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + api, cli := prepareMocks(mockCtrl) + tested, err := NewComposeService(cli) + assert.NilError(t, err) + + projectName := strings.ToLower(testProject) + project := &types.Project{ + Name: projectName, + Services: types.Services{ + "service1": { + Name: "service1", + }, + }, + DisabledServices: types.Services{ + "service2": { + Name: "service2", + Profiles: []string{"manual"}, + }, + }, + Networks: types.Networks{ + "default": { + Name: fmt.Sprintf("%s_default", projectName), + }, + }, + Volumes: types.Volumes{ + "data": { + Name: fmt.Sprintf("%s_data", projectName), + }, + }, + } + + api.EXPECT().ContainerList(gomock.Any(), projectFilterListOpt(true)).Return( + client.ContainerListResult{ + Items: []container.Summary{ + testContainer("service1", "123", false), + testContainer("service2", "456", false), + testContainer("service_orphan", "321", true), + }, + }, nil) + + stopOptions := client.ContainerStopOptions{} + api.EXPECT().ContainerStop(gomock.Any(), "123", stopOptions).Return(client.ContainerStopResult{}, nil) + api.EXPECT().ContainerStop(gomock.Any(), "456", stopOptions).Return(client.ContainerStopResult{}, nil) + api.EXPECT().ContainerStop(gomock.Any(), "321", stopOptions).Return(client.ContainerStopResult{}, nil) + + api.EXPECT().ContainerRemove(gomock.Any(), "123", client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}).Return(client.ContainerRemoveResult{}, nil) + api.EXPECT().ContainerRemove(gomock.Any(), "456", client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}).Return(client.ContainerRemoveResult{}, nil) + api.EXPECT().ContainerRemove(gomock.Any(), "321", client.ContainerRemoveOptions{Force: true}).Return(client.ContainerRemoveResult{}, nil) + + api.EXPECT().NetworkList(gomock.Any(), client.NetworkListOptions{ + Filters: projectFilter(projectName).Add("label", networkFilter("default")), + }).Return(client.NetworkListResult{ + Items: []network.Summary{{Network: network.Network{ID: "abc123", Name: fmt.Sprintf("%s_default", projectName)}}}, + }, nil) + api.EXPECT().NetworkInspect(gomock.Any(), "abc123", gomock.Any()).Return(client.NetworkInspectResult{ + Network: network.Inspect{Network: network.Network{ID: "abc123"}}, + }, nil) + api.EXPECT().NetworkRemove(gomock.Any(), "abc123", gomock.Any()).Return(client.NetworkRemoveResult{}, nil) + + api.EXPECT().VolumeInspect(gomock.Any(), fmt.Sprintf("%s_data", projectName), gomock.Any()). + Return(client.VolumeInspectResult{}, nil) + api.EXPECT().VolumeRemove(gomock.Any(), fmt.Sprintf("%s_data", projectName), client.VolumeRemoveOptions{Force: true}). + Return(client.VolumeRemoveResult{}, nil) + + err = tested.Down(t.Context(), projectName, compose.DownOptions{ + All: true, + Project: project, + Volumes: true, + Services: []string{"service1"}, + }) + assert.NilError(t, err) +} + func TestDownRemoveImages(t *testing.T) { mockCtrl := gomock.NewController(t) defer mockCtrl.Finish() diff --git a/pkg/e2e/fixtures/down-all/compose.yaml b/pkg/e2e/fixtures/down-all/compose.yaml new file mode 100644 index 00000000000..7f2c361a4c4 --- /dev/null +++ b/pkg/e2e/fixtures/down-all/compose.yaml @@ -0,0 +1,28 @@ +services: + foo: + image: alpine + command: tail -f /dev/null + stop_grace_period: 1s + networks: + - wipe + volumes: + - shared:/data + + bar: + profiles: + - manual + depends_on: + - foo + image: alpine + command: tail -f /dev/null + stop_grace_period: 1s + networks: + - wipe + volumes: + - shared:/data + +volumes: + shared: + +networks: + wipe: diff --git a/pkg/e2e/profiles_test.go b/pkg/e2e/profiles_test.go index dffc209d00e..89c93e9f145 100644 --- a/pkg/e2e/profiles_test.go +++ b/pkg/e2e/profiles_test.go @@ -205,3 +205,39 @@ func TestDotEnvProfileUsage(t *testing.T) { res.Assert(t, icmd.Expected{Out: profiledService}) }) } + +func TestDownAllRemovesInactiveProfileResources(t *testing.T) { + c := NewParallelCLI(t) + const projectName = "compose-e2e-down-all" + const composeFile = "./fixtures/down-all/compose.yaml" + + t.Cleanup(func() { + _ = c.RunDockerComposeCmdNoCheck(t, "-f", composeFile, "-p", projectName, "down", "--all", "-v", "-t", "0") + }) + + c.RunDockerComposeCmd(t, "-f", composeFile, "-p", projectName, "up", "-d", "bar") + + c.RunDockerComposeCmd(t, "-f", composeFile, "-p", projectName, "down", "--remove-orphans", "-v", "-t", "0") + + res := c.RunDockerCmd(t, "ps", "--all", "--format", "{{.Names}}") + assert.Assert(t, !strings.Contains(res.Combined(), projectName+"-foo-1"), res.Combined()) + assert.Assert(t, strings.Contains(res.Combined(), projectName+"-bar-1"), res.Combined()) + + res = c.RunDockerCmd(t, "network", "ls") + assert.Assert(t, strings.Contains(res.Combined(), projectName+"_wipe"), res.Combined()) + + res = c.RunDockerCmd(t, "volume", "ls") + assert.Assert(t, strings.Contains(res.Combined(), projectName+"_shared"), res.Combined()) + + c.RunDockerComposeCmd(t, "-f", composeFile, "-p", projectName, "down", "--all", "-v", "-t", "0") + + res = c.RunDockerCmd(t, "ps", "--all", "--format", "{{.Names}}") + assert.Assert(t, !strings.Contains(res.Combined(), projectName+"-foo-1"), res.Combined()) + assert.Assert(t, !strings.Contains(res.Combined(), projectName+"-bar-1"), res.Combined()) + + res = c.RunDockerCmd(t, "network", "ls") + assert.Assert(t, !strings.Contains(res.Combined(), projectName+"_wipe"), res.Combined()) + + res = c.RunDockerCmd(t, "volume", "ls") + assert.Assert(t, !strings.Contains(res.Combined(), projectName+"_shared"), res.Combined()) +}