1
0
Эх сурвалжийг харах

Adding docker-compose down

Signed-off-by: Daniel Nephin <[email protected]>
Daniel Nephin 10 жил өмнө
parent
commit
c8ed156806

+ 27 - 0
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()

+ 20 - 0
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'])

+ 21 - 0
compose/service.py

@@ -98,6 +98,14 @@ class ConvergenceStrategy(enum.Enum):
         return self is not type(self).never
 
 
[email protected]
+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)

+ 8 - 0
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')

+ 34 - 0
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',