diff --git a/compose/cli/main.py b/compose/cli/main.py index ea3054abbb4..f481d584724 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -22,6 +22,7 @@ from ..const import IS_WINDOWS_PLATFORM from ..progress_stream import StreamOutputError from ..project import NoSuchService +from ..project import OneOffFilter from ..service import BuildAction from ..service import BuildError from ..service import ConvergenceStrategy @@ -437,7 +438,7 @@ def ps(self, options): """ containers = sorted( self.project.containers(service_names=options['SERVICE'], stopped=True) + - self.project.containers(service_names=options['SERVICE'], one_off=True), + self.project.containers(service_names=options['SERVICE'], one_off=OneOffFilter.only), key=attrgetter('name')) if options['-q']: @@ -491,8 +492,14 @@ def rm(self, options): Options: -f, --force Don't ask to confirm removal -v Remove volumes associated with containers + -a, --all Also remove one-off containers created by + docker-compose run """ - all_containers = self.project.containers(service_names=options['SERVICE'], stopped=True) + one_off = OneOffFilter.include if options.get('--all') else OneOffFilter.exclude + + all_containers = self.project.containers( + service_names=options['SERVICE'], stopped=True, one_off=one_off + ) stopped_containers = [c for c in all_containers if not c.is_running] if len(stopped_containers) > 0: @@ -501,7 +508,8 @@ def rm(self, options): or yesno("Are you sure? [yN] ", default=False): self.project.remove_stopped( service_names=options['SERVICE'], - v=options.get('-v', False) + v=options.get('-v', False), + one_off=one_off ) else: print("No stopped containers") diff --git a/compose/project.py b/compose/project.py index dbfe6a121d1..aef556e921c 100644 --- a/compose/project.py +++ b/compose/project.py @@ -6,6 +6,7 @@ import operator from functools import reduce +import enum from docker.errors import APIError from . import parallel @@ -35,6 +36,20 @@ log = logging.getLogger(__name__) +@enum.unique +class OneOffFilter(enum.Enum): + include = 0 + exclude = 1 + only = 2 + + @classmethod + def update_labels(cls, value, labels): + if value == cls.only: + labels.append('{0}={1}'.format(LABEL_ONE_OFF, "True")) + elif value == cls.exclude or value is False: + labels.append('{0}={1}'.format(LABEL_ONE_OFF, "False")) + + class Project(object): """ A collection of services. @@ -47,10 +62,10 @@ def __init__(self, name, services, client, networks=None, volumes=None): self.networks = networks or ProjectNetworks({}, False) def labels(self, one_off=False): - return [ - '{0}={1}'.format(LABEL_PROJECT, self.name), - '{0}={1}'.format(LABEL_ONE_OFF, "True" if one_off else "False"), - ] + labels = ['{0}={1}'.format(LABEL_PROJECT, self.name)] + + OneOffFilter.update_labels(one_off, labels) + return labels @classmethod def from_config(cls, name, config_data, client): @@ -249,8 +264,10 @@ def unpause(self, service_names=None, **options): def kill(self, service_names=None, **options): parallel.parallel_kill(self.containers(service_names), options) - def remove_stopped(self, service_names=None, **options): - parallel.parallel_remove(self.containers(service_names, stopped=True), options) + def remove_stopped(self, service_names=None, one_off=False, **options): + parallel.parallel_remove(self.containers( + service_names, stopped=True, one_off=one_off + ), options) def down(self, remove_image_type, include_volumes, remove_orphans=False): self.stop() diff --git a/docs/reference/rm.md b/docs/reference/rm.md index f84792243fc..97698b58b73 100644 --- a/docs/reference/rm.md +++ b/docs/reference/rm.md @@ -17,6 +17,7 @@ Usage: rm [options] [SERVICE...] Options: -f, --force Don't ask to confirm removal -v Remove volumes associated with containers +-a, --all Also remove one-off containers ``` Removes stopped service containers. diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index f06b32801cd..382fa8870dd 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -18,6 +18,7 @@ from .. import mock from compose.cli.command import get_project from compose.container import Container +from compose.project import OneOffFilter from tests.integration.testcases import DockerClientTestCase from tests.integration.testcases import get_links from tests.integration.testcases import pull_busybox @@ -105,7 +106,7 @@ def tearDown(self): self.project.kill() self.project.remove_stopped() - for container in self.project.containers(stopped=True, one_off=True): + for container in self.project.containers(stopped=True, one_off=OneOffFilter.only): container.remove(force=True) networks = self.client.networks() @@ -802,7 +803,7 @@ def test_run_service_without_links(self): self.assertEqual(len(self.project.containers()), 0) # Ensure stdin/out was open - container = self.project.containers(stopped=True, one_off=True)[0] + container = self.project.containers(stopped=True, one_off=OneOffFilter.only)[0] config = container.inspect()['Config'] self.assertTrue(config['AttachStderr']) self.assertTrue(config['AttachStdout']) @@ -852,7 +853,7 @@ def test_run_without_command(self): self.dispatch(['run', 'implicit']) service = self.project.get_service('implicit') - containers = service.containers(stopped=True, one_off=True) + containers = service.containers(stopped=True, one_off=OneOffFilter.only) self.assertEqual( [c.human_readable_command for c in containers], [u'/bin/sh -c echo "success"'], @@ -860,7 +861,7 @@ def test_run_without_command(self): self.dispatch(['run', 'explicit']) service = self.project.get_service('explicit') - containers = service.containers(stopped=True, one_off=True) + containers = service.containers(stopped=True, one_off=OneOffFilter.only) self.assertEqual( [c.human_readable_command for c in containers], [u'/bin/true'], @@ -871,7 +872,7 @@ def test_run_service_with_entrypoint_overridden(self): name = 'service' self.dispatch(['run', '--entrypoint', '/bin/echo', name, 'helloworld']) service = self.project.get_service(name) - container = service.containers(stopped=True, one_off=True)[0] + container = service.containers(stopped=True, one_off=OneOffFilter.only)[0] self.assertEqual( shlex.split(container.human_readable_command), [u'/bin/echo', u'helloworld'], @@ -883,7 +884,7 @@ def test_run_service_with_user_overridden(self): user = 'sshd' self.dispatch(['run', '--user={user}'.format(user=user), name], returncode=1) service = self.project.get_service(name) - container = service.containers(stopped=True, one_off=True)[0] + container = service.containers(stopped=True, one_off=OneOffFilter.only)[0] self.assertEqual(user, container.get('Config.User')) def test_run_service_with_user_overridden_short_form(self): @@ -892,7 +893,7 @@ def test_run_service_with_user_overridden_short_form(self): user = 'sshd' self.dispatch(['run', '-u', user, name], returncode=1) service = self.project.get_service(name) - container = service.containers(stopped=True, one_off=True)[0] + container = service.containers(stopped=True, one_off=OneOffFilter.only)[0] self.assertEqual(user, container.get('Config.User')) def test_run_service_with_environement_overridden(self): @@ -906,7 +907,7 @@ def test_run_service_with_environement_overridden(self): '/bin/true', ]) service = self.project.get_service(name) - container = service.containers(stopped=True, one_off=True)[0] + container = service.containers(stopped=True, one_off=OneOffFilter.only)[0] # env overriden self.assertEqual('notbar', container.environment['foo']) # keep environement from yaml @@ -920,7 +921,7 @@ def test_run_service_without_map_ports(self): # create one off container self.base_dir = 'tests/fixtures/ports-composefile' self.dispatch(['run', '-d', 'simple']) - container = self.project.get_service('simple').containers(one_off=True)[0] + container = self.project.get_service('simple').containers(one_off=OneOffFilter.only)[0] # get port information port_random = container.get_local_port(3000) @@ -937,7 +938,7 @@ def test_run_service_with_map_ports(self): # create one off container self.base_dir = 'tests/fixtures/ports-composefile' self.dispatch(['run', '-d', '--service-ports', 'simple']) - container = self.project.get_service('simple').containers(one_off=True)[0] + container = self.project.get_service('simple').containers(one_off=OneOffFilter.only)[0] # get port information port_random = container.get_local_port(3000) @@ -958,7 +959,7 @@ def test_run_service_with_explicitly_maped_ports(self): # create one off container self.base_dir = 'tests/fixtures/ports-composefile' self.dispatch(['run', '-d', '-p', '30000:3000', '--publish', '30001:3001', 'simple']) - container = self.project.get_service('simple').containers(one_off=True)[0] + container = self.project.get_service('simple').containers(one_off=OneOffFilter.only)[0] # get port information port_short = container.get_local_port(3000) @@ -980,7 +981,7 @@ def test_run_service_with_explicitly_maped_ip_ports(self): '--publish', '127.0.0.1:30001:3001', 'simple' ]) - container = self.project.get_service('simple').containers(one_off=True)[0] + container = self.project.get_service('simple').containers(one_off=OneOffFilter.only)[0] # get port information port_short = container.get_local_port(3000) @@ -997,7 +998,7 @@ def test_run_with_expose_ports(self): # create one off container self.base_dir = 'tests/fixtures/expose-composefile' self.dispatch(['run', '-d', '--service-ports', 'simple']) - container = self.project.get_service('simple').containers(one_off=True)[0] + container = self.project.get_service('simple').containers(one_off=OneOffFilter.only)[0] ports = container.ports self.assertEqual(len(ports), 9) @@ -1021,7 +1022,7 @@ def test_run_with_custom_name(self): self.dispatch(['run', '--name', name, 'service', '/bin/true']) service = self.project.get_service('service') - container, = service.containers(stopped=True, one_off=True) + container, = service.containers(stopped=True, one_off=OneOffFilter.only) self.assertEqual(container.name, name) def test_run_service_with_workdir_overridden(self): @@ -1051,7 +1052,7 @@ def test_run_interactive_connects_to_network(self): self.dispatch(['run', 'app', 'nslookup', 'db']) containers = self.project.get_service('app').containers( - stopped=True, one_off=True) + stopped=True, one_off=OneOffFilter.only) assert len(containers) == 2 for container in containers: @@ -1071,7 +1072,7 @@ def test_run_detached_connects_to_network(self): self.dispatch(['up', '-d']) self.dispatch(['run', '-d', 'app', 'top']) - container = self.project.get_service('app').containers(one_off=True)[0] + container = self.project.get_service('app').containers(one_off=OneOffFilter.only)[0] networks = container.get('NetworkSettings.Networks') assert sorted(list(networks)) == [ @@ -1125,6 +1126,28 @@ def test_rm(self): self.dispatch(['rm', '-f'], None) self.assertEqual(len(service.containers(stopped=True)), 0) + def test_rm_all(self): + service = self.project.get_service('simple') + service.create_container(one_off=False) + service.create_container(one_off=True) + kill_service(service) + self.assertEqual(len(service.containers(stopped=True)), 1) + self.assertEqual(len(service.containers(stopped=True, one_off=OneOffFilter.only)), 1) + self.dispatch(['rm', '-f'], None) + self.assertEqual(len(service.containers(stopped=True)), 0) + self.assertEqual(len(service.containers(stopped=True, one_off=OneOffFilter.only)), 1) + self.dispatch(['rm', '-f', '-a'], None) + self.assertEqual(len(service.containers(stopped=True, one_off=OneOffFilter.only)), 0) + + service.create_container(one_off=False) + service.create_container(one_off=True) + kill_service(service) + self.assertEqual(len(service.containers(stopped=True)), 1) + self.assertEqual(len(service.containers(stopped=True, one_off=OneOffFilter.only)), 1) + self.dispatch(['rm', '-f', '--all'], None) + self.assertEqual(len(service.containers(stopped=True)), 0) + self.assertEqual(len(service.containers(stopped=True, one_off=OneOffFilter.only)), 0) + def test_stop(self): self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 6d0c97db3fa..2682e59daa3 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -24,6 +24,7 @@ from compose.const import LABEL_SERVICE from compose.const import LABEL_VERSION from compose.container import Container +from compose.project import OneOffFilter from compose.service import ConvergencePlan from compose.service import ConvergenceStrategy from compose.service import NetworkMode @@ -60,7 +61,7 @@ def test_containers_one_off(self): db = self.create_service('db') container = db.create_container(one_off=True) self.assertEqual(db.containers(stopped=True), []) - self.assertEqual(db.containers(one_off=True, stopped=True), [container]) + self.assertEqual(db.containers(one_off=OneOffFilter.only, stopped=True), [container]) def test_project_is_added_to_container_name(self): service = self.create_service('web') @@ -494,7 +495,7 @@ def test_start_one_off_container_creates_links_to_its_own_service(self): create_and_start_container(db) create_and_start_container(db) - c = create_and_start_container(db, one_off=True) + c = create_and_start_container(db, one_off=OneOffFilter.only) self.assertEqual( set(get_links(c)), diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 199aeeb4382..0e3e8a86c84 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -14,6 +14,7 @@ from compose.const import LABEL_PROJECT from compose.const import LABEL_SERVICE from compose.container import Container +from compose.project import OneOffFilter from compose.service import build_ulimits from compose.service import build_volume_binding from compose.service import BuildAction @@ -256,7 +257,7 @@ def test_get_container_create_options_with_name_option(self): opts = service._get_container_create_options( {'name': name}, 1, - one_off=True) + one_off=OneOffFilter.only) self.assertEqual(opts['name'], name) def test_get_container_create_options_does_not_mutate_options(self):