diff --git a/pkg/compose/containers.go b/pkg/compose/containers.go index 47df374d5..6adf91b95 100644 --- a/pkg/compose/containers.go +++ b/pkg/compose/containers.go @@ -22,6 +22,7 @@ import ( "sort" "strconv" + "github.com/compose-spec/compose-go/types" "github.com/docker/compose/v2/pkg/api" "github.com/docker/compose/v2/pkg/utils" moby "github.com/docker/docker/api/types" @@ -123,6 +124,21 @@ func isNotService(services ...string) containerPredicate { } } +// isOrphaned is a predicate to select containers without a matching service definition in compose project +func isOrphaned(project *types.Project) containerPredicate { + var services []string + for _, s := range project.Services { + services = append(services, s.Name) + } + for _, s := range project.DisabledServices { + services = append(services, s.Name) + } + return func(c moby.Container) bool { + service := c.Labels[api.ServiceLabel] + return !utils.StringContains(services, service) + } +} + func isNotOneOff(c moby.Container) bool { v, ok := c.Labels[api.OneoffLabel] return !ok || v == "False" diff --git a/pkg/compose/down.go b/pkg/compose/down.go index f0d3ddf1c..c720867d3 100644 --- a/pkg/compose/down.go +++ b/pkg/compose/down.go @@ -83,7 +83,7 @@ func (s *composeService) down(ctx context.Context, projectName string, options a return err } - orphans := containers.filter(isNotService(project.ServiceNames()...)) + orphans := containers.filter(isOrphaned(project)) if options.RemoveOrphans && len(orphans) > 0 { err := s.removeContainers(ctx, w, orphans, options.Timeout, false) if err != nil { diff --git a/pkg/e2e/compose_test.go b/pkg/e2e/compose_test.go index 38300605c..6384c29c8 100644 --- a/pkg/e2e/compose_test.go +++ b/pkg/e2e/compose_test.go @@ -292,3 +292,24 @@ func TestStopWithDependenciesAttached(t *testing.T) { res := c.RunDockerComposeCmd(t, "-f", "./fixtures/dependencies/compose.yaml", "-p", projectName, "up", "--attach-dependencies", "foo") res.Assert(t, icmd.Expected{Out: "exited with code 0"}) } + +func TestRemoveOrphaned(t *testing.T) { + const projectName = "compose-e2e-remove-orphaned" + c := NewParallelCLI(t) + + cleanup := func() { + c.RunDockerComposeCmd(t, "-p", projectName, "down", "--remove-orphans", "--timeout=0") + } + cleanup() + t.Cleanup(cleanup) + + // run stack + c.RunDockerComposeCmd(t, "-f", "./fixtures/sentences/compose.yaml", "-p", projectName, "up", "-d") + + // down "web" service with orphaned removed + c.RunDockerComposeCmd(t, "-f", "./fixtures/sentences/compose.yaml", "-p", projectName, "down", "--remove-orphans", "web") + + // check "words" service has not been considered orphaned + res := c.RunDockerComposeCmd(t, "-f", "./fixtures/sentences/compose.yaml", "-p", projectName, "ps", "--format", "{{.Name}}") + res.Assert(t, icmd.Expected{Out: fmt.Sprintf("%s-words-1", projectName)}) +}