Merge pull request #2027 from dnephin/bump-1.4.1

Bump 1.4.1
This commit is contained in:
Daniel Nephin 2015-09-15 17:47:09 -04:00
commit 605d7f26e7
17 changed files with 420 additions and 121 deletions

View File

@ -1,6 +1,22 @@
Change log
==========
1.4.1 (2015-09-10)
------------------
The following bugs have been fixed:
- Some configuration changes (notably changes to `links`, `volumes_from`, and
`net`) were not properly triggering a container recreate as part of
`docker-compose up`.
- `docker-compose up <service>` was showing logs for all services instead of
just the specified services.
- Containers with custom container names were showing up in logs as
`service_number` instead of their custom container name.
- When scaling a service sometimes containers would be recreated even when
the configuration had not changed.
1.4.0 (2015-08-04)
------------------

View File

@ -1,3 +1,3 @@
from __future__ import unicode_literals
__version__ = '1.4.0'
__version__ = '1.4.1'

View File

@ -304,7 +304,7 @@ class TopLevelCommand(Command):
log.warn(INSECURE_SSL_WARNING)
if not options['--no-deps']:
deps = service.get_linked_names()
deps = service.get_linked_service_names()
if len(deps) > 0:
project.up(
@ -496,19 +496,8 @@ class TopLevelCommand(Command):
)
if not detached:
print("Attaching to", list_containers(to_attach))
log_printer = LogPrinter(to_attach, attach_params={"logs": True}, monochrome=monochrome)
try:
log_printer.run()
finally:
def handler(signal, frame):
project.kill(service_names=service_names)
sys.exit(0)
signal.signal(signal.SIGINT, handler)
print("Gracefully stopping... (press Ctrl+C again to force)")
project.stop(service_names=service_names, timeout=timeout)
log_printer = build_log_printer(to_attach, service_names, monochrome)
attach_to_logs(project, log_printer, service_names, timeout)
def migrate_to_labels(self, project, _options):
"""
@ -551,5 +540,26 @@ class TopLevelCommand(Command):
print(get_version_info('full'))
def build_log_printer(containers, service_names, monochrome):
return LogPrinter(
[c for c in containers if c.service in service_names],
attach_params={"logs": True},
monochrome=monochrome)
def attach_to_logs(project, log_printer, service_names, timeout):
print("Attaching to", list_containers(log_printer.containers))
try:
log_printer.run()
finally:
def handler(signal, frame):
project.kill(service_names=service_names)
sys.exit(0)
signal.signal(signal.SIGINT, handler)
print("Gracefully stopping... (press Ctrl+C again to force)")
project.stop(service_names=service_names, timeout=timeout)
def list_containers(containers):
return ", ".join(c.name for c in containers)

View File

@ -12,9 +12,9 @@ from compose.cli.utils import find_candidates_in_parent_dirs
DOCKER_CONFIG_KEYS = [
'cap_add',
'cap_drop',
'command',
'cpu_shares',
'cpuset',
'command',
'detach',
'devices',
'dns',
@ -28,12 +28,12 @@ DOCKER_CONFIG_KEYS = [
'image',
'labels',
'links',
'log_driver',
'log_opt',
'mac_address',
'mem_limit',
'memswap_limit',
'net',
'log_driver',
'log_opt',
'pid',
'ports',
'privileged',
@ -382,7 +382,7 @@ def parse_environment(environment):
return dict(split_env(e) for e in environment)
if isinstance(environment, dict):
return environment
return dict(environment)
raise ConfigurationError(
"environment \"%s\" must be a list or mapping," %
@ -440,12 +440,12 @@ def resolve_volume_path(volume, working_dir, service_name):
if not any(host_path.startswith(c) for c in PATH_START_CHARS):
log.warn(
'Warning: the mapping "{0}" in the volumes config for '
'service "{1}" is ambiguous. In a future version of Docker, '
'Warning: the mapping "{0}:{1}" in the volumes config for '
'service "{2}" is ambiguous. In a future version of Docker, '
'it will designate a "named" volume '
'(see https://github.com/docker/docker/pull/14242). '
'To prevent unexpected behaviour, change it to "./{0}"'
.format(volume, service_name)
'To prevent unexpected behaviour, change it to "./{0}:{1}"'
.format(host_path, container_path, service_name)
)
return "%s:%s" % (expand_path(working_dir, host_path), container_path)

View File

@ -62,9 +62,13 @@ class Container(object):
def name(self):
return self.dictionary['Name'][1:]
@property
def service(self):
return self.labels.get(LABEL_SERVICE)
@property
def name_without_project(self):
return '{0}_{1}'.format(self.labels.get(LABEL_SERVICE), self.number)
return '{0}_{1}'.format(self.service, self.number)
@property
def number(self):

View File

@ -9,7 +9,10 @@ from .config import get_service_name_from_net, ConfigurationError
from .const import DEFAULT_TIMEOUT, LABEL_PROJECT, LABEL_SERVICE, LABEL_ONE_OFF
from .container import Container
from .legacy import check_for_legacy_containers
from .service import ContainerNet
from .service import Net
from .service import Service
from .service import ServiceNet
from .utils import parallel_execute
log = logging.getLogger(__name__)
@ -81,8 +84,14 @@ class Project(object):
volumes_from = project.get_volumes_from(service_dict)
net = project.get_net(service_dict)
project.services.append(Service(client=client, project=name, links=links, net=net,
volumes_from=volumes_from, **service_dict))
project.services.append(
Service(
client=client,
project=name,
links=links,
net=net,
volumes_from=volumes_from,
**service_dict))
return project
@property
@ -172,26 +181,26 @@ class Project(object):
return volumes_from
def get_net(self, service_dict):
if 'net' in service_dict:
net_name = get_service_name_from_net(service_dict.get('net'))
net = service_dict.pop('net', None)
if not net:
return Net(None)
if net_name:
try:
net = self.get_service(net_name)
except NoSuchService:
try:
net = Container.from_id(self.client, net_name)
except APIError:
raise ConfigurationError('Service "%s" is trying to use the network of "%s", which is not the name of a service or container.' % (service_dict['name'], net_name))
else:
net = service_dict['net']
net_name = get_service_name_from_net(net)
if not net_name:
return Net(net)
del service_dict['net']
else:
net = None
return net
try:
return ServiceNet(self.get_service(net_name))
except NoSuchService:
pass
try:
return ContainerNet(Container.from_id(self.client, net_name))
except APIError:
raise ConfigurationError(
'Service "%s" is trying to use the network of "%s", '
'which is not the name of a service or container.' % (
service_dict['name'],
net_name))
def start(self, service_names=None, **options):
for service in self.get_services(service_names):

View File

@ -83,7 +83,16 @@ ConvergencePlan = namedtuple('ConvergencePlan', 'action containers')
class Service(object):
def __init__(self, name, client=None, project='default', links=None, external_links=None, volumes_from=None, net=None, **options):
def __init__(
self,
name,
client=None,
project='default',
links=None,
volumes_from=None,
net=None,
**options
):
if not re.match('^%s+$' % VALID_NAME_CHARS, name):
raise ConfigError('Invalid service name "%s" - only %s are allowed' % (name, VALID_NAME_CHARS))
if not re.match('^%s+$' % VALID_NAME_CHARS, project):
@ -97,9 +106,8 @@ class Service(object):
self.client = client
self.project = project
self.links = links or []
self.external_links = external_links or []
self.volumes_from = volumes_from or []
self.net = net or None
self.net = net or Net(None)
self.options = options
def containers(self, stopped=False, one_off=False):
@ -469,26 +477,26 @@ class Service(object):
return {
'options': self.options,
'image_id': self.image()['Id'],
'links': self.get_link_names(),
'net': self.net.id,
'volumes_from': self.get_volumes_from_names(),
}
def get_dependency_names(self):
net_name = self.get_net_name()
return (self.get_linked_names() +
net_name = self.net.service_name
return (self.get_linked_service_names() +
self.get_volumes_from_names() +
([net_name] if net_name else []))
def get_linked_names(self):
return [s.name for (s, _) in self.links]
def get_linked_service_names(self):
return [service.name for (service, _) in self.links]
def get_link_names(self):
return [(service.name, alias) for service, alias in self.links]
def get_volumes_from_names(self):
return [s.name for s in self.volumes_from if isinstance(s, Service)]
def get_net_name(self):
if isinstance(self.net, Service):
return self.net.name
else:
return
def get_container_name(self, number, one_off=False):
# TODO: Implement issue #652 here
return build_container_name(self.project, self.name, number, one_off)
@ -517,7 +525,7 @@ class Service(object):
links.append((container.name, self.name))
links.append((container.name, container.name))
links.append((container.name, container.name_without_project))
for external_link in self.external_links:
for external_link in self.options.get('external_links') or []:
if ':' not in external_link:
link_name = external_link
else:
@ -540,32 +548,12 @@ class Service(object):
return volumes_from
def _get_net(self):
if not self.net:
return None
if isinstance(self.net, Service):
containers = self.net.containers()
if len(containers) > 0:
net = 'container:' + containers[0].id
else:
log.warning("Warning: Service %s is trying to use reuse the network stack "
"of another service that is not running." % (self.net.name))
net = None
elif isinstance(self.net, Container):
net = 'container:' + self.net.id
else:
net = self.net
return net
def _get_container_create_options(
self,
override_options,
number,
one_off=False,
previous_container=None):
add_config_hash = (not one_off and not override_options)
container_options = dict(
@ -578,13 +566,6 @@ class Service(object):
else:
container_options['name'] = self.get_container_name(number, one_off)
if add_config_hash:
config_hash = self.config_hash()
if 'labels' not in container_options:
container_options['labels'] = {}
container_options['labels'][LABEL_CONFIG_HASH] = config_hash
log.debug("Added config hash: %s" % config_hash)
if 'detach' not in container_options:
container_options['detach'] = True
@ -632,7 +613,8 @@ class Service(object):
container_options['labels'] = build_container_labels(
container_options.get('labels', {}),
self.labels(one_off=one_off),
number)
number,
self.config_hash() if add_config_hash else None)
# Delete options which are only used when starting
for key in DOCKER_START_KEYS:
@ -679,7 +661,7 @@ class Service(object):
binds=options.get('binds'),
volumes_from=self._get_volumes_from(),
privileged=privileged,
network_mode=self._get_net(),
network_mode=self.net.mode,
devices=devices,
dns=dns,
dns_search=dns_search,
@ -774,6 +756,61 @@ class Service(object):
stream_output(output, sys.stdout)
class Net(object):
"""A `standard` network mode (ex: host, bridge)"""
service_name = None
def __init__(self, net):
self.net = net
@property
def id(self):
return self.net
mode = id
class ContainerNet(object):
"""A network mode that uses a container's network stack."""
service_name = None
def __init__(self, container):
self.container = container
@property
def id(self):
return self.container.id
@property
def mode(self):
return 'container:' + self.container.id
class ServiceNet(object):
"""A network mode that uses a service's network stack."""
def __init__(self, service):
self.service = service
@property
def id(self):
return self.service.name
service_name = id
@property
def mode(self):
containers = self.service.containers()
if containers:
return 'container:' + containers[0].id
log.warn("Warning: Service %s is trying to use reuse the network stack "
"of another service that is not running." % (self.id))
return None
# Names
@ -899,11 +936,16 @@ def split_port(port):
# Labels
def build_container_labels(label_options, service_labels, number, one_off=False):
labels = label_options or {}
def build_container_labels(label_options, service_labels, number, config_hash):
labels = dict(label_options or {})
labels.update(label.split('=', 1) for label in service_labels)
labels[LABEL_CONTAINER_NUMBER] = str(number)
labels[LABEL_VERSION] = __version__
if config_hash:
log.debug("Added config hash: %s" % config_hash)
labels[LABEL_CONFIG_HASH] = config_hash
return labels

View File

@ -32,6 +32,10 @@ def parallel_execute(objects, obj_callable, msg_index, msg):
except APIError as e:
errors[msg_index] = e.explanation
result = "error"
except Exception as e:
errors[msg_index] = e
result = 'unexpected_exception'
q.put((msg_index, result))
for an_object in objects:
@ -48,6 +52,9 @@ def parallel_execute(objects, obj_callable, msg_index, msg):
while done < total_to_execute:
try:
msg_index, result = q.get(timeout=1)
if result == 'unexpected_exception':
raise errors[msg_index]
if result == 'error':
write_out_msg(stream, lines, msg_index, msg, status='error')
else:

View File

@ -53,7 +53,7 @@ To install Compose, do the following:
6. Test the installation.
$ docker-compose --version
docker-compose version: 1.4.0
docker-compose version: 1.4.1
## Upgrading

View File

@ -1,5 +1,7 @@
import sys
import mock # noqa
if sys.version_info >= (2, 7):
import unittest # NOQA
else:

View File

@ -112,7 +112,7 @@ class ProjectTest(DockerClientTestCase):
web = project.get_service('web')
net = project.get_service('net')
self.assertEqual(web._get_net(), 'container:' + net.containers()[0].id)
self.assertEqual(web.net.mode, 'container:' + net.containers()[0].id)
def test_net_from_container(self):
net_container = Container.create(
@ -138,7 +138,7 @@ class ProjectTest(DockerClientTestCase):
project.up()
web = project.get_service('web')
self.assertEqual(web._get_net(), 'container:' + net_container.id)
self.assertEqual(web.net.mode, 'container:' + net_container.id)
def test_start_stop_kill_remove(self):
web = self.create_service('web')

View File

@ -9,6 +9,7 @@ import tempfile
import shutil
from six import StringIO, text_type
from .testcases import DockerClientTestCase
from compose import __version__
from compose.const import (
LABEL_CONTAINER_NUMBER,
@ -17,14 +18,12 @@ from compose.const import (
LABEL_SERVICE,
LABEL_VERSION,
)
from compose.service import (
ConfigError,
ConvergencePlan,
Service,
build_extra_hosts,
)
from compose.container import Container
from .testcases import DockerClientTestCase
from compose.service import build_extra_hosts
from compose.service import ConfigError
from compose.service import ConvergencePlan
from compose.service import Net
from compose.service import Service
def create_and_start_container(service, **override_options):
@ -672,6 +671,25 @@ class ServiceTest(DockerClientTestCase):
self.assertTrue(service.containers()[0].is_running)
self.assertIn("ERROR: for 2 Boom", mock_stdout.getvalue())
@patch('sys.stdout', new_callable=StringIO)
def test_scale_with_api_returns_unexpected_exception(self, mock_stdout):
"""
Test that when scaling if the API returns an error, that is not of type
APIError, that error is re-raised.
"""
service = self.create_service('web')
next_number = service._next_container_number()
service.create_container(number=next_number, quiet=True)
with patch(
'compose.container.Container.create',
side_effect=ValueError("BOOM")):
with self.assertRaises(ValueError):
service.scale(3)
self.assertEqual(len(service.containers()), 1)
self.assertTrue(service.containers()[0].is_running)
@patch('compose.service.log')
def test_scale_with_desired_number_already_achieved(self, mock_log):
"""
@ -724,17 +742,17 @@ class ServiceTest(DockerClientTestCase):
self.assertEqual(list(container.inspect()['HostConfig']['PortBindings'].keys()), ['8000/tcp'])
def test_network_mode_none(self):
service = self.create_service('web', net='none')
service = self.create_service('web', net=Net('none'))
container = create_and_start_container(service)
self.assertEqual(container.get('HostConfig.NetworkMode'), 'none')
def test_network_mode_bridged(self):
service = self.create_service('web', net='bridge')
service = self.create_service('web', net=Net('bridge'))
container = create_and_start_container(service)
self.assertEqual(container.get('HostConfig.NetworkMode'), 'bridge')
def test_network_mode_host(self):
service = self.create_service('web', net='host')
service = self.create_service('web', net=Net('host'))
container = create_and_start_container(service)
self.assertEqual(container.get('HostConfig.NetworkMode'), 'host')

View File

@ -1,3 +1,7 @@
"""
Integration tests which cover state convergence (aka smart recreate) performed
by `docker-compose up`.
"""
from __future__ import unicode_literals
import tempfile
import shutil
@ -151,6 +155,24 @@ class ProjectWithDependenciesTest(ProjectTestCase):
self.assertEqual(new_containers - old_containers, set())
def test_service_removed_while_down(self):
next_cfg = {
'web': {
'image': 'busybox:latest',
'command': 'tail -f /dev/null',
},
'nginx': self.cfg['nginx'],
}
containers = self.run_up(self.cfg)
self.assertEqual(len(containers), 3)
project = self.make_project(self.cfg)
project.stop(timeout=1)
containers = self.run_up(next_cfg)
self.assertEqual(len(containers), 2)
def converge(service,
allow_recreate=True,

View File

@ -0,0 +1,47 @@
from __future__ import absolute_import
from compose import container
from compose.cli.log_printer import LogPrinter
from compose.cli.main import attach_to_logs
from compose.cli.main import build_log_printer
from compose.project import Project
from tests import mock
from tests import unittest
def mock_container(service, number):
return mock.create_autospec(
container.Container,
service=service,
number=number,
name_without_project='{0}_{1}'.format(service, number))
class CLIMainTestCase(unittest.TestCase):
def test_build_log_printer(self):
containers = [
mock_container('web', 1),
mock_container('web', 2),
mock_container('db', 1),
mock_container('other', 1),
mock_container('another', 1),
]
service_names = ['web', 'db']
log_printer = build_log_printer(containers, service_names, True)
self.assertEqual(log_printer.containers, containers[:3])
def test_attach_to_logs(self):
project = mock.create_autospec(Project)
log_printer = mock.create_autospec(LogPrinter, containers=[])
service_names = ['web', 'db']
timeout = 12
with mock.patch('compose.cli.main.signal', autospec=True) as mock_signal:
attach_to_logs(project, log_printer, service_names, timeout)
mock_signal.signal.assert_called_once_with(mock_signal.SIGINT, mock.ANY)
log_printer.run.assert_called_once_with()
project.stop.assert_called_once_with(
service_names=service_names,
timeout=timeout)

View File

@ -512,7 +512,7 @@ class ExtendsTest(unittest.TestCase):
We specify a 'file' key that is the filename we're already in.
"""
service_dicts = load_from_filename('tests/fixtures/extends/specify-file-as-self.yml')
self.assertEqual(service_dicts, [
self.assertEqual(sorted(service_dicts), sorted([
{
'environment':
{
@ -532,7 +532,7 @@ class ExtendsTest(unittest.TestCase):
'image': 'busybox',
'name': 'web'
}
])
]))
def test_circular(self):
try:

View File

@ -220,7 +220,7 @@ class ProjectTest(unittest.TestCase):
}
], self.mock_client)
service = project.get_service('test')
self.assertEqual(service._get_net(), None)
self.assertEqual(service.net.id, None)
self.assertNotIn('NetworkMode', service._get_container_host_config({}))
def test_use_net_from_container(self):
@ -235,7 +235,7 @@ class ProjectTest(unittest.TestCase):
}
], self.mock_client)
service = project.get_service('test')
self.assertEqual(service._get_net(), 'container:' + container_id)
self.assertEqual(service.net.mode, 'container:' + container_id)
def test_use_net_from_service(self):
container_name = 'test_aaa_1'
@ -260,7 +260,7 @@ class ProjectTest(unittest.TestCase):
], self.mock_client)
service = project.get_service('test')
self.assertEqual(service._get_net(), 'container:' + container_name)
self.assertEqual(service.net.mode, 'container:' + container_name)
def test_container_without_name(self):
self.mock_client.containers.return_value = [

View File

@ -7,21 +7,25 @@ import mock
import docker
from docker.utils import LogConfig
from compose.service import Service
from compose.const import LABEL_CONFIG_HASH
from compose.const import LABEL_ONE_OFF
from compose.const import LABEL_PROJECT
from compose.const import LABEL_SERVICE
from compose.container import Container
from compose.const import LABEL_SERVICE, LABEL_PROJECT, LABEL_ONE_OFF
from compose.service import (
ConfigError,
NeedsBuildError,
NoSuchImageError,
build_port_bindings,
build_volume_binding,
get_container_data_volumes,
merge_volume_bindings,
parse_repository_tag,
parse_volume_spec,
split_port,
)
from compose.service import ConfigError
from compose.service import ContainerNet
from compose.service import NeedsBuildError
from compose.service import Net
from compose.service import NoSuchImageError
from compose.service import Service
from compose.service import ServiceNet
from compose.service import build_port_bindings
from compose.service import build_volume_binding
from compose.service import get_container_data_volumes
from compose.service import merge_volume_bindings
from compose.service import parse_repository_tag
from compose.service import parse_volume_spec
from compose.service import split_port
class ServiceTest(unittest.TestCase):
@ -221,6 +225,40 @@ class ServiceTest(unittest.TestCase):
self.assertEqual(opts['hostname'], 'name.sub', 'hostname')
self.assertEqual(opts['domainname'], 'domain.tld', 'domainname')
def test_get_container_create_options_does_not_mutate_options(self):
labels = {'thing': 'real'}
environment = {'also': 'real'}
service = Service(
'foo',
image='foo',
labels=dict(labels),
client=self.mock_client,
environment=dict(environment),
)
self.mock_client.inspect_image.return_value = {'Id': 'abcd'}
prev_container = mock.Mock(
id='ababab',
image_config={'ContainerConfig': {}})
opts = service._get_container_create_options(
{},
1,
previous_container=prev_container)
self.assertEqual(service.options['labels'], labels)
self.assertEqual(service.options['environment'], environment)
self.assertEqual(
opts['labels'][LABEL_CONFIG_HASH],
'3c85881a8903b9d73a06c41860c8be08acce1494ab4cf8408375966dccd714de')
self.assertEqual(
opts['environment'],
{
'affinity:container': '=ababab',
'also': 'real',
}
)
def test_get_container_not_found(self):
self.mock_client.containers.return_value = []
service = Service('foo', client=self.mock_client, image='foo')
@ -340,6 +378,90 @@ class ServiceTest(unittest.TestCase):
self.assertEqual(self.mock_client.build.call_count, 1)
self.assertFalse(self.mock_client.build.call_args[1]['pull'])
def test_config_dict(self):
self.mock_client.inspect_image.return_value = {'Id': 'abcd'}
service = Service(
'foo',
image='example.com/foo',
client=self.mock_client,
net=ServiceNet(Service('other', image='foo')),
links=[(Service('one', image='foo'), 'one')],
volumes_from=[Service('two', image='foo')])
config_dict = service.config_dict()
expected = {
'image_id': 'abcd',
'options': {'image': 'example.com/foo'},
'links': [('one', 'one')],
'net': 'other',
'volumes_from': ['two'],
}
self.assertEqual(config_dict, expected)
def test_config_dict_with_net_from_container(self):
self.mock_client.inspect_image.return_value = {'Id': 'abcd'}
container = Container(
self.mock_client,
{'Id': 'aaabbb', 'Name': '/foo_1'})
service = Service(
'foo',
image='example.com/foo',
client=self.mock_client,
net=container)
config_dict = service.config_dict()
expected = {
'image_id': 'abcd',
'options': {'image': 'example.com/foo'},
'links': [],
'net': 'aaabbb',
'volumes_from': [],
}
self.assertEqual(config_dict, expected)
class NetTestCase(unittest.TestCase):
def test_net(self):
net = Net('host')
self.assertEqual(net.id, 'host')
self.assertEqual(net.mode, 'host')
self.assertEqual(net.service_name, None)
def test_net_container(self):
container_id = 'abcd'
net = ContainerNet(Container(None, {'Id': container_id}))
self.assertEqual(net.id, container_id)
self.assertEqual(net.mode, 'container:' + container_id)
self.assertEqual(net.service_name, None)
def test_net_service(self):
container_id = 'bbbb'
service_name = 'web'
mock_client = mock.create_autospec(docker.Client)
mock_client.containers.return_value = [
{'Id': container_id, 'Name': container_id, 'Image': 'abcd'},
]
service = Service(name=service_name, client=mock_client, image='foo')
net = ServiceNet(service)
self.assertEqual(net.id, service_name)
self.assertEqual(net.mode, 'container:' + container_id)
self.assertEqual(net.service_name, service_name)
def test_net_service_no_containers(self):
service_name = 'web'
mock_client = mock.create_autospec(docker.Client)
mock_client.containers.return_value = []
service = Service(name=service_name, client=mock_client, image='foo')
net = ServiceNet(service)
self.assertEqual(net.id, service_name)
self.assertEqual(net.mode, None)
self.assertEqual(net.service_name, service_name)
def mock_get_image(images):
if images: