Repository: docker/compose-cli
Pull request #1303 (2021-02-15 12:59:28)

introduce timeout flag on down and stop
**What I did**
introduce timeout flag on down and stop

**Related issue**
https://github.com/docker/compose-cli/issues/1275

**(not mandatory) A picture of a cute animal, if possible in relation with what you did**



Patch:
diff --git a/aci/compose.go b/aci/compose.go
index c7f82aba..7d1780eb 100644
--- a/aci/compose.go
+++ b/aci/compose.go
@@ -64,7 +64,7 @@ func (cs *aciComposeService) Start(ctx context.Context, project *types.Project,
 	return errdefs.ErrNotImplemented
 }
 
-func (cs *aciComposeService) Stop(ctx context.Context, project *types.Project) error {
+func (cs *aciComposeService) Stop(ctx context.Context, project *types.Project, options compose.StopOptions) error {
 	return errdefs.ErrNotImplemented
 }
 
diff --git a/api/client/compose.go b/api/client/compose.go
index 063da208..912fb08b 100644
--- a/api/client/compose.go
+++ b/api/client/compose.go
@@ -48,7 +48,7 @@ func (c *composeService) Start(ctx context.Context, project *types.Project, opti
 	return errdefs.ErrNotImplemented
 }
 
-func (c *composeService) Stop(ctx context.Context, project *types.Project) error {
+func (c *composeService) Stop(ctx context.Context, project *types.Project, options compose.StopOptions) error {
 	return errdefs.ErrNotImplemented
 }
 
diff --git a/api/compose/api.go b/api/compose/api.go
index 65cbbbe7..3d2f39ee 100644
--- a/api/compose/api.go
+++ b/api/compose/api.go
@@ -19,6 +19,7 @@ package compose
 import (
 	"context"
 	"io"
+	"time"
 
 	"github.com/compose-spec/compose-go/types"
 )
@@ -36,7 +37,7 @@ type Service interface {
 	// Start executes the equivalent to a `compose start`
 	Start(ctx context.Context, project *types.Project, options StartOptions) error
 	// Stop executes the equivalent to a `compose stop`
-	Stop(ctx context.Context, project *types.Project) error
+	Stop(ctx context.Context, project *types.Project, options StopOptions) error
 	// Up executes the equivalent to a `compose up`
 	Up(ctx context.Context, project *types.Project, options UpOptions) error
 	// Down executes the equivalent to a `compose down`
@@ -71,6 +72,12 @@ type StartOptions struct {
 	Attach ContainerEventListener
 }
 
+// StopOptions group options of the Stop API
+type StopOptions struct {
+	// Timeout override container stop timeout
+	Timeout *time.Duration
+}
+
 // UpOptions group options of the Up API
 type UpOptions struct {
 	// Detach will create services and return immediately
@@ -83,6 +90,8 @@ type DownOptions struct {
 	RemoveOrphans bool
 	// Project is the compose project used to define this app. Might be nil if user ran `down` just with project name
 	Project *types.Project
+	// Timeout override container stop timeout
+	Timeout *time.Duration
 }
 
 // ConvertOptions group options of the Convert API
diff --git a/cli/cmd/compose/down.go b/cli/cmd/compose/down.go
index ca92e96f..44de0c88 100644
--- a/cli/cmd/compose/down.go
+++ b/cli/cmd/compose/down.go
@@ -18,6 +18,7 @@ package compose
 
 import (
 	"context"
+	"time"
 
 	"github.com/compose-spec/compose-go/types"
 
@@ -32,6 +33,8 @@ import (
 type downOptions struct {
 	*projectOptions
 	removeOrphans bool
+	timeChanged   bool
+	timeout       int
 }
 
 func downCommand(p *projectOptions) *cobra.Command {
@@ -42,11 +45,13 @@ func downCommand(p *projectOptions) *cobra.Command {
 		Use:   "down",
 		Short: "Stop and remove containers, networks",
 		RunE: func(cmd *cobra.Command, args []string) error {
+			opts.timeChanged = cmd.Flags().Changed("timeout")
 			return runDown(cmd.Context(), opts)
 		},
 	}
 	flags := downCmd.Flags()
 	flags.BoolVar(&opts.removeOrphans, "remove-orphans", false, "Remove containers for services not defined in the Compose file.")
+	flags.IntVarP(&opts.timeout, "timeout", "t", 10, "Specify a shutdown timeout in seconds")
 
 	return downCmd
 }
@@ -69,9 +74,15 @@ func runDown(ctx context.Context, opts downOptions) error {
 			name = p.Name
 		}
 
+		var timeout *time.Duration
+		if opts.timeChanged {
+			timeoutValue := time.Duration(opts.timeout) * time.Second
+			timeout = &timeoutValue
+		}
 		return name, c.ComposeService().Down(ctx, name, compose.DownOptions{
 			RemoveOrphans: opts.removeOrphans,
 			Project:       project,
+			Timeout:       timeout,
 		})
 	})
 	return err
diff --git a/cli/cmd/compose/remove.go b/cli/cmd/compose/remove.go
index af504013..02a0e0b8 100644
--- a/cli/cmd/compose/remove.go
+++ b/cli/cmd/compose/remove.go
@@ -73,7 +73,7 @@ func runRemove(ctx context.Context, opts removeOptions, services []string) error
 
 	if opts.stop {
 		_, err = progress.Run(ctx, func(ctx context.Context) (string, error) {
-			err := c.ComposeService().Stop(ctx, project)
+			err := c.ComposeService().Stop(ctx, project, compose.StopOptions{})
 			return "", err
 		})
 		if err != nil {
diff --git a/cli/cmd/compose/stop.go b/cli/cmd/compose/stop.go
index f3bb29d3..a0c87007 100644
--- a/cli/cmd/compose/stop.go
+++ b/cli/cmd/compose/stop.go
@@ -18,29 +18,37 @@ package compose
 
 import (
 	"context"
+	"time"
 
 	"github.com/spf13/cobra"
 
 	"github.com/docker/compose-cli/api/client"
+	"github.com/docker/compose-cli/api/compose"
 	"github.com/docker/compose-cli/api/progress"
 )
 
 type stopOptions struct {
 	*projectOptions
+	timeChanged bool
+	timeout     int
 }
 
 func stopCommand(p *projectOptions) *cobra.Command {
 	opts := stopOptions{
 		projectOptions: p,
 	}
-	stopCmd := &cobra.Command{
+	cmd := &cobra.Command{
 		Use:   "stop [SERVICE...]",
 		Short: "Stop services",
 		RunE: func(cmd *cobra.Command, args []string) error {
+			opts.timeChanged = cmd.Flags().Changed("timeout")
 			return runStop(cmd.Context(), opts, args)
 		},
 	}
-	return stopCmd
+	flags := cmd.Flags()
+	flags.IntVarP(&opts.timeout, "timeout", "t", 10, "Specify a shutdown timeout in seconds")
+
+	return cmd
 }
 
 func runStop(ctx context.Context, opts stopOptions, services []string) error {
@@ -54,8 +62,15 @@ func runStop(ctx context.Context, opts stopOptions, services []string) error {
 		return err
 	}
 
+	var timeout *time.Duration
+	if opts.timeChanged {
+		timeoutValue := time.Duration(opts.timeout) * time.Second
+		timeout = &timeoutValue
+	}
 	_, err = progress.Run(ctx, func(ctx context.Context) (string, error) {
-		return "", c.ComposeService().Stop(ctx, project)
+		return "", c.ComposeService().Stop(ctx, project, compose.StopOptions{
+			Timeout: timeout,
+		})
 	})
 	return err
 }
diff --git a/cli/cmd/compose/up.go b/cli/cmd/compose/up.go
index 4da5e1bc..97358be9 100644
--- a/cli/cmd/compose/up.go
+++ b/cli/cmd/compose/up.go
@@ -192,7 +192,7 @@ func runCreateStart(ctx context.Context, opts upOptions, services []string) erro
 	stopFunc := func() error {
 		ctx := context.Background()
 		_, err := progress.Run(ctx, func(ctx context.Context) (string, error) {
-			return "", c.ComposeService().Stop(ctx, project)
+			return "", c.ComposeService().Stop(ctx, project, compose.StopOptions{})
 		})
 		return err
 	}
diff --git a/ecs/local/compose.go b/ecs/local/compose.go
index 1cd0c9b6..15212e9e 100644
--- a/ecs/local/compose.go
+++ b/ecs/local/compose.go
@@ -57,8 +57,8 @@ func (e ecsLocalSimulation) Start(ctx context.Context, project *types.Project, o
 	return e.compose.Start(ctx, project, options)
 }
 
-func (e ecsLocalSimulation) Stop(ctx context.Context, project *types.Project) error {
-	return e.compose.Stop(ctx, project)
+func (e ecsLocalSimulation) Stop(ctx context.Context, project *types.Project, options compose.StopOptions) error {
+	return e.compose.Stop(ctx, project, options)
 }
 
 func (e ecsLocalSimulation) Up(ctx context.Context, project *types.Project, options compose.UpOptions) error {
diff --git a/ecs/up.go b/ecs/up.go
index f66bca71..343317e5 100644
--- a/ecs/up.go
+++ b/ecs/up.go
@@ -51,7 +51,7 @@ func (b *ecsAPIService) Start(ctx context.Context, project *types.Project, optio
 	return errdefs.ErrNotImplemented
 }
 
-func (b *ecsAPIService) Stop(ctx context.Context, project *types.Project) error {
+func (b *ecsAPIService) Stop(ctx context.Context, project *types.Project, options compose.StopOptions) error {
 	return errdefs.ErrNotImplemented
 }
 
diff --git a/kube/compose.go b/kube/compose.go
index 77f1ecee..62b8615a 100644
--- a/kube/compose.go
+++ b/kube/compose.go
@@ -149,7 +149,7 @@ func (s *composeService) Start(ctx context.Context, project *types.Project, opti
 }
 
 // Stop executes the equivalent to a `compose stop`
-func (s *composeService) Stop(ctx context.Context, project *types.Project) error {
+func (s *composeService) Stop(ctx context.Context, project *types.Project, options compose.StopOptions) error {
 	return errdefs.ErrNotImplemented
 }
 
diff --git a/local/compose/create.go b/local/compose/create.go
index 01bec5ed..5b0e121b 100644
--- a/local/compose/create.go
+++ b/local/compose/create.go
@@ -80,7 +80,7 @@ func (s *composeService) Create(ctx context.Context, project *types.Project, opt
 	if len(orphans) > 0 {
 		if opts.RemoveOrphans {
 			w := progress.ContextWriter(ctx)
-			err := s.removeContainers(ctx, w, orphans)
+			err := s.removeContainers(ctx, w, orphans, nil)
 			if err != nil {
 				return err
 			}
diff --git a/local/compose/down.go b/local/compose/down.go
index 6e7cc1e9..d3b1048c 100644
--- a/local/compose/down.go
+++ b/local/compose/down.go
@@ -20,6 +20,7 @@ import (
 	"context"
 	"path/filepath"
 	"strings"
+	"time"
 
 	"github.com/docker/compose-cli/api/compose"
 
@@ -58,7 +59,7 @@ func (s *composeService) Down(ctx context.Context, projectName string, options c
 
 	err = InReverseDependencyOrder(ctx, options.Project, func(c context.Context, service types.ServiceConfig) error {
 		serviceContainers := containers.filter(isService(service.Name))
-		err := s.removeContainers(ctx, w, serviceContainers)
+		err := s.removeContainers(ctx, w, serviceContainers, options.Timeout)
 		return err
 	})
 	if err != nil {
@@ -67,7 +68,7 @@ func (s *composeService) Down(ctx context.Context, projectName string, options c
 
 	orphans := containers.filter(isNotService(options.Project.ServiceNames()...))
 	if options.RemoveOrphans && len(orphans) > 0 {
-		err := s.removeContainers(ctx, w, orphans)
+		err := s.removeContainers(ctx, w, orphans, options.Timeout)
 		if err != nil {
 			return err
 		}
@@ -93,12 +94,12 @@ func (s *composeService) Down(ctx context.Context, projectName string, options c
 	return eg.Wait()
 }
 
-func (s *composeService) stopContainers(ctx context.Context, w progress.Writer, containers []moby.Container) error {
+func (s *composeService) stopContainers(ctx context.Context, w progress.Writer, containers []moby.Container, timeout *time.Duration) error {
 	for _, container := range containers {
 		toStop := container
 		eventName := getContainerProgressName(toStop)
 		w.Event(progress.StoppingEvent(eventName))
-		err := s.apiClient.ContainerStop(ctx, toStop.ID, nil)
+		err := s.apiClient.ContainerStop(ctx, toStop.ID, timeout)
 		if err != nil {
 			w.Event(progress.ErrorMessageEvent(eventName, "Error while Stopping"))
 			return err
@@ -108,14 +109,14 @@ func (s *composeService) stopContainers(ctx context.Context, w progress.Writer,
 	return nil
 }
 
-func (s *composeService) removeContainers(ctx context.Context, w progress.Writer, containers []moby.Container) error {
+func (s *composeService) removeContainers(ctx context.Context, w progress.Writer, containers []moby.Container, timeout *time.Duration) error {
 	eg, _ := errgroup.WithContext(ctx)
 	for _, container := range containers {
 		toDelete := container
 		eg.Go(func() error {
 			eventName := getContainerProgressName(toDelete)
 			w.Event(progress.StoppingEvent(eventName))
-			err := s.stopContainers(ctx, w, []moby.Container{toDelete})
+			err := s.stopContainers(ctx, w, []moby.Container{toDelete}, timeout)
 			if err != nil {
 				w.Event(progress.ErrorMessageEvent(eventName, "Error while Stopping"))
 				return err
diff --git a/local/compose/stop.go b/local/compose/stop.go
index b1436f33..5ecc5016 100644
--- a/local/compose/stop.go
+++ b/local/compose/stop.go
@@ -19,16 +19,16 @@ package compose
 import (
 	"context"
 
+	"github.com/docker/compose-cli/api/compose"
+	"github.com/docker/compose-cli/api/progress"
+
 	"github.com/compose-spec/compose-go/types"
 	moby "github.com/docker/docker/api/types"
 	"github.com/docker/docker/api/types/filters"
-
-	"github.com/docker/compose-cli/api/progress"
 )
 
-func (s *composeService) Stop(ctx context.Context, project *types.Project) error {
+func (s *composeService) Stop(ctx context.Context, project *types.Project, options compose.StopOptions) error {
 	w := progress.ContextWriter(ctx)
-
 	var containers Containers
 	containers, err := s.apiClient.ContainerList(ctx, moby.ContainerListOptions{
 		Filters: filters.NewArgs(projectFilter(project.Name)),
@@ -41,6 +41,6 @@ func (s *composeService) Stop(ctx context.Context, project *types.Project) error
 	containers = containers.filter(isService(project.ServiceNames()...))
 
 	return InReverseDependencyOrder(ctx, project, func(c context.Context, service types.ServiceConfig) error {
-		return s.stopContainers(ctx, w, containers.filter(isService(service.Name)))
+		return s.stopContainers(ctx, w, containers.filter(isService(service.Name)), options.Timeout)
 	})
 }


Test patch:
diff --git a/local/compose/down_test.go b/local/compose/down_test.go
index 7ebf0af9..64ed23aa 100644
--- a/local/compose/down_test.go
+++ b/local/compose/down_test.go
@@ -20,14 +20,13 @@ import (
 	"context"
 	"testing"
 
-	"github.com/golang/mock/gomock"
-	"gotest.tools/v3/assert"
+	"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/compose-cli/api/compose"
-	"github.com/docker/compose-cli/local/mocks"
+	"github.com/golang/mock/gomock"
+	"gotest.tools/v3/assert"
 )
 
 func TestDown(t *testing.T) {
diff --git a/local/compose/stop_test.go b/local/compose/stop_test.go
new file mode 100644
index 00000000..1afce2d4
--- /dev/null
+++ b/local/compose/stop_test.go
@@ -0,0 +1,62 @@
+/*
+   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 (
+	"context"
+	"testing"
+	"time"
+
+	"github.com/docker/compose-cli/api/compose"
+	"github.com/docker/compose-cli/local/mocks"
+	moby "github.com/docker/docker/api/types"
+
+	"github.com/compose-spec/compose-go/types"
+	"github.com/golang/mock/gomock"
+	"gotest.tools/v3/assert"
+)
+
+func TestStopTimeout(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+	defer mockCtrl.Finish()
+	api := mocks.NewMockAPIClient(mockCtrl)
+	tested.apiClient = api
+
+	ctx := context.Background()
+	api.EXPECT().ContainerList(ctx, projectFilterListOpt(testProject)).Return(
+		[]moby.Container{
+			testContainer("service1", "123"),
+			testContainer("service1", "456"),
+			testContainer("service2", "789"),
+		}, nil)
+
+	timeout := time.Duration(2) * time.Second
+	api.EXPECT().ContainerStop(ctx, "123", &timeout).Return(nil)
+	api.EXPECT().ContainerStop(ctx, "456", &timeout).Return(nil)
+	api.EXPECT().ContainerStop(ctx, "789", &timeout).Return(nil)
+
+	err := tested.Stop(ctx, &types.Project{
+		Name: testProject,
+		Services: []types.ServiceConfig{
+			{Name: "service1"},
+			{Name: "service2"},
+		},
+	}, compose.StopOptions{
+		Timeout: &timeout,
+	})
+	assert.NilError(t, err)
+}