diff --git a/compose/cli/main.py b/compose/cli/main.py index 486fb1516..b94fbdaf5 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -252,13 +252,15 @@ class TopLevelCommand(object): Usage: down [options] Options: - --rmi type Remove images, type may be one of: 'all' to remove - all images, or 'local' to remove only images that - don't have an custom name set by the `image` field - -v, --volumes Remove data volumes + --rmi type Remove images, type may be one of: 'all' to remove + all images, or 'local' to remove only images that + don't have an custom name set by the `image` field + -v, --volumes Remove data volumes + --remove-orphans Remove containers for services not defined in + the Compose file """ image_type = image_type_from_opt('--rmi', options['--rmi']) - self.project.down(image_type, options['--volumes']) + self.project.down(image_type, options['--volumes'], options['--remove-orphans']) def events(self, options): """ @@ -324,9 +326,9 @@ class TopLevelCommand(object): signals.set_signal_handler_to_shutdown() try: operation = ExecOperation( - self.project.client, - exec_id, - interactive=tty, + self.project.client, + exec_id, + interactive=tty, ) pty = PseudoTerminal(self.project.client, operation) pty.start() @@ -666,12 +668,15 @@ class TopLevelCommand(object): -t, --timeout TIMEOUT Use this timeout in seconds for container shutdown when attached or when containers are already running. (default: 10) + --remove-orphans Remove containers for services not + defined in the Compose file """ monochrome = options['--no-color'] start_deps = not options['--no-deps'] cascade_stop = options['--abort-on-container-exit'] service_names = options['SERVICE'] timeout = int(options.get('--timeout') or DEFAULT_TIMEOUT) + remove_orphans = options['--remove-orphans'] detached = options.get('-d') if detached and cascade_stop: @@ -684,7 +689,8 @@ class TopLevelCommand(object): strategy=convergence_strategy_from_opts(options), do_build=build_action_from_opts(options), timeout=timeout, - detached=detached) + detached=detached, + remove_orphans=remove_orphans) if detached: return diff --git a/compose/project.py b/compose/project.py index 3de68b2c6..49cbfbf72 100644 --- a/compose/project.py +++ b/compose/project.py @@ -252,9 +252,11 @@ class Project(object): def remove_stopped(self, service_names=None, **options): parallel.parallel_remove(self.containers(service_names, stopped=True), options) - def down(self, remove_image_type, include_volumes): + def down(self, remove_image_type, include_volumes, remove_orphans=False): self.stop() + self.find_orphan_containers(remove_orphans) self.remove_stopped(v=include_volumes) + self.networks.remove() if include_volumes: @@ -334,7 +336,8 @@ class Project(object): strategy=ConvergenceStrategy.changed, do_build=BuildAction.none, timeout=DEFAULT_TIMEOUT, - detached=False): + detached=False, + remove_orphans=False): self.initialize() services = self.get_services_without_duplicate( @@ -346,6 +349,8 @@ class Project(object): for svc in services: svc.ensure_image_exists(do_build=do_build) + self.find_orphan_containers(remove_orphans) + def do(service): return service.execute_convergence_plan( plans[service.name], @@ -402,23 +407,52 @@ class Project(object): for service in self.get_services(service_names, include_deps=False): service.pull(ignore_pull_failures) + def _labeled_containers(self, stopped=False, one_off=False): + return list(filter(None, [ + Container.from_ps(self.client, container) + for container in self.client.containers( + all=stopped, + filters={'label': self.labels(one_off=one_off)})]) + ) + def containers(self, service_names=None, stopped=False, one_off=False): if service_names: self.validate_service_names(service_names) else: service_names = self.service_names - containers = list(filter(None, [ - Container.from_ps(self.client, container) - for container in self.client.containers( - all=stopped, - filters={'label': self.labels(one_off=one_off)})])) + containers = self._labeled_containers(stopped, one_off) def matches_service_names(container): return container.labels.get(LABEL_SERVICE) in service_names return [c for c in containers if matches_service_names(c)] + def find_orphan_containers(self, remove_orphans): + def _find(): + containers = self._labeled_containers() + for ctnr in containers: + service_name = ctnr.labels.get(LABEL_SERVICE) + if service_name not in self.service_names: + yield ctnr + orphans = list(_find()) + if not orphans: + return + if remove_orphans: + for ctnr in orphans: + log.info('Removing orphan container "{0}"'.format(ctnr.name)) + ctnr.kill() + ctnr.remove(force=True) + else: + log.warning( + 'Found orphan containers ({0}) 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.'.format( + ', '.join(["{}".format(ctnr.name) for ctnr in orphans]) + ) + ) + def _inject_deps(self, acc, service): dep_names = service.get_dependency_names() diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index d926d648e..0769e6571 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -161,7 +161,7 @@ _docker_compose_down() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--help --rmi --volumes -v" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--help --rmi --volumes -v --remove-orphans" -- "$cur" ) ) ;; esac } @@ -406,7 +406,7 @@ _docker_compose_up() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--abort-on-container-exit -d --force-recreate --help --no-build --no-color --no-deps --no-recreate --timeout -t" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--abort-on-container-exit -d --force-recreate --help --no-build --no-color --no-deps --no-recreate --timeout -t --remove-orphans" -- "$cur" ) ) ;; *) __docker_compose_services_all diff --git a/docs/reference/down.md b/docs/reference/down.md index 2495abeac..e8b1db597 100644 --- a/docs/reference/down.md +++ b/docs/reference/down.md @@ -18,9 +18,11 @@ created by `up`. Only containers and networks are removed by default. Usage: down [options] Options: - --rmi type Remove images, type may be one of: 'all' to remove - all images, or 'local' to remove only images that - don't have an custom name set by the `image` field - -v, --volumes Remove data volumes + --rmi type Remove images, type may be one of: 'all' to remove + all images, or 'local' to remove only images that + don't have an custom name set by the `image` field + -v, --volumes Remove data volumes + --remove-orphans Remove containers for services not defined in the + Compose file ``` diff --git a/docs/reference/up.md b/docs/reference/up.md index 07ee82f93..3951f8792 100644 --- a/docs/reference/up.md +++ b/docs/reference/up.md @@ -32,6 +32,8 @@ Options: -t, --timeout TIMEOUT Use this timeout in seconds for container shutdown when attached or when containers are already running. (default: 10) + --remove-orphans Remove containers for services not defined in + the Compose file ``` diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 393f4f11b..9839bf8fc 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -7,6 +7,7 @@ import py import pytest from docker.errors import NotFound +from .. import mock from ..helpers import build_config from .testcases import DockerClientTestCase from compose.config import config @@ -15,6 +16,7 @@ from compose.config.config import V2_0 from compose.config.types import VolumeFromSpec from compose.config.types import VolumeSpec from compose.const import LABEL_PROJECT +from compose.const import LABEL_SERVICE from compose.container import Container from compose.project import Project from compose.service import ConvergenceStrategy @@ -1055,3 +1057,40 @@ class ProjectTest(DockerClientTestCase): container = service.get_container() assert [mount['Name'] for mount in container.get('Mounts')] == [full_vol_name] assert next((v for v in engine_volumes if v['Name'] == vol_name), None) is None + + def test_project_up_orphans(self): + config_dict = { + 'service1': { + 'image': 'busybox:latest', + 'command': 'top', + } + } + + config_data = build_config(config_dict) + project = Project.from_config( + name='composetest', config_data=config_data, client=self.client + ) + project.up() + config_dict['service2'] = config_dict['service1'] + del config_dict['service1'] + + config_data = build_config(config_dict) + project = Project.from_config( + name='composetest', config_data=config_data, client=self.client + ) + with mock.patch('compose.project.log') as mock_log: + project.up() + + mock_log.warning.assert_called_once_with(mock.ANY) + + assert len([ + ctnr for ctnr in project._labeled_containers() + if ctnr.labels.get(LABEL_SERVICE) == 'service1' + ]) == 1 + + project.up(remove_orphans=True) + + assert len([ + ctnr for ctnr in project._labeled_containers() + if ctnr.labels.get(LABEL_SERVICE) == 'service1' + ]) == 0