diff --git a/compose/cli/main.py b/compose/cli/main.py index a46521f3..9957d391 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`. 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 + """ + 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/parallel.py b/compose/parallel.py index 2735a397..b8415e5e 100644 --- a/compose/parallel.py +++ b/compose/parallel.py @@ -24,7 +24,7 @@ def parallel_execute(objects, func, index_func, msg): object we give it. """ objects = list(objects) - stream = get_output_stream(sys.stdout) + stream = get_output_stream(sys.stderr) writer = ParallelStreamWriter(stream, msg) for obj in objects: diff --git a/compose/project.py b/compose/project.py index e882713c..3ba9532f 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(v=include_volumes) + 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,8 +437,11 @@ 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: + log.info("Removing network %s", self.default_network_name) self.client.remove_network(network['Id']) def uses_default_network(self): diff --git a/compose/service.py b/compose/service.py index d5c36f1a..1c848ca3 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,20 @@ 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 + + log.info("Removing image %s", self.image_name) + 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/compose/volume.py b/compose/volume.py index b78aa029..469e406a 100644 --- a/compose/volume.py +++ b/compose/volume.py @@ -1,9 +1,14 @@ from __future__ import absolute_import from __future__ import unicode_literals +import logging + from docker.errors import NotFound +log = logging.getLogger(__name__) + + class Volume(object): def __init__(self, client, project, name, driver=None, driver_opts=None, external_name=None): @@ -20,6 +25,10 @@ class Volume(object): ) def remove(self): + if self.external: + log.info("Volume %s is external, skipping", self.full_name) + return + log.info("Removing volume %s", self.full_name) return self.client.remove_volume(self.full_name) def inspect(self): diff --git a/docs/index.md b/docs/index.md index 6e8f2090..887df99d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -154,8 +154,7 @@ environments in just a few commands: $ docker-compose up -d $ ./run_tests - $ docker-compose stop - $ docker-compose rm -f + $ docker-compose down ### Single host deployments diff --git a/docs/reference/down.md b/docs/reference/down.md new file mode 100644 index 00000000..428e4e58 --- /dev/null +++ b/docs/reference/down.md @@ -0,0 +1,26 @@ + + +# down + +``` +Stop containers and remove containers, networks, volumes, and images +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 + +``` diff --git a/docs/reference/index.md b/docs/reference/index.md index 1635b60c..5406b9c7 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -14,10 +14,14 @@ parent = "smn_compose_ref" The following pages describe the usage information for the [docker-compose](docker-compose.md) subcommands. You can also see this information by running `docker-compose [SUBCOMMAND] --help` from the command line. * [build](build.md) +* [config](config.md) +* [create](create.md) +* [down](down.md) * [events](events.md) * [help](help.md) * [kill](kill.md) * [logs](logs.md) +* [pause](pause.md) * [port](port.md) * [ps](ps.md) * [pull](pull.md) @@ -27,6 +31,7 @@ The following pages describe the usage information for the [docker-compose](dock * [scale](scale.md) * [start](start.md) * [stop](stop.md) +* [unpause](unpause.md) * [up](up.md) ## Where to go next diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 8b5892ab..cb04918b 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -314,6 +314,22 @@ 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): + self.base_dir = 'tests/fixtures/shutdown' + self.dispatch(['up', '-d']) + wait_on_condition(ContainerCountCondition(self.project, 1)) + + result = self.dispatch(['down', '--rmi=local', '--volumes']) + assert 'Stopping shutdown_web_1' in result.stderr + assert 'Removing shutdown_web_1' in result.stderr + assert 'Removing volume shutdown_data' in result.stderr + assert 'Removing image shutdown_web' in result.stderr + assert 'Removing network shutdown_default' in result.stderr + def test_up_detached(self): self.dispatch(['up', '-d']) service = self.project.get_service('simple') diff --git a/tests/fixtures/shutdown/Dockerfile b/tests/fixtures/shutdown/Dockerfile new file mode 100644 index 00000000..51ed0d90 --- /dev/null +++ b/tests/fixtures/shutdown/Dockerfile @@ -0,0 +1,4 @@ + +FROM busybox:latest +RUN echo something +CMD top diff --git a/tests/fixtures/shutdown/docker-compose.yml b/tests/fixtures/shutdown/docker-compose.yml new file mode 100644 index 00000000..c83c3d63 --- /dev/null +++ b/tests/fixtures/shutdown/docker-compose.yml @@ -0,0 +1,10 @@ + +version: 2 + +volumes: + data: + driver: local + +services: + web: + build: . diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 4818e47a..314076cd 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -616,13 +616,13 @@ class ServiceTest(DockerClientTestCase): service.create_container(number=next_number) service.create_container(number=next_number + 1) - with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: + with mock.patch('sys.stderr', new_callable=StringIO) as mock_stderr: service.scale(2) for container in service.containers(): self.assertTrue(container.is_running) self.assertTrue(container.number in valid_numbers) - captured_output = mock_stdout.getvalue() + captured_output = mock_stderr.getvalue() self.assertNotIn('Creating', captured_output) self.assertIn('Starting', captured_output) @@ -639,14 +639,14 @@ class ServiceTest(DockerClientTestCase): for container in service.containers(): self.assertFalse(container.is_running) - with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: + with mock.patch('sys.stderr', new_callable=StringIO) as mock_stderr: service.scale(2) self.assertEqual(len(service.containers()), 2) for container in service.containers(): self.assertTrue(container.is_running) - captured_output = mock_stdout.getvalue() + captured_output = mock_stderr.getvalue() self.assertIn('Creating', captured_output) self.assertIn('Starting', captured_output) @@ -665,12 +665,12 @@ class ServiceTest(DockerClientTestCase): response={}, explanation="Boom")): - with mock.patch('sys.stdout', new_callable=StringIO) as mock_stdout: + with mock.patch('sys.stderr', new_callable=StringIO) as mock_stderr: service.scale(3) self.assertEqual(len(service.containers()), 1) self.assertTrue(service.containers()[0].is_running) - self.assertIn("ERROR: for 2 Boom", mock_stdout.getvalue()) + self.assertIn("ERROR: for 2 Boom", mock_stderr.getvalue()) def test_scale_with_unexpected_exception(self): """Test that when scaling if the API returns an error, that is not of type diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 63cf658e..fa58929b 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', diff --git a/tests/unit/volume_test.py b/tests/unit/volume_test.py new file mode 100644 index 00000000..d7ad0792 --- /dev/null +++ b/tests/unit/volume_test.py @@ -0,0 +1,26 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +import docker +import pytest + +from compose import volume +from tests import mock + + +@pytest.fixture +def mock_client(): + return mock.create_autospec(docker.Client) + + +class TestVolume(object): + + def test_remove_local_volume(self, mock_client): + vol = volume.Volume(mock_client, 'foo', 'project') + vol.remove() + mock_client.remove_volume.assert_called_once_with('foo_project') + + def test_remove_external_volume(self, mock_client): + vol = volume.Volume(mock_client, 'foo', 'project', external_name='data') + vol.remove() + assert not mock_client.remove_volume.called