diff --git a/compose/cli/main.py b/compose/cli/main.py index a46521f31..8c1a55ffe 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -25,6 +25,7 @@ from ..progress_stream import StreamOutputError from ..project import NoSuchService from ..service import BuildError from ..service import ConvergenceStrategy +from ..service import ImageType from ..service import NeedsBuildError from .command import friendly_error_message from .command import get_config_path_from_options @@ -129,6 +130,7 @@ class TopLevelCommand(DocoptCommand): build Build or rebuild services config Validate and view the compose file create Create services + down Stop and remove containers, networks, images, and volumes events Receive real time events from containers help Get help on a command kill Kill containers @@ -242,6 +244,22 @@ class TopLevelCommand(DocoptCommand): do_build=not options['--no-build'] ) + def down(self, project, options): + """ + Stop containers and remove containers, networks, volumes, and images + created by `up`. + + 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 + """ + image_type = image_type_from_opt('--rmi', options['--rmi']) + project.down(image_type, options['--volumes']) + def events(self, project, options): """ Receive real time events from containers. @@ -660,6 +678,15 @@ def convergence_strategy_from_opts(options): return ConvergenceStrategy.changed +def image_type_from_opt(flag, value): + if not value: + return ImageType.none + try: + return ImageType[value] + except KeyError: + raise UserError("%s flag must be one of: all, local" % flag) + + def run_one_off_container(container_options, project, service, options): if not options['--no-deps']: deps = service.get_linked_service_names() diff --git a/compose/project.py b/compose/project.py index e882713c2..0774a4002 100644 --- a/compose/project.py +++ b/compose/project.py @@ -270,6 +270,24 @@ class Project(object): ) ) + def down(self, remove_image_type, include_volumes): + self.stop() + self.remove_stopped() + self.remove_network() + + if include_volumes: + self.remove_volumes() + + self.remove_images(remove_image_type) + + def remove_images(self, remove_image_type): + for service in self.get_services(): + service.remove_image(remove_image_type) + + def remove_volumes(self): + for volume in self.volumes: + volume.remove() + def restart(self, service_names=None, **options): containers = self.containers(service_names, stopped=True) parallel.parallel_restart(containers, options) @@ -419,6 +437,8 @@ class Project(object): self.client.create_network(self.default_network_name, driver=self.network_driver) def remove_network(self): + if not self.use_networking: + return network = self.get_network() if network: self.client.remove_network(network['Id']) diff --git a/compose/service.py b/compose/service.py index d5c36f1ad..1972b1b14 100644 --- a/compose/service.py +++ b/compose/service.py @@ -98,6 +98,14 @@ class ConvergenceStrategy(enum.Enum): return self is not type(self).never +@enum.unique +class ImageType(enum.Enum): + """Enumeration for the types of images known to compose.""" + none = 0 + local = 1 + all = 2 + + class Service(object): def __init__( self, @@ -672,6 +680,19 @@ class Service(object): def custom_container_name(self): return self.options.get('container_name') + def remove_image(self, image_type): + if not image_type or image_type == ImageType.none: + return False + if image_type == ImageType.local and self.options.get('image'): + return False + + try: + self.client.remove_image(self.image_name) + return True + except APIError as e: + log.error("Failed to remove image for service %s: %s", self.name, e) + return False + def specifies_host_port(self): def has_host_port(binding): _, external_bindings = split_port(binding) diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 8b5892ab1..a06f14dcc 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -314,6 +314,14 @@ class CLITestCase(DockerClientTestCase): ['create', '--force-recreate', '--no-recreate'], returncode=1) + def test_down_invalid_rmi_flag(self): + result = self.dispatch(['down', '--rmi', 'bogus'], returncode=1) + assert '--rmi flag must be' in result.stderr + + def test_down(self): + result = self.dispatch(['down']) + # TODO: + def test_up_detached(self): self.dispatch(['up', '-d']) service = self.project.get_service('simple') diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 63cf658ed..fa58929b8 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -2,6 +2,7 @@ from __future__ import absolute_import from __future__ import unicode_literals import docker +from docker.errors import APIError from .. import mock from .. import unittest @@ -16,6 +17,7 @@ from compose.service import build_ulimits from compose.service import build_volume_binding from compose.service import ContainerNet from compose.service import get_container_data_volumes +from compose.service import ImageType from compose.service import merge_volume_bindings from compose.service import NeedsBuildError from compose.service import Net @@ -422,6 +424,38 @@ class ServiceTest(unittest.TestCase): } self.assertEqual(config_dict, expected) + def test_remove_image_none(self): + web = Service('web', image='example', client=self.mock_client) + assert not web.remove_image(ImageType.none) + assert not self.mock_client.remove_image.called + + def test_remove_image_local_with_image_name_doesnt_remove(self): + web = Service('web', image='example', client=self.mock_client) + assert not web.remove_image(ImageType.local) + assert not self.mock_client.remove_image.called + + def test_remove_image_local_without_image_name_does_remove(self): + web = Service('web', build='.', client=self.mock_client) + assert web.remove_image(ImageType.local) + self.mock_client.remove_image.assert_called_once_with(web.image_name) + + def test_remove_image_all_does_remove(self): + web = Service('web', image='example', client=self.mock_client) + assert web.remove_image(ImageType.all) + self.mock_client.remove_image.assert_called_once_with(web.image_name) + + def test_remove_image_with_error(self): + self.mock_client.remove_image.side_effect = error = APIError( + message="testing", + response={}, + explanation="Boom") + + web = Service('web', image='example', client=self.mock_client) + with mock.patch('compose.service.log', autospec=True) as mock_log: + assert not web.remove_image(ImageType.all) + mock_log.error.assert_called_once_with( + "Failed to remove image for service %s: %s", web.name, error) + def test_specifies_host_port_with_no_ports(self): service = Service( 'foo',