Repository: docker/compose-cli
Pull request #1618 (2021-04-30 06:09:10)

down --volume to remove volume
**What I did**
implement "down --volumes" to cleanup compose application named volumes

**Related issue**
close https://github.com/docker/compose-cli/issues/1553

**(not mandatory) A picture of a cute animal, if possible in relation with what you did**



Patch:
diff --git a/local/compose/create.go b/local/compose/create.go
index 56b8adad..6deefa78 100644
--- a/local/compose/create.go
+++ b/local/compose/create.go
@@ -938,7 +938,7 @@ func (s *composeService) ensureNetwork(ctx context.Context, n types.NetworkConfi
 	return nil
 }
 
-func (s *composeService) ensureNetworkDown(ctx context.Context, networkID string, networkName string) error {
+func (s *composeService) removeNetwork(ctx context.Context, networkID string, networkName string) error {
 	w := progress.ContextWriter(ctx)
 	eventName := fmt.Sprintf("Network %s", networkName)
 	w.Event(progress.RemovingEvent(eventName))
diff --git a/local/compose/down.go b/local/compose/down.go
index 98184733..73582c36 100644
--- a/local/compose/down.go
+++ b/local/compose/down.go
@@ -33,6 +33,8 @@ import (
 	"github.com/docker/compose-cli/api/progress"
 )
 
+type downOp func() error
+
 func (s *composeService) Down(ctx context.Context, projectName string, options compose.DownOptions) error {
 	w := progress.ContextWriter(ctx)
 	resourceToRemove := false
@@ -73,37 +75,76 @@ func (s *composeService) Down(ctx context.Context, projectName string, options c
 		}
 	}
 
-	networks, err := s.apiClient.NetworkList(ctx, moby.NetworkListOptions{Filters: filters.NewArgs(projectFilter(projectName))})
+	ops, err := s.ensureNetwoksDown(ctx, projectName)
 	if err != nil {
 		return err
 	}
 
-	eg, _ := errgroup.WithContext(ctx)
-	for _, n := range networks {
-		resourceToRemove = true
-		networkID := n.ID
-		networkName := n.Name
-		eg.Go(func() error {
-			return s.ensureNetworkDown(ctx, networkID, networkName)
-		})
+	if options.Images != "" {
+		ops = append(ops, s.ensureImagesDown(ctx, projectName, options, w)...)
 	}
 
-	if options.Images != "" {
-		for image := range s.getServiceImages(options, projectName) {
-			image := image
-			eg.Go(func() error {
-				resourceToRemove = true
-				return s.removeImage(ctx, image, w)
-			})
+	if options.Volumes {
+		rm, err := s.ensureVolumesDown(ctx, projectName, w)
+		if err != nil {
+			return err
 		}
+		ops = append(ops, rm...)
 	}
 
-	if !resourceToRemove {
+	if !resourceToRemove && len(ops) == 0 {
 		w.Event(progress.NewEvent(projectName, progress.Done, "Warning: No resource found to remove"))
 	}
+
+	eg, _ := errgroup.WithContext(ctx)
+	for _, op := range ops {
+		eg.Go(op)
+	}
 	return eg.Wait()
 }
 
+func (s *composeService) ensureVolumesDown(ctx context.Context, projectName string, w progress.Writer) ([]downOp, error) {
+	var ops []downOp
+	volumes, err := s.apiClient.VolumeList(ctx, filters.NewArgs(projectFilter(projectName)))
+	if err != nil {
+		return ops, err
+	}
+	for _, vol := range volumes.Volumes {
+		id := vol.Name
+		ops = append(ops, func() error {
+			return s.removeVolume(ctx, id, w)
+		})
+	}
+	return ops, nil
+}
+
+func (s *composeService) ensureImagesDown(ctx context.Context, projectName string, options compose.DownOptions, w progress.Writer) []downOp {
+	var ops []downOp
+	for image := range s.getServiceImages(options, projectName) {
+		image := image
+		ops = append(ops, func() error {
+			return s.removeImage(ctx, image, w)
+		})
+	}
+	return ops
+}
+
+func (s *composeService) ensureNetwoksDown(ctx context.Context, projectName string) ([]downOp, error) {
+	var ops []downOp
+	networks, err := s.apiClient.NetworkList(ctx, moby.NetworkListOptions{Filters: filters.NewArgs(projectFilter(projectName))})
+	if err != nil {
+		return ops, err
+	}
+	for _, n := range networks {
+		networkID := n.ID
+		networkName := n.Name
+		ops = append(ops, func() error {
+			return s.removeNetwork(ctx, networkID, networkName)
+		})
+	}
+	return ops, nil
+}
+
 func (s *composeService) getServiceImages(options compose.DownOptions, projectName string) map[string]struct{} {
 	images := map[string]struct{}{}
 	for _, service := range options.Project.Services {
@@ -134,6 +175,21 @@ func (s *composeService) removeImage(ctx context.Context, image string, w progre
 	return err
 }
 
+func (s *composeService) removeVolume(ctx context.Context, id string, w progress.Writer) error {
+	resource := fmt.Sprintf("Volume %s", id)
+	w.Event(progress.NewEvent(resource, progress.Working, "Removing"))
+	err := s.apiClient.VolumeRemove(ctx, id, true)
+	if err == nil {
+		w.Event(progress.NewEvent(resource, progress.Done, "Removed"))
+		return nil
+	}
+	if errdefs.IsNotFound(err) {
+		w.Event(progress.NewEvent(resource, progress.Done, "Warning: No resource found to remove"))
+		return nil
+	}
+	return err
+}
+
 func (s *composeService) stopContainers(ctx context.Context, w progress.Writer, containers []moby.Container, timeout *time.Duration) error {
 	for _, container := range containers {
 		toStop := container


Test patch:
diff --git a/local/compose/down_test.go b/local/compose/down_test.go
index 00282b02..b4bc37b0 100644
--- a/local/compose/down_test.go
+++ b/local/compose/down_test.go
@@ -20,12 +20,12 @@ import (
 	"context"
 	"testing"
 
-	"github.com/docker/docker/api/types/filters"
-
 	"github.com/docker/compose-cli/api/compose"
 	"github.com/docker/compose-cli/local/mocks"
 
 	apitypes "github.com/docker/docker/api/types"
+	"github.com/docker/docker/api/types/filters"
+	"github.com/docker/docker/api/types/volume"
 	"github.com/golang/mock/gomock"
 	"gotest.tools/v3/assert"
 )
@@ -79,3 +79,24 @@ func TestDownRemoveOrphans(t *testing.T) {
 	err := tested.Down(context.Background(), testProject, compose.DownOptions{RemoveOrphans: true})
 	assert.NilError(t, err)
 }
+
+func TestDownRemoveVolumes(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	defer mockCtrl.Finish()
+	api := mocks.NewMockAPIClient(mockCtrl)
+	tested.apiClient = api
+
+	api.EXPECT().ContainerList(gomock.Any(), projectFilterListOpt()).Return(
+		[]apitypes.Container{testContainer("service1", "123")}, nil)
+
+	api.EXPECT().ContainerStop(gomock.Any(), "123", nil).Return(nil)
+	api.EXPECT().ContainerRemove(gomock.Any(), "123", apitypes.ContainerRemoveOptions{Force: true}).Return(nil)
+
+	api.EXPECT().NetworkList(gomock.Any(), apitypes.NetworkListOptions{Filters: filters.NewArgs(projectFilter(testProject))}).Return(nil, nil)
+
+	api.EXPECT().VolumeList(gomock.Any(), filters.NewArgs(projectFilter(testProject))).Return(volume.VolumeListOKBody{Volumes: []*apitypes.Volume{{Name: "myProject_volume"}}}, nil)
+	api.EXPECT().VolumeRemove(gomock.Any(), "myProject_volume", true).Return(nil)
+
+	err := tested.Down(context.Background(), testProject, compose.DownOptions{Volumes: true})
+	assert.NilError(t, err)
+}
diff --git a/local/e2e/compose/volumes_test.go b/local/e2e/compose/volumes_test.go
index ecf0bfd8..93557736 100644
--- a/local/e2e/compose/volumes_test.go
+++ b/local/e2e/compose/volumes_test.go
@@ -72,7 +72,9 @@ func TestLocalComposeVolume(t *testing.T) {
 	})
 
 	t.Run("cleanup volume project", func(t *testing.T) {
-		c.RunDockerCmd("compose", "--project-name", projectName, "down")
-		c.RunDockerCmd("volume", "rm", projectName+"_staticVol")
+		c.RunDockerCmd("compose", "--project-name", projectName, "down", "--volumes")
+		res := c.RunDockerCmd("volume", "ls")
+		assert.Assert(t, !strings.Contains(res.Stdout(), projectName+"_staticVol"))
+		assert.Assert(t, !strings.Contains(res.Stdout(), "myvolume"))
 	})
 }