Merge pull request #2646 from dnephin/docker_compose_down

docker-compose down
This commit is contained in:
Daniel Nephin 2016-01-14 11:59:02 -05:00
commit 172b955d79
14 changed files with 208 additions and 9 deletions

View File

@ -25,6 +25,7 @@ from ..progress_stream import StreamOutputError
from ..project import NoSuchService from ..project import NoSuchService
from ..service import BuildError from ..service import BuildError
from ..service import ConvergenceStrategy from ..service import ConvergenceStrategy
from ..service import ImageType
from ..service import NeedsBuildError from ..service import NeedsBuildError
from .command import friendly_error_message from .command import friendly_error_message
from .command import get_config_path_from_options from .command import get_config_path_from_options
@ -129,6 +130,7 @@ class TopLevelCommand(DocoptCommand):
build Build or rebuild services build Build or rebuild services
config Validate and view the compose file config Validate and view the compose file
create Create services create Create services
down Stop and remove containers, networks, images, and volumes
events Receive real time events from containers events Receive real time events from containers
help Get help on a command help Get help on a command
kill Kill containers kill Kill containers
@ -242,6 +244,22 @@ class TopLevelCommand(DocoptCommand):
do_build=not options['--no-build'] 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): def events(self, project, options):
""" """
Receive real time events from containers. Receive real time events from containers.
@ -660,6 +678,15 @@ def convergence_strategy_from_opts(options):
return ConvergenceStrategy.changed 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): def run_one_off_container(container_options, project, service, options):
if not options['--no-deps']: if not options['--no-deps']:
deps = service.get_linked_service_names() deps = service.get_linked_service_names()

View File

@ -24,7 +24,7 @@ def parallel_execute(objects, func, index_func, msg):
object we give it. object we give it.
""" """
objects = list(objects) objects = list(objects)
stream = get_output_stream(sys.stdout) stream = get_output_stream(sys.stderr)
writer = ParallelStreamWriter(stream, msg) writer = ParallelStreamWriter(stream, msg)
for obj in objects: for obj in objects:

View File

@ -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): def restart(self, service_names=None, **options):
containers = self.containers(service_names, stopped=True) containers = self.containers(service_names, stopped=True)
parallel.parallel_restart(containers, options) parallel.parallel_restart(containers, options)
@ -419,8 +437,11 @@ class Project(object):
self.client.create_network(self.default_network_name, driver=self.network_driver) self.client.create_network(self.default_network_name, driver=self.network_driver)
def remove_network(self): def remove_network(self):
if not self.use_networking:
return
network = self.get_network() network = self.get_network()
if network: if network:
log.info("Removing network %s", self.default_network_name)
self.client.remove_network(network['Id']) self.client.remove_network(network['Id'])
def uses_default_network(self): def uses_default_network(self):

View File

@ -98,6 +98,14 @@ class ConvergenceStrategy(enum.Enum):
return self is not type(self).never 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): class Service(object):
def __init__( def __init__(
self, self,
@ -672,6 +680,20 @@ class Service(object):
def custom_container_name(self): def custom_container_name(self):
return self.options.get('container_name') 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 specifies_host_port(self):
def has_host_port(binding): def has_host_port(binding):
_, external_bindings = split_port(binding) _, external_bindings = split_port(binding)

View File

@ -1,9 +1,14 @@
from __future__ import absolute_import from __future__ import absolute_import
from __future__ import unicode_literals from __future__ import unicode_literals
import logging
from docker.errors import NotFound from docker.errors import NotFound
log = logging.getLogger(__name__)
class Volume(object): class Volume(object):
def __init__(self, client, project, name, driver=None, driver_opts=None, def __init__(self, client, project, name, driver=None, driver_opts=None,
external_name=None): external_name=None):
@ -20,6 +25,10 @@ class Volume(object):
) )
def remove(self): 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) return self.client.remove_volume(self.full_name)
def inspect(self): def inspect(self):

View File

@ -154,8 +154,7 @@ environments in just a few commands:
$ docker-compose up -d $ docker-compose up -d
$ ./run_tests $ ./run_tests
$ docker-compose stop $ docker-compose down
$ docker-compose rm -f
### Single host deployments ### Single host deployments

26
docs/reference/down.md Normal file
View File

@ -0,0 +1,26 @@
<!--[metadata]>
+++
title = "down"
description = "down"
keywords = ["fig, composition, compose, docker, orchestration, cli, down"]
[menu.main]
identifier="build.compose"
parent = "smn_compose_cli"
+++
<![end-metadata]-->
# 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
```

View File

@ -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. 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) * [build](build.md)
* [config](config.md)
* [create](create.md)
* [down](down.md)
* [events](events.md) * [events](events.md)
* [help](help.md) * [help](help.md)
* [kill](kill.md) * [kill](kill.md)
* [logs](logs.md) * [logs](logs.md)
* [pause](pause.md)
* [port](port.md) * [port](port.md)
* [ps](ps.md) * [ps](ps.md)
* [pull](pull.md) * [pull](pull.md)
@ -27,6 +31,7 @@ The following pages describe the usage information for the [docker-compose](dock
* [scale](scale.md) * [scale](scale.md)
* [start](start.md) * [start](start.md)
* [stop](stop.md) * [stop](stop.md)
* [unpause](unpause.md)
* [up](up.md) * [up](up.md)
## Where to go next ## Where to go next

View File

@ -314,6 +314,22 @@ class CLITestCase(DockerClientTestCase):
['create', '--force-recreate', '--no-recreate'], ['create', '--force-recreate', '--no-recreate'],
returncode=1) 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): def test_up_detached(self):
self.dispatch(['up', '-d']) self.dispatch(['up', '-d'])
service = self.project.get_service('simple') service = self.project.get_service('simple')

4
tests/fixtures/shutdown/Dockerfile vendored Normal file
View File

@ -0,0 +1,4 @@
FROM busybox:latest
RUN echo something
CMD top

View File

@ -0,0 +1,10 @@
version: 2
volumes:
data:
driver: local
services:
web:
build: .

View File

@ -616,13 +616,13 @@ class ServiceTest(DockerClientTestCase):
service.create_container(number=next_number) service.create_container(number=next_number)
service.create_container(number=next_number + 1) 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) service.scale(2)
for container in service.containers(): for container in service.containers():
self.assertTrue(container.is_running) self.assertTrue(container.is_running)
self.assertTrue(container.number in valid_numbers) self.assertTrue(container.number in valid_numbers)
captured_output = mock_stdout.getvalue() captured_output = mock_stderr.getvalue()
self.assertNotIn('Creating', captured_output) self.assertNotIn('Creating', captured_output)
self.assertIn('Starting', captured_output) self.assertIn('Starting', captured_output)
@ -639,14 +639,14 @@ class ServiceTest(DockerClientTestCase):
for container in service.containers(): for container in service.containers():
self.assertFalse(container.is_running) 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) service.scale(2)
self.assertEqual(len(service.containers()), 2) self.assertEqual(len(service.containers()), 2)
for container in service.containers(): for container in service.containers():
self.assertTrue(container.is_running) self.assertTrue(container.is_running)
captured_output = mock_stdout.getvalue() captured_output = mock_stderr.getvalue()
self.assertIn('Creating', captured_output) self.assertIn('Creating', captured_output)
self.assertIn('Starting', captured_output) self.assertIn('Starting', captured_output)
@ -665,12 +665,12 @@ class ServiceTest(DockerClientTestCase):
response={}, response={},
explanation="Boom")): 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) service.scale(3)
self.assertEqual(len(service.containers()), 1) self.assertEqual(len(service.containers()), 1)
self.assertTrue(service.containers()[0].is_running) 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): def test_scale_with_unexpected_exception(self):
"""Test that when scaling if the API returns an error, that is not of type """Test that when scaling if the API returns an error, that is not of type

View File

@ -2,6 +2,7 @@ from __future__ import absolute_import
from __future__ import unicode_literals from __future__ import unicode_literals
import docker import docker
from docker.errors import APIError
from .. import mock from .. import mock
from .. import unittest from .. import unittest
@ -16,6 +17,7 @@ from compose.service import build_ulimits
from compose.service import build_volume_binding from compose.service import build_volume_binding
from compose.service import ContainerNet from compose.service import ContainerNet
from compose.service import get_container_data_volumes from compose.service import get_container_data_volumes
from compose.service import ImageType
from compose.service import merge_volume_bindings from compose.service import merge_volume_bindings
from compose.service import NeedsBuildError from compose.service import NeedsBuildError
from compose.service import Net from compose.service import Net
@ -422,6 +424,38 @@ class ServiceTest(unittest.TestCase):
} }
self.assertEqual(config_dict, expected) 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): def test_specifies_host_port_with_no_ports(self):
service = Service( service = Service(
'foo', 'foo',

26
tests/unit/volume_test.py Normal file
View File

@ -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