diff --git a/CHANGES.md b/CHANGES.md index 38a54324..0353edc6 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,68 @@ Change log ========== +1.4.2 (2015-09-22) +------------------ + +Fixes a regression in the 1.4.1 release that would cause `docker-compose up` +without the `-d` option to exit immediately. + + +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 ` 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) +------------------ + +- By default, `docker-compose up` now only recreates containers for services whose configuration has changed since they were created. This should result in a dramatic speed-up for many applications. + + The experimental `--x-smart-recreate` flag which introduced this feature in Compose 1.3.0 has been removed, and a `--force-recreate` flag has been added for when you want to recreate everything. + +- Several of Compose's commands - `scale`, `stop`, `kill` and `rm` - now perform actions on multiple containers in parallel, rather than in sequence, which will run much faster on larger applications. + +- You can now specify a custom name for a service's container with `container_name`. Because Docker container names must be unique, this means you can't scale the service beyond one container. + +- You no longer have to specify a `file` option when using `extends` - it will default to the current file. + +- Service names can now contain dots, dashes and underscores. + +- Compose can now read YAML configuration from standard input, rather than from a file, by specifying `-` as the filename. This makes it easier to generate configuration dynamically: + + $ echo 'redis: {"image": "redis"}' | docker-compose --file - up + +- There's a new `docker-compose version` command which prints extended information about Compose's bundled dependencies. + +- `docker-compose.yml` now supports `log_opt` as well as `log_driver`, allowing you to pass extra configuration to a service's logging driver. + +- `docker-compose.yml` now supports `memswap_limit`, similar to `docker run --memory-swap`. + +- When mounting volumes with the `volumes` option, you can now pass in any mode supported by the daemon, not just `:ro` or `:rw`. For example, SELinux users can pass `:z` or `:Z`. + +- You can now specify a custom volume driver with the `volume_driver` option in `docker-compose.yml`, much like `docker run --volume-driver`. + +- A bug has been fixed where Compose would fail to pull images from private registries serving plain (unsecured) HTTP. The `--allow-insecure-ssl` flag, which was previously used to work around this issue, has been deprecated and now has no effect. + +- A bug has been fixed where `docker-compose build` would fail if the build depended on a private Hub image or an image from a private registry. + +- A bug has been fixed where Compose would crash if there were containers which the Docker daemon had not finished removing. + +- Two bugs have been fixed where Compose would sometimes fail with a "Duplicate bind mount" error, or fail to attach volumes to a container, if there was a volume path specified in `docker-compose.yml` with a trailing slash. + +Thanks @mnowster, @dnephin, @ekristen, @funkyfuture, @jeffk and @lukemarsden! + 1.3.3 (2015-07-15) ------------------ diff --git a/Dockerfile b/Dockerfile index 738e0b99..7c048232 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,16 +48,16 @@ RUN set -ex; \ rm -rf pip-7.0.1; \ rm pip-7.0.1.tar.gz -ENV ALL_DOCKER_VERSIONS 1.6.2 1.7.1 +ENV ALL_DOCKER_VERSIONS 1.7.1 1.8.0-rc3 RUN set -ex; \ - curl https://get.docker.com/builds/Linux/x86_64/docker-1.6.2 -o /usr/local/bin/docker-1.6.2; \ - chmod +x /usr/local/bin/docker-1.6.2; \ curl https://get.docker.com/builds/Linux/x86_64/docker-1.7.1 -o /usr/local/bin/docker-1.7.1; \ - chmod +x /usr/local/bin/docker-1.7.1 + chmod +x /usr/local/bin/docker-1.7.1; \ + curl https://test.docker.com/builds/Linux/x86_64/docker-1.8.0-rc3 -o /usr/local/bin/docker-1.8.0-rc3; \ + chmod +x /usr/local/bin/docker-1.8.0-rc3 # Set the default Docker to be run -RUN ln -s /usr/local/bin/docker-1.6.2 /usr/local/bin/docker +RUN ln -s /usr/local/bin/docker-1.7.1 /usr/local/bin/docker RUN useradd -d /home/user -m -s /bin/bash user WORKDIR /code/ diff --git a/compose/__init__.py b/compose/__init__.py index 0d464ee8..af2bdbf2 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,3 +1,3 @@ from __future__ import unicode_literals -__version__ = '1.4.0dev' +__version__ = '1.4.2' diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index e513182f..244bcbef 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -14,6 +14,8 @@ def docker_client(): cert_path = os.path.join(os.environ.get('HOME', ''), '.docker') base_url = os.environ.get('DOCKER_HOST') + api_version = os.environ.get('COMPOSE_API_VERSION', '1.19') + tls_config = None if os.environ.get('DOCKER_TLS_VERIFY', '') != '': @@ -32,4 +34,4 @@ def docker_client(): ) timeout = int(os.environ.get('DOCKER_CLIENT_TIMEOUT', 60)) - return Client(base_url=base_url, tls=tls_config, version='1.18', timeout=timeout) + return Client(base_url=base_url, tls=tls_config, version=api_version, timeout=timeout) diff --git a/compose/cli/main.py b/compose/cli/main.py index 56f6c050..3504c241 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -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,29 @@ class TopLevelCommand(Command): print(get_version_info('full')) +def build_log_printer(containers, service_names, monochrome): + if service_names: + containers = [c for c in containers if c.service in service_names] + + return LogPrinter( + containers, + 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) diff --git a/compose/config.py b/compose/config.py index 064dadae..6bb0fea6 100644 --- a/compose/config.py +++ b/compose/config.py @@ -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', @@ -43,6 +43,7 @@ DOCKER_CONFIG_KEYS = [ 'stdin_open', 'tty', 'user', + 'volume_driver', 'volumes', 'volumes_from', 'working_dir', @@ -82,6 +83,13 @@ SUPPORTED_FILENAMES = [ ] +PATH_START_CHARS = [ + '/', + '.', + '~', +] + + log = logging.getLogger(__name__) @@ -251,8 +259,8 @@ def process_container_options(service_dict, working_dir=None): if 'memswap_limit' in service_dict and 'mem_limit' not in service_dict: raise ConfigurationError("Invalid 'memswap_limit' configuration for %s service: when defining 'memswap_limit' you must set 'mem_limit' as well" % service_dict['name']) - if 'volumes' in service_dict: - service_dict['volumes'] = resolve_volume_paths(service_dict['volumes'], working_dir=working_dir) + if 'volumes' in service_dict and service_dict.get('volume_driver') is None: + service_dict['volumes'] = resolve_volume_paths(service_dict, working_dir=working_dir) if 'build' in service_dict: service_dict['build'] = resolve_build_path(service_dict['build'], working_dir=working_dir) @@ -374,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," % @@ -413,18 +421,33 @@ def env_vars_from_file(filename): return env -def resolve_volume_paths(volumes, working_dir=None): +def resolve_volume_paths(service_dict, working_dir=None): if working_dir is None: raise Exception("No working_dir passed to resolve_volume_paths()") - return [resolve_volume_path(v, working_dir) for v in volumes] + return [ + resolve_volume_path(v, working_dir, service_dict['name']) + for v in service_dict['volumes'] + ] -def resolve_volume_path(volume, working_dir): +def resolve_volume_path(volume, working_dir, service_name): container_path, host_path = split_path_mapping(volume) container_path = os.path.expanduser(os.path.expandvars(container_path)) + if host_path is not None: host_path = os.path.expanduser(os.path.expandvars(host_path)) + + if not any(host_path.startswith(c) for c in PATH_START_CHARS): + log.warn( + '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}:{1}"' + .format(host_path, container_path, service_name) + ) + return "%s:%s" % (expand_path(working_dir, host_path), container_path) else: return container_path diff --git a/compose/container.py b/compose/container.py index 71951497..6f88b41d 100644 --- a/compose/container.py +++ b/compose/container.py @@ -22,10 +22,14 @@ class Container(object): """ Construct a container object from the output of GET /containers/json. """ + name = get_container_name(dictionary) + if name is None: + return None + new_dictionary = { 'Id': dictionary['Id'], 'Image': dictionary['Image'], - 'Name': '/' + get_container_name(dictionary), + 'Name': '/' + name, } return cls(client, new_dictionary, **kwargs) @@ -58,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): diff --git a/compose/project.py b/compose/project.py index 2667855d..abc3132a 100644 --- a/compose/project.py +++ b/compose/project.py @@ -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): @@ -310,11 +319,11 @@ class Project(object): else: service_names = self.service_names - containers = [ + containers = filter(None, [ Container.from_ps(self.client, container) for container in self.client.containers( all=stopped, - filters={'label': self.labels(one_off=one_off)})] + filters={'label': self.labels(one_off=one_off)})]) def matches_service_names(container): return container.labels.get(LABEL_SERVICE) in service_names diff --git a/compose/service.py b/compose/service.py index b9b4ed3e..b3c68735 100644 --- a/compose/service.py +++ b/compose/service.py @@ -3,6 +3,7 @@ from __future__ import absolute_import from collections import namedtuple import logging import re +import os import sys from operator import attrgetter @@ -41,6 +42,8 @@ DOCKER_START_KEYS = [ 'net', 'log_driver', 'log_opt', + 'mem_limit', + 'memswap_limit', 'pid', 'privileged', 'restart', @@ -80,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): @@ -94,17 +106,16 @@ 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): - containers = [ + containers = filter(None, [ Container.from_ps(self.client, container) for container in self.client.containers( all=stopped, - filters={'label': self.labels(one_off=one_off)})] + filters={'label': self.labels(one_off=one_off)})]) if not containers: check_for_legacy_containers( @@ -466,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) @@ -493,12 +504,13 @@ class Service(object): # TODO: this would benefit from github.com/docker/docker/pull/11943 # to remove the need to inspect every container def _next_container_number(self, one_off=False): - numbers = [ - Container.from_ps(self.client, container).number + containers = filter(None, [ + Container.from_ps(self.client, container) for container in self.client.containers( all=True, filters={'label': self.labels(one_off=one_off)}) - ] + ]) + numbers = [c.number for c in containers] return 1 if not numbers else max(numbers) + 1 def _get_links(self, link_to_self): @@ -513,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: @@ -536,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( @@ -574,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 @@ -628,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: @@ -675,13 +661,15 @@ 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, restart_policy=restart, cap_add=cap_add, cap_drop=cap_drop, + mem_limit=options.get('mem_limit'), + memswap_limit=options.get('memswap_limit'), log_config=log_config, extra_hosts=extra_hosts, read_only=read_only, @@ -768,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 @@ -848,12 +891,15 @@ def parse_volume_spec(volume_config): "external:internal[:mode]" % volume_config) if len(parts) == 1: - return VolumeSpec(None, parts[0], 'rw') + external = None + internal = os.path.normpath(parts[0]) + else: + external = os.path.normpath(parts[0]) + internal = os.path.normpath(parts[1]) - if len(parts) == 2: - parts.append('rw') + mode = parts[2] if len(parts) == 3 else 'rw' - return VolumeSpec(*parts) + return VolumeSpec(external, internal, mode) # Ports @@ -890,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 diff --git a/compose/utils.py b/compose/utils.py index 4c7f94c5..61d6d802 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -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: diff --git a/docker b/docker new file mode 100755 index 00000000..f24f3613 Binary files /dev/null and b/docker differ diff --git a/docs/Dockerfile b/docs/Dockerfile index d6864c2d..d9add75c 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -6,6 +6,14 @@ COPY . /src COPY . /docs/content/compose/ +RUN svn checkout https://github.com/docker/docker/trunk/docs /docs/content/docker +RUN svn checkout https://github.com/docker/swarm/trunk/docs /docs/content/swarm +RUN svn checkout https://github.com/docker/machine/trunk/docs /docs/content/machine +RUN svn checkout https://github.com/docker/distribution/trunk/docs /docs/content/registry +RUN svn checkout https://github.com/docker/tutorials/trunk/docs /docs/content/tutorials +RUN svn checkout https://github.com/docker/opensource/trunk/docs /docs/content + + # Sed to process GitHub Markdown # 1-2 Remove comment code from metadata block # 3 Change ](/word to ](/project/ in links @@ -15,10 +23,4 @@ COPY . /docs/content/compose/ # 7 Change ](../../ to ](/project/ --> not implemented # # -RUN find /docs/content/compose -type f -name "*.md" -exec sed -i.old \ - -e '/^/g' \ - -e '/^/g' \ - -e 's/\(\]\)\([(]\)\(\/\)/\1\2\/compose\//g' \ - -e 's/\(\][(]\)\([A-z].*\)\(\.md\)/\1\/compose\/\2/g' \ - -e 's/\([(]\)\(.*\)\(\.md\)/\1\2/g' \ - -e 's/\(\][(]\)\(\.\.\/\)/\1\/compose\//g' {} \; +RUN /src/pre-process.sh /docs diff --git a/docs/install.md b/docs/install.md index dad6efd5..b74f8f62 100644 --- a/docs/install.md +++ b/docs/install.md @@ -12,48 +12,67 @@ weight=4 # Install Docker Compose -To install Compose, you'll need to install Docker first. You'll then install -Compose with a `curl` command. +You can run Compose on OS X and 64-bit Linux. It is currently not supported on +the Windows operating system. To install Compose, you'll need to install Docker +first. -## Install Docker +Depending on how your system is configured, you may require `sudo` access to +install Compose. If your system requires `sudo`, you will receive "Permission +denied" errors when installing Compose. If this is the case for you, preface the +install commands with `sudo` to install. -First, install Docker version 1.6 or greater: +To install Compose, do the following: -- [Instructions for Mac OS X](http://docs.docker.com/installation/mac/) -- [Instructions for Ubuntu](http://docs.docker.com/installation/ubuntulinux/) -- [Instructions for other systems](http://docs.docker.com/installation/) +1. Install Docker Engine version 1.7.1 or greater: -## Install Compose + * Mac OS X installation (installs both Engine and Compose) + + * Ubuntu installation + + * other system installations + +2. Mac OS X users are done installing. Others should continue to the next step. + +3. Go to the repository release page. -To install Compose, run the following commands: +4. Enter the `curl` command in your termial. - curl -L https://github.com/docker/compose/releases/download/1.3.3/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose - chmod +x /usr/local/bin/docker-compose + The command has the following format: -> Note: If you get a "Permission denied" error, your `/usr/local/bin` directory probably isn't writable and you'll need to install Compose as the superuser. Run `sudo -i`, then the two commands above, then `exit`. + curl -L https://github.com/docker/compose/releases/download/VERSION_NUM/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose + + If you have problems installing with `curl`, you can use `pip` instead: `pip install -U docker-compose` + +4. Apply executable permissions to the binary: -Optionally, you can also install [command completion](completion.md) for the -bash and zsh shell. + $ chmod +x /usr/local/bin/docker-compose -Compose is available for OS X and 64-bit Linux. If you're on another platform, -Compose can also be installed as a Python package: +5. Optionally, install [command completion](completion.md) for the +`bash` and `zsh` shell. - $ sudo pip install -U docker-compose +6. Test the installation. -No further steps are required; Compose should now be successfully installed. -You can test the installation by running `docker-compose --version`. + $ docker-compose --version + docker-compose version: 1.4.2 -### Upgrading +## Upgrading -If you're coming from Compose 1.2 or earlier, you'll need to remove or migrate your existing containers after upgrading Compose. This is because, as of version 1.3, Compose uses Docker labels to keep track of containers, and so they need to be recreated with labels added. +If you're upgrading from Compose 1.2 or earlier, you'll need to remove or migrate +your existing containers after upgrading Compose. This is because, as of version +1.3, Compose uses Docker labels to keep track of containers, and so they need to +be recreated with labels added. -If Compose detects containers that were created without labels, it will refuse to run so that you don't end up with two sets of them. If you want to keep using your existing containers (for example, because they have data volumes you want to preserve) you can migrate them with the following command: +If Compose detects containers that were created without labels, it will refuse +to run so that you don't end up with two sets of them. If you want to keep using +your existing containers (for example, because they have data volumes you want +to preserve) you can migrate them with the following command: - docker-compose migrate-to-labels + $ docker-compose migrate-to-labels -Alternatively, if you're not worried about keeping them, you can remove them - Compose will just create new ones. +Alternatively, if you're not worried about keeping them, you can remove them &endash; +Compose will just create new ones. - docker rm -f myapp_web_1 myapp_db_1 ... + $ docker rm -f -v myapp_web_1 myapp_db_1 ... ## Uninstallation @@ -67,10 +86,13 @@ To uninstall Docker Compose if you installed using `pip`: $ pip uninstall docker-compose -> Note: If you get a "Permission denied" error using either of the above methods, you probably do not have the proper permissions to remove `docker-compose`. To force the removal, prepend `sudo` to either of the above commands and run again. +>**Note**: If you get a "Permission denied" error using either of the above +>methods, you probably do not have the proper permissions to remove +>`docker-compose`. To force the removal, prepend `sudo` to either of the above +>commands and run again. -## Compose documentation +## Where to go next - [User guide](/) - [Get started with Django](django.md) diff --git a/docs/pre-process.sh b/docs/pre-process.sh new file mode 100755 index 00000000..75e9611f --- /dev/null +++ b/docs/pre-process.sh @@ -0,0 +1,61 @@ +#!/bin/bash -e + +# Populate an array with just docker dirs and one with content dirs +docker_dir=(`ls -d /docs/content/docker/*`) +content_dir=(`ls -d /docs/content/*`) + +# Loop content not of docker/ +# +# Sed to process GitHub Markdown +# 1-2 Remove comment code from metadata block +# 3 Remove .md extension from link text +# 4 Change ](/ to ](/project/ in links +# 5 Change ](word) to ](/project/word) +# 6 Change ](../../ to ](/project/ +# 7 Change ](../ to ](/project/word) +# +for i in "${content_dir[@]}" +do + : + case $i in + "/docs/content/windows") + ;; + "/docs/content/mac") + ;; + "/docs/content/linux") + ;; + "/docs/content/docker") + y=${i##*/} + find $i -type f -name "*.md" -exec sed -i.old \ + -e '/^/g' \ + -e '/^/g' {} \; + ;; + *) + y=${i##*/} + find $i -type f -name "*.md" -exec sed -i.old \ + -e '/^/g' \ + -e '/^/g' \ + -e 's/\(\]\)\([(]\)\(\/\)/\1\2\/'$y'\//g' \ + -e 's/\(\][(]\)\([A-z].*\)\(\.md\)/\1\/'$y'\/\2/g' \ + -e 's/\([(]\)\(.*\)\(\.md\)/\1\2/g' \ + -e 's/\(\][(]\)\(\.\/\)/\1\/'$y'\//g' \ + -e 's/\(\][(]\)\(\.\.\/\.\.\/\)/\1\/'$y'\//g' \ + -e 's/\(\][(]\)\(\.\.\/\)/\1\/'$y'\//g' {} \; + ;; + esac +done + +# +# Move docker directories to content +# +for i in "${docker_dir[@]}" +do + : + if [ -d $i ] + then + mv $i /docs/content/ + fi +done + +rm -rf /docs/content/docker + diff --git a/docs/rails.md b/docs/rails.md index 7394aadc..9ce6c4a6 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -40,8 +40,6 @@ Finally, `docker-compose.yml` is where the magic happens. This file describes th db: image: postgres - ports: - - "5432" web: build: . command: bundle exec rails s -p 3000 -b '0.0.0.0' diff --git a/docs/reference/build.md b/docs/reference/build.md index b2b01511..b6e27bb2 100644 --- a/docs/reference/build.md +++ b/docs/reference/build.md @@ -4,6 +4,7 @@ title = "build" description = "build" keywords = ["fig, composition, compose, docker, orchestration, cli, build"] [menu.main] +identifier="build.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/help.md b/docs/reference/help.md index 229ac5de..613708ed 100644 --- a/docs/reference/help.md +++ b/docs/reference/help.md @@ -4,6 +4,7 @@ title = "help" description = "help" keywords = ["fig, composition, compose, docker, orchestration, cli, help"] [menu.main] +identifier="help.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/kill.md b/docs/reference/kill.md index c7160874..e5dd0573 100644 --- a/docs/reference/kill.md +++ b/docs/reference/kill.md @@ -4,6 +4,7 @@ title = "kill" description = "Forces running containers to stop." keywords = ["fig, composition, compose, docker, orchestration, cli, kill"] [menu.main] +identifier="kill.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/logs.md b/docs/reference/logs.md index 87f93727..5b241ea7 100644 --- a/docs/reference/logs.md +++ b/docs/reference/logs.md @@ -4,6 +4,7 @@ title = "logs" description = "Displays log output from services." keywords = ["fig, composition, compose, docker, orchestration, cli, logs"] [menu.main] +identifier="logs.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/port.md b/docs/reference/port.md index 4745c92d..76f93f23 100644 --- a/docs/reference/port.md +++ b/docs/reference/port.md @@ -4,6 +4,7 @@ title = "port" description = "Prints the public port for a port binding.s" keywords = ["fig, composition, compose, docker, orchestration, cli, port"] [menu.main] +identifier="port.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/ps.md b/docs/reference/ps.md index b271376f..546d68e7 100644 --- a/docs/reference/ps.md +++ b/docs/reference/ps.md @@ -4,6 +4,7 @@ title = "ps" description = "Lists containers." keywords = ["fig, composition, compose, docker, orchestration, cli, ps"] [menu.main] +identifier="ps.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/pull.md b/docs/reference/pull.md index ac22010e..e5b5d166 100644 --- a/docs/reference/pull.md +++ b/docs/reference/pull.md @@ -4,6 +4,7 @@ title = "pull" description = "Pulls service images." keywords = ["fig, composition, compose, docker, orchestration, cli, pull"] [menu.main] +identifier="pull.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/restart.md b/docs/reference/restart.md index 9b570082..bbd4a68b 100644 --- a/docs/reference/restart.md +++ b/docs/reference/restart.md @@ -4,6 +4,7 @@ title = "restart" description = "Restarts Docker Compose services." keywords = ["fig, composition, compose, docker, orchestration, cli, restart"] [menu.main] +identifier="restart.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/rm.md b/docs/reference/rm.md index 0a4ba5b6..2ed959e4 100644 --- a/docs/reference/rm.md +++ b/docs/reference/rm.md @@ -4,6 +4,7 @@ title = "rm" description = "Removes stopped service containers." keywords = ["fig, composition, compose, docker, orchestration, cli, rm"] [menu.main] +identifier="rm.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/run.md b/docs/reference/run.md index b07ddd06..5ea9a61b 100644 --- a/docs/reference/run.md +++ b/docs/reference/run.md @@ -4,6 +4,7 @@ title = "run" description = "Runs a one-off command on a service." keywords = ["fig, composition, compose, docker, orchestration, cli, run"] [menu.main] +identifier="run.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/start.md b/docs/reference/start.md index 69d853f9..f0bdd5a9 100644 --- a/docs/reference/start.md +++ b/docs/reference/start.md @@ -4,6 +4,7 @@ title = "start" description = "Starts existing containers for a service." keywords = ["fig, composition, compose, docker, orchestration, cli, start"] [menu.main] +identifier="start.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/stop.md b/docs/reference/stop.md index 8ff92129..ec7e6688 100644 --- a/docs/reference/stop.md +++ b/docs/reference/stop.md @@ -4,6 +4,7 @@ title = "stop" description = "Stops running containers without removing them. " keywords = ["fig, composition, compose, docker, orchestration, cli, stop"] [menu.main] +identifier="stop.compose" parent = "smn_compose_cli" +++ diff --git a/docs/reference/up.md b/docs/reference/up.md index 441d7f9c..966aff1e 100644 --- a/docs/reference/up.md +++ b/docs/reference/up.md @@ -4,6 +4,7 @@ title = "up" description = "Builds, (re)creates, starts, and attaches to containers for a service." keywords = ["fig, composition, compose, docker, orchestration, cli, up"] [menu.main] +identifier="up.compose" parent = "smn_compose_cli" +++ diff --git a/docs/yml.md b/docs/yml.md index f92b5682..bd339ec1 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -131,9 +131,16 @@ Mount paths as volumes, optionally specifying a path on the host machine volumes: - /var/lib/mysql - - cache/:/tmp/cache + - ./cache:/tmp/cache - ~/configs:/etc/configs/:ro +You can mount a relative path on the host, which will expand relative to +the directory of the Compose configuration file being used. Relative paths +should always begin with `.` or `..`. + +> Note: No path expansion will be done if you have also specified a +> `volume_driver`. + ### volumes_from Mount all of the volumes from another service or container. @@ -333,7 +340,7 @@ Override the default labeling scheme for each container. - label:user:USER - label:role:ROLE -### working\_dir, entrypoint, user, hostname, domainname, mac\_address, mem\_limit, memswap\_limit, privileged, restart, stdin\_open, tty, cpu\_shares, cpuset, read\_only +### working\_dir, entrypoint, user, hostname, domainname, mac\_address, mem\_limit, memswap\_limit, privileged, restart, stdin\_open, tty, cpu\_shares, cpuset, read\_only, volume\_driver Each of these is a single value, analogous to its [docker run](https://docs.docker.com/reference/run/) counterpart. @@ -360,6 +367,8 @@ Each of these is a single value, analogous to its tty: true read_only: true + volume_driver: mydriver +``` ## Compose documentation diff --git a/script/wrapdocker b/script/wrapdocker index 2e07bdad..3e669b5d 100755 --- a/script/wrapdocker +++ b/script/wrapdocker @@ -7,10 +7,17 @@ fi # If a pidfile is still around (for example after a container restart), # delete it so that docker can start. rm -rf /var/run/docker.pid -docker -d $DOCKER_DAEMON_ARGS &>/var/log/docker.log & +docker -d --storage-driver="overlay" &>/var/log/docker.log & +docker_pid=$! >&2 echo "Waiting for Docker to start..." while ! docker ps &>/dev/null; do + if ! kill -0 "$docker_pid" &>/dev/null; then + >&2 echo "Docker failed to start" + cat /var/log/docker.log + exit 1 + fi + sleep 1 done diff --git a/tests/__init__.py b/tests/__init__.py index 08a7865e..c7a1bd4a 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,5 +1,7 @@ import sys +import mock # noqa + if sys.version_info >= (2, 7): import unittest # NOQA else: diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index f3b3b9f5..c54a85bb 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -4,6 +4,7 @@ import sys import os import shlex +import mock from six import StringIO from mock import patch @@ -104,7 +105,7 @@ class CLITestCase(DockerClientTestCase): output = mock_stdout.getvalue() self.assertNotIn(cache_indicator, output) - def test_up(self): + def test_up_detached(self): self.command.dispatch(['up', '-d'], None) service = self.project.get_service('simple') another = self.project.get_service('another') @@ -112,10 +113,28 @@ class CLITestCase(DockerClientTestCase): self.assertEqual(len(another.containers()), 1) # Ensure containers don't have stdin and stdout connected in -d mode - config = service.containers()[0].inspect()['Config'] - self.assertFalse(config['AttachStderr']) - self.assertFalse(config['AttachStdout']) - self.assertFalse(config['AttachStdin']) + container, = service.containers() + self.assertFalse(container.get('Config.AttachStderr')) + self.assertFalse(container.get('Config.AttachStdout')) + self.assertFalse(container.get('Config.AttachStdin')) + + def test_up_attached(self): + with mock.patch( + 'compose.cli.main.attach_to_logs', + autospec=True + ) as mock_attach: + self.command.dispatch(['up'], None) + _, args, kwargs = mock_attach.mock_calls[0] + _project, log_printer, _names, _timeout = args + + service = self.project.get_service('simple') + another = self.project.get_service('another') + self.assertEqual(len(service.containers()), 1) + self.assertEqual(len(another.containers()), 1) + self.assertEqual( + set(log_printer.containers), + set(self.project.containers()) + ) def test_up_with_links(self): self.command.base_dir = 'tests/fixtures/links-composefile' diff --git a/tests/integration/legacy_test.py b/tests/integration/legacy_test.py index f79089b2..9913bbb0 100644 --- a/tests/integration/legacy_test.py +++ b/tests/integration/legacy_test.py @@ -65,7 +65,7 @@ class UtilitiesTestCase(unittest.TestCase): legacy.is_valid_name("composetest_web_lol_1", one_off=True), ) - def test_get_legacy_containers_no_labels(self): + def test_get_legacy_containers(self): client = Mock() client.containers.return_value = [ { @@ -74,12 +74,23 @@ class UtilitiesTestCase(unittest.TestCase): "Name": "composetest_web_1", "Labels": None, }, + { + "Id": "ghi789", + "Image": "def456", + "Name": None, + "Labels": None, + }, + { + "Id": "jkl012", + "Image": "def456", + "Labels": None, + }, ] - containers = list(legacy.get_legacy_containers( - client, "composetest", ["web"])) + containers = legacy.get_legacy_containers(client, "composetest", ["web"]) self.assertEqual(len(containers), 1) + self.assertEqual(containers[0].id, 'abc123') class LegacyTestCase(DockerClientTestCase): diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 9788c186..a0fbe3e1 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -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') diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 60e2eed1..aec2caf1 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -9,6 +9,8 @@ import tempfile import shutil from six import StringIO, text_type +from .testcases import DockerClientTestCase +from .testcases import pull_busybox from compose import __version__ from compose.const import ( LABEL_CONTAINER_NUMBER, @@ -17,14 +19,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): @@ -117,11 +117,17 @@ class ServiceTest(DockerClientTestCase): service.start_container(container) self.assertIn('/var/db', container.get('Volumes')) + def test_create_container_with_volume_driver(self): + service = self.create_service('db', volume_driver='foodriver') + container = service.create_container() + service.start_container(container) + self.assertEqual('foodriver', container.get('Config.VolumeDriver')) + def test_create_container_with_cpu_shares(self): service = self.create_service('db', cpu_shares=73) container = service.create_container() service.start_container(container) - self.assertEqual(container.inspect()['Config']['CpuShares'], 73) + self.assertEqual(container.get('HostConfig.CpuShares'), 73) def test_build_extra_hosts(self): # string @@ -183,7 +189,7 @@ class ServiceTest(DockerClientTestCase): service = self.create_service('db', cpuset='0') container = service.create_container() service.start_container(container) - self.assertEqual(container.inspect()['Config']['Cpuset'], '0') + self.assertEqual(container.get('HostConfig.CpusetCpus'), '0') def test_create_container_with_read_only_root_fs(self): read_only = True @@ -221,6 +227,52 @@ class ServiceTest(DockerClientTestCase): self.assertTrue(path.basename(actual_host_path) == path.basename(host_path), msg=("Last component differs: %s, %s" % (actual_host_path, host_path))) + def test_recreate_preserves_volume_with_trailing_slash(self): + """ + When the Compose file specifies a trailing slash in the container path, make + sure we copy the volume over when recreating. + """ + service = self.create_service('data', volumes=['/data/']) + old_container = create_and_start_container(service) + volume_path = old_container.get('Volumes')['/data'] + + new_container = service.recreate_container(old_container) + self.assertEqual(new_container.get('Volumes')['/data'], volume_path) + + def test_duplicate_volume_trailing_slash(self): + """ + When an image specifies a volume, and the Compose file specifies a host path + but adds a trailing slash, make sure that we don't create duplicate binds. + """ + host_path = '/tmp/data' + container_path = '/data' + volumes = ['{}:{}/'.format(host_path, container_path)] + + tmp_container = self.client.create_container( + 'busybox', 'true', + volumes={container_path: {}}, + labels={'com.docker.compose.test_image': 'true'}, + ) + image = self.client.commit(tmp_container)['Id'] + + service = self.create_service('db', image=image, volumes=volumes) + old_container = create_and_start_container(service) + + self.assertEqual( + old_container.get('Config.Volumes'), + {container_path: {}}, + ) + + service = self.create_service('db', image=image, volumes=volumes) + new_container = service.recreate_container(old_container) + + self.assertEqual( + new_container.get('Config.Volumes'), + {container_path: {}}, + ) + + self.assertEqual(service.containers(stopped=False), [new_container]) + @patch.dict(os.environ) def test_create_container_with_home_and_env_var_in_volume_path(self): os.environ['VOLUME_NAME'] = 'my-volume' @@ -526,8 +578,10 @@ class ServiceTest(DockerClientTestCase): }) def test_create_with_image_id(self): - # Image id for the current busybox:latest - service = self.create_service('foo', image='8c2e06607696') + # Get image id for the current busybox:latest + pull_busybox(self.client) + image_id = self.client.inspect_image('busybox:latest')['Id'][:12] + service = self.create_service('foo', image=image_id) service.create_container() def test_scale(self): @@ -620,6 +674,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): """ @@ -672,17 +745,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') diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index b124b19f..b2543761 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -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, diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index 2a7c0a44..41b50a81 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -1,5 +1,8 @@ from __future__ import unicode_literals from __future__ import absolute_import + +from docker import errors + from compose.service import Service from compose.config import ServiceLoader from compose.const import LABEL_PROJECT @@ -8,6 +11,13 @@ from compose.progress_stream import stream_output from .. import unittest +def pull_busybox(client): + try: + client.inspect_image('busybox:latest') + except errors.APIError: + client.pull('busybox:latest', stream=False) + + class DockerClientTestCase(unittest.TestCase): @classmethod def setUpClass(cls): diff --git a/tests/unit/cli/main_test.py b/tests/unit/cli/main_test.py new file mode 100644 index 00000000..e3a4629e --- /dev/null +++ b/tests/unit/cli/main_test.py @@ -0,0 +1,57 @@ +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_build_log_printer_all_services(self): + containers = [ + mock_container('web', 1), + mock_container('db', 1), + mock_container('other', 1), + ] + service_names = [] + log_printer = build_log_printer(containers, service_names, True) + self.assertEqual(log_printer.containers, containers) + + 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) diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py index 281717db..cefb1a20 100644 --- a/tests/unit/config_test.py +++ b/tests/unit/config_test.py @@ -9,7 +9,7 @@ from compose import config def make_service_dict(name, service_dict, working_dir): """ - Test helper function to contruct a ServiceLoader + Test helper function to construct a ServiceLoader """ return config.ServiceLoader(working_dir=working_dir).make_service_dict(name, service_dict) @@ -72,6 +72,67 @@ class VolumePathTest(unittest.TestCase): d = make_service_dict('foo', {'volumes': ['~:/container/path']}, working_dir='.') self.assertEqual(d['volumes'], ['/home/user:/container/path']) + @mock.patch.dict(os.environ) + def test_volume_binding_with_local_dir_name_raises_warning(self): + def make_dict(**config): + make_service_dict('foo', config, working_dir='.') + + with mock.patch('compose.config.log.warn') as warn: + make_dict(volumes=['/container/path']) + self.assertEqual(0, warn.call_count) + + make_dict(volumes=['/data:/container/path']) + self.assertEqual(0, warn.call_count) + + make_dict(volumes=['.:/container/path']) + self.assertEqual(0, warn.call_count) + + make_dict(volumes=['..:/container/path']) + self.assertEqual(0, warn.call_count) + + make_dict(volumes=['./data:/container/path']) + self.assertEqual(0, warn.call_count) + + make_dict(volumes=['../data:/container/path']) + self.assertEqual(0, warn.call_count) + + make_dict(volumes=['.profile:/container/path']) + self.assertEqual(0, warn.call_count) + + make_dict(volumes=['~:/container/path']) + self.assertEqual(0, warn.call_count) + + make_dict(volumes=['~/data:/container/path']) + self.assertEqual(0, warn.call_count) + + make_dict(volumes=['~tmp:/container/path']) + self.assertEqual(0, warn.call_count) + + make_dict(volumes=['data:/container/path'], volume_driver='mydriver') + self.assertEqual(0, warn.call_count) + + make_dict(volumes=['data:/container/path']) + self.assertEqual(1, warn.call_count) + warning = warn.call_args[0][0] + self.assertIn('"data:/container/path"', warning) + self.assertIn('"./data:/container/path"', warning) + + def test_named_volume_with_driver_does_not_expand(self): + d = make_service_dict('foo', { + 'volumes': ['namedvolume:/data'], + 'volume_driver': 'foodriver', + }, working_dir='.') + self.assertEqual(d['volumes'], ['namedvolume:/data']) + + @mock.patch.dict(os.environ) + def test_named_volume_with_special_chars(self): + os.environ['NAME'] = 'surprise!' + d = make_service_dict('foo', { + 'volumes': ['~/${NAME}:/data'], + 'volume_driver': 'foodriver', + }, working_dir='.') + self.assertEqual(d['volumes'], ['~/${NAME}:/data']) + class MergePathMappingTest(object): def config_name(self): @@ -451,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': { @@ -471,7 +532,7 @@ class ExtendsTest(unittest.TestCase): 'image': 'busybox', 'name': 'web' } - ]) + ])) def test_circular(self): try: diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index 39ad30a1..a66aaf5d 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -3,6 +3,7 @@ from .. import unittest from compose.service import Service from compose.project import Project from compose.container import Container +from compose.const import LABEL_SERVICE import mock import docker @@ -219,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): @@ -234,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' @@ -259,4 +260,28 @@ 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 = [ + {'Image': 'busybox:latest', 'Id': '1', 'Name': '1'}, + {'Image': 'busybox:latest', 'Id': '2', 'Name': None}, + {'Image': 'busybox:latest', 'Id': '3'}, + ] + self.mock_client.inspect_container.return_value = { + 'Id': '1', + 'Config': { + 'Labels': { + LABEL_SERVICE: 'web', + }, + }, + } + project = Project.from_dicts( + 'test', + [{ + 'name': 'web', + 'image': 'busybox:latest', + }], + self.mock_client, + ) + self.assertEqual([c.id for c in project.containers()], ['1']) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index bc6b9e48..263c9b32 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -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): @@ -76,6 +80,18 @@ class ServiceTest(unittest.TestCase): all=False, filters={'label': expected_labels}) + def test_container_without_name(self): + self.mock_client.containers.return_value = [ + {'Image': 'foo', 'Id': '1', 'Name': '1'}, + {'Image': 'foo', 'Id': '2', 'Name': None}, + {'Image': 'foo', 'Id': '3'}, + ] + service = Service('db', self.mock_client, 'myproject', image='foo') + + self.assertEqual([c.id for c in service.containers()], ['1']) + self.assertEqual(service._next_container_number(), 2) + self.assertEqual(service.get_container(1).id, '1') + def test_get_volumes_from_container(self): container_id = 'aabbccddee' service = Service( @@ -161,8 +177,8 @@ class ServiceTest(unittest.TestCase): service = Service(name='foo', image='foo', hostname='name', client=self.mock_client, mem_limit=1000000000, memswap_limit=2000000000) self.mock_client.containers.return_value = [] opts = service._get_container_create_options({'some': 'overrides'}, 1) - self.assertEqual(opts['memswap_limit'], 2000000000) - self.assertEqual(opts['mem_limit'], 1000000000) + self.assertEqual(opts['host_config']['MemorySwap'], 2000000000) + self.assertEqual(opts['host_config']['Memory'], 1000000000) def test_log_opt(self): log_opt = {'address': 'tcp://192.168.0.42:123'} @@ -209,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') @@ -328,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: