From 4de01936f80504d24a4f596062d7d74e0340b334 Mon Sep 17 00:00:00 2001
From: Nicolas De Loof <nicolas.deloof@gmail.com>
Date: Wed, 6 Jan 2021 14:23:37 +0100
Subject: [PATCH] introduce --remove-orphans option

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
---
 aci/compose.go               |  2 +-
 api/client/compose.go        |  2 +-
 api/compose/api.go           |  8 +++-
 cli/cmd/compose/run.go       |  2 +-
 cli/cmd/compose/up.go        | 20 +++++++---
 ecs/local/compose.go         |  4 +-
 ecs/up.go                    |  2 +-
 example/backend.go           |  2 +-
 local/compose/containers.go  | 72 ++++++++++++++++++++++++++++++++++++
 local/compose/convergence.go | 14 +------
 local/compose/create.go      | 42 ++++++++++++++++++---
 local/compose/down.go        | 34 +++--------------
 12 files changed, 145 insertions(+), 59 deletions(-)
 create mode 100644 local/compose/containers.go

diff --git a/aci/compose.go b/aci/compose.go
index b3fe0210b..2820e192f 100644
--- a/aci/compose.go
+++ b/aci/compose.go
@@ -56,7 +56,7 @@ func (cs *aciComposeService) Pull(ctx context.Context, project *types.Project) e
 	return errdefs.ErrNotImplemented
 }
 
-func (cs *aciComposeService) Create(ctx context.Context, project *types.Project) error {
+func (cs *aciComposeService) Create(ctx context.Context, project *types.Project, opts compose.CreateOptions) error {
 	return errdefs.ErrNotImplemented
 }
 
diff --git a/api/client/compose.go b/api/client/compose.go
index 8f4c199a0..0c6211230 100644
--- a/api/client/compose.go
+++ b/api/client/compose.go
@@ -40,7 +40,7 @@ func (c *composeService) Pull(ctx context.Context, project *types.Project) error
 	return errdefs.ErrNotImplemented
 }
 
-func (c *composeService) Create(ctx context.Context, project *types.Project) error {
+func (c *composeService) Create(ctx context.Context, project *types.Project, opts compose.CreateOptions) error {
 	return errdefs.ErrNotImplemented
 }
 
diff --git a/api/compose/api.go b/api/compose/api.go
index 6777594b5..72f3517e4 100644
--- a/api/compose/api.go
+++ b/api/compose/api.go
@@ -32,7 +32,7 @@ type Service interface {
 	// Pull executes the equivalent of a `compose pull`
 	Pull(ctx context.Context, project *types.Project) error
 	// Create executes the equivalent to a `compose create`
-	Create(ctx context.Context, project *types.Project) error
+	Create(ctx context.Context, project *types.Project, opts CreateOptions) error
 	// Start executes the equivalent to a `compose start`
 	Start(ctx context.Context, project *types.Project, consumer LogConsumer) error
 	// Up executes the equivalent to a `compose up`
@@ -51,6 +51,12 @@ type Service interface {
 	RunOneOffContainer(ctx context.Context, project *types.Project, opts RunOptions) error
 }
 
+// CreateOptions group options of the Create API
+type CreateOptions struct {
+	// Remove legacy containers for services that are not defined in the project
+	RemoveOrphans bool
+}
+
 // UpOptions group options of the Up API
 type UpOptions struct {
 	// Detach will create services and return immediately
diff --git a/cli/cmd/compose/run.go b/cli/cmd/compose/run.go
index 9f0140e74..3c019cb12 100644
--- a/cli/cmd/compose/run.go
+++ b/cli/cmd/compose/run.go
@@ -104,7 +104,7 @@ func startDependencies(ctx context.Context, c *client.Client, project *types.Pro
 		}
 	}
 	project.Services = dependencies
-	if err := c.ComposeService().Create(ctx, project); err != nil {
+	if err := c.ComposeService().Create(ctx, project, compose.CreateOptions{}); err != nil {
 		return err
 	}
 	if err := c.ComposeService().Start(ctx, project, nil); err != nil {
diff --git a/cli/cmd/compose/up.go b/cli/cmd/compose/up.go
index b1b0b8189..c84b9dd57 100644
--- a/cli/cmd/compose/up.go
+++ b/cli/cmd/compose/up.go
@@ -33,8 +33,13 @@ import (
 	"github.com/spf13/cobra"
 )
 
+type upOptions struct {
+	composeOptions
+	removeOrphans bool
+}
+
 func upCommand(contextType string) *cobra.Command {
-	opts := composeOptions{}
+	opts := upOptions{}
 	upCmd := &cobra.Command{
 		Use:   "up [SERVICE...]",
 		Short: "Create and start containers",
@@ -53,6 +58,7 @@ func upCommand(contextType string) *cobra.Command {
 	upCmd.Flags().StringArrayVarP(&opts.Environment, "environment", "e", []string{}, "Environment variables")
 	upCmd.Flags().BoolVarP(&opts.Detach, "detach", "d", false, "Detached mode: Run containers in the background")
 	upCmd.Flags().BoolVar(&opts.Build, "build", false, "Build images before starting containers.")
+	upCmd.Flags().BoolVar(&opts.removeOrphans, "remove-orphans", false, "Remove containers for services not defined in the Compose file.")
 
 	if contextType == store.AciContextType {
 		upCmd.Flags().StringVar(&opts.DomainName, "domainname", "", "Container NIS domain name")
@@ -61,8 +67,8 @@ func upCommand(contextType string) *cobra.Command {
 	return upCmd
 }
 
-func runUp(ctx context.Context, opts composeOptions, services []string) error {
-	c, project, err := setup(ctx, opts, services)
+func runUp(ctx context.Context, opts upOptions, services []string) error {
+	c, project, err := setup(ctx, opts.composeOptions, services)
 	if err != nil {
 		return err
 	}
@@ -75,14 +81,16 @@ func runUp(ctx context.Context, opts composeOptions, services []string) error {
 	return err
 }
 
-func runCreateStart(ctx context.Context, opts composeOptions, services []string) error {
-	c, project, err := setup(ctx, opts, services)
+func runCreateStart(ctx context.Context, opts upOptions, services []string) error {
+	c, project, err := setup(ctx, opts.composeOptions, services)
 	if err != nil {
 		return err
 	}
 
 	_, err = progress.Run(ctx, func(ctx context.Context) (string, error) {
-		return "", c.ComposeService().Create(ctx, project)
+		return "", c.ComposeService().Create(ctx, project, compose.CreateOptions{
+			RemoveOrphans: opts.removeOrphans,
+		})
 	})
 	if err != nil {
 		return err
diff --git a/ecs/local/compose.go b/ecs/local/compose.go
index 959f1c587..d5c9728fd 100644
--- a/ecs/local/compose.go
+++ b/ecs/local/compose.go
@@ -43,13 +43,13 @@ func (e ecsLocalSimulation) Pull(ctx context.Context, project *types.Project) er
 	return e.compose.Pull(ctx, project)
 }
 
-func (e ecsLocalSimulation) Create(ctx context.Context, project *types.Project) error {
+func (e ecsLocalSimulation) Create(ctx context.Context, project *types.Project, opts compose.CreateOptions) error {
 	enhanced, err := e.enhanceForLocalSimulation(project)
 	if err != nil {
 		return err
 	}
 
-	return e.compose.Create(ctx, enhanced)
+	return e.compose.Create(ctx, enhanced, opts)
 }
 
 func (e ecsLocalSimulation) Start(ctx context.Context, project *types.Project, consumer compose.LogConsumer) error {
diff --git a/ecs/up.go b/ecs/up.go
index f649f36f6..13b33ab5a 100644
--- a/ecs/up.go
+++ b/ecs/up.go
@@ -43,7 +43,7 @@ func (b *ecsAPIService) Pull(ctx context.Context, project *types.Project) error
 	return errdefs.ErrNotImplemented
 }
 
-func (b *ecsAPIService) Create(ctx context.Context, project *types.Project) error {
+func (b *ecsAPIService) Create(ctx context.Context, project *types.Project, opts compose.CreateOptions) error {
 	return errdefs.ErrNotImplemented
 }
 
diff --git a/example/backend.go b/example/backend.go
index e0c62a6b7..58cf7a6c8 100644
--- a/example/backend.go
+++ b/example/backend.go
@@ -150,7 +150,7 @@ func (cs *composeService) Pull(ctx context.Context, project *types.Project) erro
 	return errdefs.ErrNotImplemented
 }
 
-func (cs *composeService) Create(ctx context.Context, project *types.Project) error {
+func (cs *composeService) Create(ctx context.Context, project *types.Project, opts compose.CreateOptions) error {
 	return errdefs.ErrNotImplemented
 }
 
diff --git a/local/compose/containers.go b/local/compose/containers.go
new file mode 100644
index 000000000..4159045c4
--- /dev/null
+++ b/local/compose/containers.go
@@ -0,0 +1,72 @@
+/*
+   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 moby "github.com/docker/docker/api/types"
+
+// Containers is a set of moby Container
+type Containers []moby.Container
+
+// containerPredicate define a predicate we want container to satisfy for filtering operations
+type containerPredicate func(c moby.Container) bool
+
+func isService(services ...string) containerPredicate {
+	return func(c moby.Container) bool {
+		service := c.Labels[serviceLabel]
+		return contains(services, service)
+	}
+}
+
+func isNotService(services ...string) containerPredicate {
+	return func(c moby.Container) bool {
+		service := c.Labels[serviceLabel]
+		return !contains(services, service)
+	}
+}
+
+// filter return Containers with elements to match predicate
+func (containers Containers) filter(predicate containerPredicate) Containers {
+	var filtered Containers
+	for _, c := range containers {
+		if predicate(c) {
+			filtered = append(filtered, c)
+		}
+	}
+	return filtered
+}
+
+// split return Containers with elements to match and those not to match predicate
+func (containers Containers) split(predicate containerPredicate) (Containers, Containers) {
+	var right Containers
+	var left Containers
+	for _, c := range containers {
+		if predicate(c) {
+			right = append(right, c)
+		} else {
+			left = append(left, c)
+		}
+	}
+	return right, left
+}
+
+func (containers Containers) names() []string {
+	var names []string
+	for _, c := range containers {
+		names = append(names, getContainerName(c))
+	}
+	return names
+}
diff --git a/local/compose/convergence.go b/local/compose/convergence.go
index 8e23d4e9e..4721a8ab0 100644
--- a/local/compose/convergence.go
+++ b/local/compose/convergence.go
@@ -37,19 +37,9 @@ const (
 	forceRecreate = "force_recreate"
 )
 
-func (s *composeService) ensureService(ctx context.Context, project *types.Project, service types.ServiceConfig) error {
-	actual, err := s.apiClient.ContainerList(ctx, moby.ContainerListOptions{
-		Filters: filters.NewArgs(
-			projectFilter(project.Name),
-			serviceFilter(service.Name),
-		),
-		All: true,
-	})
-	if err != nil {
-		return err
-	}
-
+func (s *composeService) ensureService(ctx context.Context, observedState Containers, project *types.Project, service types.ServiceConfig) error {
 	scale := getScale(service)
+	actual := observedState.filter(isService(service.Name))
 
 	eg, _ := errgroup.WithContext(ctx)
 	if len(actual) < scale {
diff --git a/local/compose/create.go b/local/compose/create.go
index b97a8173f..57c174dbf 100644
--- a/local/compose/create.go
+++ b/local/compose/create.go
@@ -23,12 +23,10 @@ import (
 	"strconv"
 	"strings"
 
-	convert "github.com/docker/compose-cli/local/moby"
-	"github.com/docker/compose-cli/progress"
-
 	"github.com/compose-spec/compose-go/types"
 	moby "github.com/docker/docker/api/types"
 	"github.com/docker/docker/api/types/container"
+	"github.com/docker/docker/api/types/filters"
 	"github.com/docker/docker/api/types/mount"
 	"github.com/docker/docker/api/types/network"
 	"github.com/docker/docker/api/types/strslice"
@@ -36,9 +34,15 @@ import (
 	"github.com/docker/docker/errdefs"
 	"github.com/docker/go-connections/nat"
 	"github.com/pkg/errors"
+	"github.com/sirupsen/logrus"
+	"golang.org/x/sync/errgroup"
+
+	"github.com/docker/compose-cli/api/compose"
+	convert "github.com/docker/compose-cli/local/moby"
+	"github.com/docker/compose-cli/progress"
 )
 
-func (s *composeService) Create(ctx context.Context, project *types.Project) error {
+func (s *composeService) Create(ctx context.Context, project *types.Project, opts compose.CreateOptions) error {
 	err := s.ensureImagesExists(ctx, project)
 	if err != nil {
 		return err
@@ -52,8 +56,36 @@ func (s *composeService) Create(ctx context.Context, project *types.Project) err
 		return err
 	}
 
+	var observedState Containers
+	observedState, err = s.apiClient.ContainerList(ctx, moby.ContainerListOptions{
+		Filters: filters.NewArgs(
+			projectFilter(project.Name),
+		),
+		All: true,
+	})
+	if err != nil {
+		return err
+	}
+
+	orphans := observedState.filter(isNotService(project.ServiceNames()...))
+	if len(orphans) > 0 {
+		if opts.RemoveOrphans {
+			eg, _ := errgroup.WithContext(ctx)
+			w := progress.ContextWriter(ctx)
+			s.removeContainers(ctx, w, eg, orphans)
+			if eg.Wait() != nil {
+				return err
+			}
+		} else {
+			logrus.Warnf("Found orphan containers (%s) for this project. If "+
+				"you removed or renamed this service in your compose "+
+				"file, you can run this command with the "+
+				"--remove-orphans flag to clean it up.", orphans.names())
+		}
+	}
+
 	return InDependencyOrder(ctx, project, func(c context.Context, service types.ServiceConfig) error {
-		return s.ensureService(c, project, service)
+		return s.ensureService(c, observedState, project, service)
 	})
 }
 
diff --git a/local/compose/down.go b/local/compose/down.go
index 704f832c0..d522cb51a 100644
--- a/local/compose/down.go
+++ b/local/compose/down.go
@@ -41,7 +41,8 @@ func (s *composeService) Down(ctx context.Context, projectName string, options c
 		return err
 	}
 
-	containers, err := s.apiClient.ContainerList(ctx, moby.ContainerListOptions{
+	var containers Containers
+	containers, err = s.apiClient.ContainerList(ctx, moby.ContainerListOptions{
 		Filters: filters.NewArgs(projectFilter(project.Name)),
 		All:     true,
 	})
@@ -50,14 +51,14 @@ func (s *composeService) Down(ctx context.Context, projectName string, options c
 	}
 
 	err = InReverseDependencyOrder(ctx, project, func(c context.Context, service types.ServiceConfig) error {
-		serviceContainers, others := split(containers, isService(service.Name))
-		err := s.removeContainers(ctx, w, eg, serviceContainers)
+		serviceContainers, others := containers.split(isService(service.Name))
+		s.removeContainers(ctx, w, eg, serviceContainers)
 		containers = others
 		return err
 	})
 
 	if options.RemoveOrphans {
-		err := s.removeContainers(ctx, w, eg, containers)
+		s.removeContainers(ctx, w, eg, containers)
 		if err != nil {
 			return err
 		}
@@ -89,7 +90,7 @@ func (s *composeService) Down(ctx context.Context, projectName string, options c
 	return eg.Wait()
 }
 
-func (s *composeService) removeContainers(ctx context.Context, w progress.Writer, eg *errgroup.Group, containers []moby.Container) error {
+func (s *composeService) removeContainers(ctx context.Context, w progress.Writer, eg *errgroup.Group, containers []moby.Container) {
 	for _, container := range containers {
 		toDelete := container
 		eg.Go(func() error {
@@ -110,7 +111,6 @@ func (s *composeService) removeContainers(ctx context.Context, w progress.Writer
 			return nil
 		})
 	}
-	return nil
 }
 
 func (s *composeService) projectFromContainerLabels(ctx context.Context, projectName string) (*types.Project, error) {
@@ -160,25 +160,3 @@ func loadProjectOptionsFromLabels(c moby.Container) (*cli.ProjectOptions, error)
 		cli.WithWorkingDirectory(c.Labels[workingDirLabel]),
 		cli.WithName(c.Labels[projectLabel]))
 }
-
-type containerPredicate func(c moby.Container) bool
-
-func isService(service string) containerPredicate {
-	return func(c moby.Container) bool {
-		return c.Labels[serviceLabel] == service
-	}
-}
-
-// split return a container slice with elements to match predicate
-func split(containers []moby.Container, predicate containerPredicate) ([]moby.Container, []moby.Container) {
-	var right []moby.Container
-	var left []moby.Container
-	for _, c := range containers {
-		if predicate(c) {
-			right = append(right, c)
-		} else {
-			left = append(left, c)
-		}
-	}
-	return right, left
-}