diff --git a/docker/api/client.py b/docker/api/client.py index cbe74b916f..f0a86d4596 100644 --- a/docker/api/client.py +++ b/docker/api/client.py @@ -63,21 +63,21 @@ class APIClient( >>> import docker >>> client = docker.APIClient(base_url='unix://var/run/docker.sock') >>> client.version() - {u'ApiVersion': u'1.24', + {u'ApiVersion': u'1.33', u'Arch': u'amd64', - u'BuildTime': u'2016-09-27T23:38:15.810178467+00:00', - u'Experimental': True, - u'GitCommit': u'45bed2c', - u'GoVersion': u'go1.6.3', - u'KernelVersion': u'4.4.22-moby', + u'BuildTime': u'2017-11-19T18:46:37.000000000+00:00', + u'GitCommit': u'f4ffd2511c', + u'GoVersion': u'go1.9.2', + u'KernelVersion': u'4.14.3-1-ARCH', + u'MinAPIVersion': u'1.12', u'Os': u'linux', - u'Version': u'1.12.2-rc1'} + u'Version': u'17.10.0-ce'} Args: base_url (str): URL to the Docker server. For example, ``unix:///var/run/docker.sock`` or ``tcp://127.0.0.1:1234``. version (str): The version of the API to use. Set to ``auto`` to - automatically detect the server's version. Default: ``1.26`` + automatically detect the server's version. Default: ``1.30`` timeout (int): Default timeout for API calls, in seconds. tls (bool or :py:class:`~docker.tls.TLSConfig`): Enable TLS. Pass ``True`` to enable it with default options, or pass a @@ -206,7 +206,7 @@ def _url(self, pathfmt, *args, **kwargs): 'instead'.format(arg, type(arg)) ) - quote_f = partial(six.moves.urllib.parse.quote_plus, safe="/:") + quote_f = partial(six.moves.urllib.parse.quote, safe="/:") args = map(quote_f, args) if kwargs.get('versioned_api', True): diff --git a/docker/api/container.py b/docker/api/container.py index f3c33c9786..5d58851284 100644 --- a/docker/api/container.py +++ b/docker/api/container.py @@ -139,7 +139,8 @@ def containers(self, quiet=False, all=False, trunc=False, latest=False, Args: quiet (bool): Only display numeric Ids all (bool): Show all containers. Only running containers are shown - by default trunc (bool): Truncate output + by default + trunc (bool): Truncate output latest (bool): Show only the latest created container, include non-running ones. since (str): Show only containers created since Id or Name, include @@ -1112,20 +1113,26 @@ def stats(self, container, decode=None, stream=True): json=True) @utils.check_resource('container') - def stop(self, container, timeout=10): + def stop(self, container, timeout=None): """ Stops a container. Similar to the ``docker stop`` command. Args: container (str): The container to stop timeout (int): Timeout in seconds to wait for the container to - stop before sending a ``SIGKILL``. Default: 10 + stop before sending a ``SIGKILL``. If None, then the + StopTimeout value of the container will be used. + Default: None Raises: :py:class:`docker.errors.APIError` If the server returns an error. """ - params = {'t': timeout} + if timeout is None: + params = {} + timeout = 10 + else: + params = {'t': timeout} url = self._url("/containers/{0}/stop", container) res = self._post(url, params=params, diff --git a/docker/api/service.py b/docker/api/service.py index 4c10ef8efd..86f4b07361 100644 --- a/docker/api/service.py +++ b/docker/api/service.py @@ -19,6 +19,10 @@ def raise_version_error(param, min_version): if 'Monitor' in update_config: raise_version_error('UpdateConfig.monitor', '1.25') + if utils.version_lt(version, '1.29'): + if 'Order' in update_config: + raise_version_error('UpdateConfig.order', '1.29') + if task_template is not None: if 'ForceUpdate' in task_template and utils.version_lt( version, '1.25'): @@ -62,6 +66,21 @@ def raise_version_error(param, min_version): raise_version_error('ContainerSpec.privileges', '1.30') +def _merge_task_template(current, override): + merged = current.copy() + if override is not None: + for ts_key, ts_value in override.items(): + if ts_key == 'ContainerSpec': + if 'ContainerSpec' not in merged: + merged['ContainerSpec'] = {} + for cs_key, cs_value in override['ContainerSpec'].items(): + if cs_value is not None: + merged['ContainerSpec'][cs_key] = cs_value + elif ts_value is not None: + merged[ts_key] = ts_value + return merged + + class ServiceApiMixin(object): @utils.minimum_version('1.24') def create_service( @@ -306,7 +325,7 @@ def tasks(self, filters=None): def update_service(self, service, version, task_template=None, name=None, labels=None, mode=None, update_config=None, networks=None, endpoint_config=None, - endpoint_spec=None): + endpoint_spec=None, fetch_current_spec=False): """ Update a service. @@ -328,6 +347,8 @@ def update_service(self, service, version, task_template=None, name=None, the service to. Default: ``None``. endpoint_spec (EndpointSpec): Properties that can be configured to access and load balance a service. Default: ``None``. + fetch_current_spec (boolean): Use the undefined settings from the + current specification of the service. Default: ``False`` Returns: ``True`` if successful. @@ -345,32 +366,64 @@ def update_service(self, service, version, task_template=None, name=None, _check_api_features(self._version, task_template, update_config) + if fetch_current_spec: + inspect_defaults = True + if utils.version_lt(self._version, '1.29'): + inspect_defaults = None + current = self.inspect_service( + service, insert_defaults=inspect_defaults + )['Spec'] + + else: + current = {} + url = self._url('/services/{0}/update', service) data = {} headers = {} - if name is not None: - data['Name'] = name - if labels is not None: - data['Labels'] = labels + + data['Name'] = current.get('Name') if name is None else name + + data['Labels'] = current.get('Labels') if labels is None else labels + if mode is not None: if not isinstance(mode, dict): mode = ServiceMode(mode) data['Mode'] = mode - if task_template is not None: - image = task_template.get('ContainerSpec', {}).get('Image', None) - if image is not None: - registry, repo_name = auth.resolve_repository_name(image) - auth_header = auth.get_config_header(self, registry) - if auth_header: - headers['X-Registry-Auth'] = auth_header - data['TaskTemplate'] = task_template + else: + data['Mode'] = current.get('Mode') + + data['TaskTemplate'] = _merge_task_template( + current.get('TaskTemplate', {}), task_template + ) + + container_spec = data['TaskTemplate'].get('ContainerSpec', {}) + image = container_spec.get('Image', None) + if image is not None: + registry, repo_name = auth.resolve_repository_name(image) + auth_header = auth.get_config_header(self, registry) + if auth_header: + headers['X-Registry-Auth'] = auth_header + if update_config is not None: data['UpdateConfig'] = update_config + else: + data['UpdateConfig'] = current.get('UpdateConfig') if networks is not None: - data['Networks'] = utils.convert_service_networks(networks) + converted_networks = utils.convert_service_networks(networks) + data['TaskTemplate']['Networks'] = converted_networks + elif data['TaskTemplate'].get('Networks') is None: + current_task_template = current.get('TaskTemplate', {}) + current_networks = current_task_template.get('Networks') + if current_networks is None: + current_networks = current.get('Networks') + if current_networks is not None: + data['TaskTemplate']['Networks'] = current_networks + if endpoint_spec is not None: data['EndpointSpec'] = endpoint_spec + else: + data['EndpointSpec'] = current.get('EndpointSpec') resp = self._post_json( url, data=data, params={'version': version}, headers=headers diff --git a/docker/api/swarm.py b/docker/api/swarm.py index 576fd79bf8..04595da139 100644 --- a/docker/api/swarm.py +++ b/docker/api/swarm.py @@ -1,7 +1,9 @@ import logging from six.moves import http_client +from .. import errors from .. import types from .. import utils + log = logging.getLogger(__name__) @@ -68,6 +70,16 @@ def create_swarm_spec(self, *args, **kwargs): kwargs['external_cas'] = [ext_ca] return types.SwarmSpec(self._version, *args, **kwargs) + @utils.minimum_version('1.24') + def get_unlock_key(self): + """ + Get the unlock key for this Swarm manager. + + Returns: + A ``dict`` containing an ``UnlockKey`` member + """ + return self._result(self._get(self._url('/swarm/unlockkey')), True) + @utils.minimum_version('1.24') def init_swarm(self, advertise_addr=None, listen_addr='0.0.0.0:2377', force_new_cluster=False, swarm_spec=None): @@ -152,7 +164,7 @@ def inspect_node(self, node_id): return self._result(self._get(url), True) @utils.minimum_version('1.24') - def join_swarm(self, remote_addrs, join_token, listen_addr=None, + def join_swarm(self, remote_addrs, join_token, listen_addr='0.0.0.0:2377', advertise_addr=None): """ Make this Engine join a swarm that has already been created. @@ -270,10 +282,46 @@ def remove_node(self, node_id, force=False): self._raise_for_status(res) return True + @utils.minimum_version('1.24') + def unlock_swarm(self, key): + """ + Unlock a locked swarm. + + Args: + key (string): The unlock key as provided by + :py:meth:`get_unlock_key` + + Raises: + :py:class:`docker.errors.InvalidArgument` + If the key argument is in an incompatible format + + :py:class:`docker.errors.APIError` + If the server returns an error. + + Returns: + `True` if the request was successful. + + Example: + + >>> key = client.get_unlock_key() + >>> client.unlock_node(key) + + """ + if isinstance(key, dict): + if 'UnlockKey' not in key: + raise errors.InvalidArgument('Invalid unlock key format') + else: + key = {'UnlockKey': key} + + url = self._url('/swarm/unlock') + res = self._post_json(url, data=key) + self._raise_for_status(res) + return True + @utils.minimum_version('1.24') def update_node(self, node_id, version, node_spec=None): """ - Update the Node's configuration + Update the node's configuration Args: diff --git a/docker/auth.py b/docker/auth.py index c3fb062e9b..c0cae5d97a 100644 --- a/docker/auth.py +++ b/docker/auth.py @@ -203,7 +203,7 @@ def parse_auth(entries, raise_on_error=False): # https://github.com/docker/compose/issues/3265 log.debug( 'Auth data for {0} is absent. Client might be using a ' - 'credentials store instead.' + 'credentials store instead.'.format(registry) ) conf[registry] = {} continue diff --git a/docker/client.py b/docker/client.py index 29968c1f0d..467583e639 100644 --- a/docker/client.py +++ b/docker/client.py @@ -26,7 +26,7 @@ class DockerClient(object): base_url (str): URL to the Docker server. For example, ``unix:///var/run/docker.sock`` or ``tcp://127.0.0.1:1234``. version (str): The version of the API to use. Set to ``auto`` to - automatically detect the server's version. Default: ``1.26`` + automatically detect the server's version. Default: ``1.30`` timeout (int): Default timeout for API calls, in seconds. tls (bool or :py:class:`~docker.tls.TLSConfig`): Enable TLS. Pass ``True`` to enable it with default options, or pass a @@ -60,7 +60,7 @@ def from_env(cls, **kwargs): Args: version (str): The version of the API to use. Set to ``auto`` to - automatically detect the server's version. Default: ``1.26`` + automatically detect the server's version. Default: ``1.30`` timeout (int): Default timeout for API calls, in seconds. ssl_version (int): A valid `SSL version`_. assert_hostname (bool): Verify the hostname of the server. diff --git a/docker/errors.py b/docker/errors.py index 2a2f871e5d..50423a268d 100644 --- a/docker/errors.py +++ b/docker/errors.py @@ -18,7 +18,7 @@ def create_api_error_from_http_exception(e): try: explanation = response.json()['message'] except ValueError: - explanation = response.content.strip() + explanation = (response.content or '').strip() cls = APIError if response.status_code == 404: if explanation and ('No such image' in str(explanation) or diff --git a/docker/models/containers.py b/docker/models/containers.py index 97a08b9d83..6ba308e492 100644 --- a/docker/models/containers.py +++ b/docker/models/containers.py @@ -629,6 +629,9 @@ def run(self, image, command=None, stdout=True, stderr=False, (e.g. ``SIGINT``). storage_opt (dict): Storage driver options per container as a key-value mapping. + stream (bool): If true and ``detach`` is false, return a log + generator instead of a string. Ignored if ``detach`` is true. + Default: ``False``. sysctls (dict): Kernel parameters to set in the container. tmpfs (dict): Temporary filesystems to mount, as a dictionary mapping a path inside the container to options for that path. @@ -696,6 +699,7 @@ def run(self, image, command=None, stdout=True, stderr=False, """ if isinstance(image, Image): image = image.id + stream = kwargs.pop('stream', False) detach = kwargs.pop("detach", False) if detach and remove: if version_gte(self.client.api._version, '1.25'): @@ -723,23 +727,30 @@ def run(self, image, command=None, stdout=True, stderr=False, if detach: return container - exit_status = container.wait() - if exit_status != 0: - stdout = False - stderr = True - logging_driver = container.attrs['HostConfig']['LogConfig']['Type'] + out = None if logging_driver == 'json-file' or logging_driver == 'journald': - out = container.logs(stdout=stdout, stderr=stderr) - else: + out = container.logs( + stdout=stdout, stderr=stderr, stream=True, follow=True + ) + + exit_status = container.wait() + if exit_status != 0: out = None + if not kwargs.get('auto_remove'): + out = container.logs(stdout=False, stderr=True) if remove: container.remove() if exit_status != 0: - raise ContainerError(container, exit_status, command, image, out) - return out + raise ContainerError( + container, exit_status, command, image, out + ) + + return out if stream or out is None else b''.join( + [line for line in out] + ) def create(self, image, command=None, **kwargs): """ @@ -873,6 +884,8 @@ def prune(self, filters=None): 'cpu_shares', 'cpuset_cpus', 'cpuset_mems', + 'cpu_rt_period', + 'cpu_rt_runtime', 'device_read_bps', 'device_read_iops', 'device_write_bps', diff --git a/docker/models/networks.py b/docker/models/networks.py index 158af99b8d..1c2fbf2465 100644 --- a/docker/models/networks.py +++ b/docker/models/networks.py @@ -1,4 +1,5 @@ from ..api import APIClient +from ..utils import version_gte from .containers import Container from .resource import Model, Collection @@ -153,7 +154,7 @@ def create(self, name, *args, **kwargs): resp = self.client.api.create_network(name, *args, **kwargs) return self.get(resp['Id']) - def get(self, network_id): + def get(self, network_id, *args, **kwargs): """ Get a network by its ID. @@ -175,7 +176,9 @@ def get(self, network_id): If the server returns an error. """ - return self.prepare_model(self.client.api.inspect_network(network_id)) + return self.prepare_model( + self.client.api.inspect_network(network_id, *args, **kwargs) + ) def list(self, *args, **kwargs): """ @@ -184,6 +187,13 @@ def list(self, *args, **kwargs): Args: names (:py:class:`list`): List of names to filter by. ids (:py:class:`list`): List of ids to filter by. + filters (dict): Filters to be processed on the network list. + Available filters: + - ``driver=[]`` Matches a network's driver. + - ``label=[]`` or ``label=[=]``. + - ``type=["custom"|"builtin"]`` Filters networks by type. + greedy (bool): Fetch more details for each network individually. + You might want this to get the containers attached to them. Returns: (list of :py:class:`Network`) The networks on the server. @@ -192,8 +202,13 @@ def list(self, *args, **kwargs): :py:class:`docker.errors.APIError` If the server returns an error. """ + greedy = kwargs.pop('greedy', False) resp = self.client.api.networks(*args, **kwargs) - return [self.prepare_model(item) for item in resp] + networks = [self.prepare_model(item) for item in resp] + if greedy and version_gte(self.client.api._version, '1.28'): + for net in networks: + net.reload() + return networks def prune(self, filters=None): self.client.api.prune_networks(filters=filters) diff --git a/docker/models/services.py b/docker/models/services.py index 6fc5c2a5c1..009e4551ac 100644 --- a/docker/models/services.py +++ b/docker/models/services.py @@ -251,6 +251,7 @@ def list(self, **kwargs): # kwargs to copy straight over to TaskTemplate TASK_TEMPLATE_KWARGS = [ + 'networks', 'resources', 'restart_policy', ] @@ -261,7 +262,6 @@ def list(self, **kwargs): 'labels', 'mode', 'update_config', - 'networks', 'endpoint_spec', ] @@ -295,6 +295,15 @@ def _get_create_service_kwargs(func_name, kwargs): 'Options': kwargs.pop('log_driver_options', {}) } + if func_name == 'update': + if 'force_update' in kwargs: + task_template_kwargs['force_update'] = kwargs.pop('force_update') + + # fetch the current spec by default if updating the service + # through the model + fetch_current_spec = kwargs.pop('fetch_current_spec', True) + create_kwargs['fetch_current_spec'] = fetch_current_spec + # All kwargs should have been consumed by this point, so raise # error if any are left if kwargs: diff --git a/docker/models/swarm.py b/docker/models/swarm.py index 5a253c57b5..7396e730d7 100644 --- a/docker/models/swarm.py +++ b/docker/models/swarm.py @@ -29,6 +29,10 @@ def version(self): """ return self.attrs.get('Version').get('Index') + def get_unlock_key(self): + return self.client.api.get_unlock_key() + get_unlock_key.__doc__ = APIClient.get_unlock_key.__doc__ + def init(self, advertise_addr=None, listen_addr='0.0.0.0:2377', force_new_cluster=False, **kwargs): """ @@ -128,6 +132,10 @@ def reload(self): """ self.attrs = self.client.api.inspect_swarm() + def unlock(self, key): + return self.client.api.unlock_swarm(key) + unlock.__doc__ = APIClient.unlock_swarm.__doc__ + def update(self, rotate_worker_token=False, rotate_manager_token=False, **kwargs): """ diff --git a/docker/types/containers.py b/docker/types/containers.py index 13bea713ed..15dd86c991 100644 --- a/docker/types/containers.py +++ b/docker/types/containers.py @@ -120,7 +120,8 @@ def __init__(self, version, binds=None, port_bindings=None, isolation=None, auto_remove=False, storage_opt=None, init=None, init_path=None, volume_driver=None, cpu_count=None, cpu_percent=None, nano_cpus=None, - cpuset_mems=None, runtime=None, mounts=None): + cpuset_mems=None, runtime=None, mounts=None, + cpu_rt_period=None, cpu_rt_runtime=None): if mem_limit is not None: self['Memory'] = parse_bytes(mem_limit) @@ -336,6 +337,26 @@ def __init__(self, version, binds=None, port_bindings=None, ) self['CpusetMems'] = cpuset_mems + if cpu_rt_period: + if version_lt(version, '1.25'): + raise host_config_version_error('cpu_rt_period', '1.25') + + if not isinstance(cpu_rt_period, int): + raise host_config_type_error( + 'cpu_rt_period', cpu_rt_period, 'int' + ) + self['CPURealtimePeriod'] = cpu_rt_period + + if cpu_rt_runtime: + if version_lt(version, '1.25'): + raise host_config_version_error('cpu_rt_runtime', '1.25') + + if not isinstance(cpu_rt_runtime, int): + raise host_config_type_error( + 'cpu_rt_runtime', cpu_rt_runtime, 'int' + ) + self['CPURealtimeRuntime'] = cpu_rt_runtime + if blkio_weight: if not isinstance(blkio_weight, int): raise host_config_type_error( diff --git a/docker/types/services.py b/docker/types/services.py index 9031e609ab..18d4d2adf2 100644 --- a/docker/types/services.py +++ b/docker/types/services.py @@ -4,7 +4,7 @@ from ..constants import IS_WINDOWS_PLATFORM from ..utils import ( check_resource, format_environment, format_extra_hosts, parse_bytes, - split_command, + split_command, convert_service_networks, ) @@ -26,11 +26,14 @@ class TaskTemplate(dict): placement (Placement): Placement instructions for the scheduler. If a list is passed instead, it is assumed to be a list of constraints as part of a :py:class:`Placement` object. + networks (:py:class:`list`): List of network names or IDs to attach + the containers to. force_update (int): A counter that triggers an update even if no relevant parameters have been changed. """ def __init__(self, container_spec, resources=None, restart_policy=None, - placement=None, log_driver=None, force_update=None): + placement=None, log_driver=None, networks=None, + force_update=None): self['ContainerSpec'] = container_spec if resources: self['Resources'] = resources @@ -42,6 +45,8 @@ def __init__(self, container_spec, resources=None, restart_policy=None, self['Placement'] = placement if log_driver: self['LogDriver'] = log_driver + if networks: + self['Networks'] = convert_service_networks(networks) if force_update is not None: if not isinstance(force_update, int): @@ -137,7 +142,7 @@ def __init__(self, image, command=None, args=None, hostname=None, env=None, if labels is not None: self['Labels'] = labels if hosts is not None: - self['Hosts'] = format_extra_hosts(hosts) + self['Hosts'] = format_extra_hosts(hosts, task=True) if mounts is not None: parsed_mounts = [] @@ -334,9 +339,11 @@ class UpdateConfig(dict): max_failure_ratio (float): The fraction of tasks that may fail during an update before the failure action is invoked, specified as a floating point number between 0 and 1. Default: 0 + order (string): Specifies the order of operations when rolling out an + updated task. Either ``start_first`` or ``stop_first`` are accepted. """ def __init__(self, parallelism=0, delay=None, failure_action='continue', - monitor=None, max_failure_ratio=None): + monitor=None, max_failure_ratio=None, order=None): self['Parallelism'] = parallelism if delay is not None: self['Delay'] = delay @@ -360,6 +367,13 @@ def __init__(self, parallelism=0, delay=None, failure_action='continue', ) self['MaxFailureRatio'] = max_failure_ratio + if order is not None: + if order not in ('start-first', 'stop-first'): + raise errors.InvalidArgument( + 'order must be either `start-first` or `stop-first`' + ) + self['Order'] = order + class RestartConditionTypesEnum(object): _values = ( diff --git a/docker/utils/utils.py b/docker/utils/utils.py index a123fd8f83..2de995c402 100644 --- a/docker/utils/utils.py +++ b/docker/utils/utils.py @@ -97,7 +97,12 @@ def create_archive(root, files=None, fileobj=None, gzip=False): if files is None: files = build_file_list(root) for path in files: - i = t.gettarinfo(os.path.join(root, path), arcname=path) + full_path = os.path.join(root, path) + if not os.access(full_path, os.R_OK): + raise IOError( + 'Can not access file in context: {}'.format(full_path) + ) + i = t.gettarinfo(full_path, arcname=path) if i is None: # This happens when we encounter a socket file. We can safely # ignore it and proceed. @@ -108,12 +113,14 @@ def create_archive(root, files=None, fileobj=None, gzip=False): # and directories executable by default. i.mode = i.mode & 0o755 | 0o111 - try: - # We open the file object in binary mode for Windows support. - with open(os.path.join(root, path), 'rb') as f: - t.addfile(i, f) - except IOError: - # When we encounter a directory the file object is set to None. + if i.isfile(): + try: + with open(full_path, 'rb') as f: + t.addfile(i, f) + except IOError: + t.addfile(i, None) + else: + # Directories, FIFOs, symlinks... don't need to be read. t.addfile(i, None) t.close() fileobj.seek(0) @@ -564,7 +571,13 @@ def format_env(key, value): return [format_env(*var) for var in six.iteritems(environment)] -def format_extra_hosts(extra_hosts): +def format_extra_hosts(extra_hosts, task=False): + # Use format dictated by Swarm API if container is part of a task + if task: + return [ + '{} {}'.format(v, k) for k, v in sorted(six.iteritems(extra_hosts)) + ] + return [ '{}:{}'.format(k, v) for k, v in sorted(six.iteritems(extra_hosts)) ] diff --git a/docker/version.py b/docker/version.py index 87c864313b..2502183331 100644 --- a/docker/version.py +++ b/docker/version.py @@ -1,2 +1,2 @@ -version = "2.6.1" +version = "2.7.0" version_info = tuple([int(d) for d in version.split("-")[0].split(".")]) diff --git a/docs/change-log.md b/docs/change-log.md index 57293f3e14..b8298a7981 100644 --- a/docs/change-log.md +++ b/docs/change-log.md @@ -1,6 +1,41 @@ Change log ========== +2.7.0 +----- + +[List of PRs / issues for this release](https://github.com/docker/docker-py/milestone/41?closed=1) + +### Features + +* Added `unlock_swarm` and `get_unlock_key` methods to the `APIClient`. + * Added `unlock` and `get_unlock_key` to `DockerClient.swarm`. +* Added a `greedy` parameter to `DockerClient.networks.list`, yielding + additional details about the listed networks. +* Added `cpu_rt_runtime` and `cpu_rt_period` as parameters to + `APIClient.create_host_config` and `DockerClient.containers.run`. +* Added the `order` argument to `UpdateConfig`. +* Added `fetch_current_spec` to `APIClient.update_service` and `Service.update` + that will retrieve the current configuration of the service and merge it with + the provided parameters to determine the new configuration. + +### Bugfixes + +* Fixed a bug where the `build` method tried to include inaccessible files + in the context, leading to obscure errors during the build phase + (inaccessible files inside the context now raise an `IOError` instead). +* Fixed a bug where the `build` method would try to read from FIFOs present + inside the build context, causing it to hang. +* `APIClient.stop` will no longer override the `stop_timeout` value present + in the container's configuration. +* Fixed a bug preventing removal of networks with names containing a space. +* Fixed a bug where `DockerClient.containers.run` would crash if the + `auto_remove` parameter was set to `True`. +* Changed the default value of `listen_addr` in `join_swarm` to match the + one in `init_swarm`. +* Fixed a bug where handling HTTP errors with no body would cause an unexpected + exception to be thrown while generating an `APIError` object. + 2.6.1 ----- diff --git a/docs/swarm.rst b/docs/swarm.rst index 0c21bae1ab..cab9def70a 100644 --- a/docs/swarm.rst +++ b/docs/swarm.rst @@ -12,9 +12,11 @@ These methods are available on ``client.swarm``: .. rst-class:: hide-signature .. py:class:: Swarm + .. automethod:: get_unlock_key() .. automethod:: init() .. automethod:: join() .. automethod:: leave() + .. automethod:: unlock() .. automethod:: update() .. automethod:: reload() diff --git a/setup.py b/setup.py index 4a33c8df02..d59d8124ba 100644 --- a/setup.py +++ b/setup.py @@ -20,7 +20,7 @@ SOURCE_DIR = os.path.join(ROOT_DIR) requirements = [ - 'requests >= 2.5.2, != 2.11.0, != 2.12.2, != 2.18.0', + 'requests >= 2.14.2, != 2.18.0', 'six >= 1.4.0', 'websocket-client >= 0.32.0', 'docker-pycreds >= 0.2.1' diff --git a/tests/integration/api_container_test.py b/tests/integration/api_container_test.py index f03ccdb436..5e30eee27d 100644 --- a/tests/integration/api_container_test.py +++ b/tests/integration/api_container_test.py @@ -464,6 +464,20 @@ def test_create_with_init_path(self): config = self.client.inspect_container(ctnr) assert config['HostConfig']['InitPath'] == "/usr/libexec/docker-init" + @requires_api_version('1.24') + @pytest.mark.xfail(not os.path.exists('/sys/fs/cgroup/cpu.rt_runtime_us'), + reason='CONFIG_RT_GROUP_SCHED isn\'t enabled') + def test_create_with_cpu_rt_options(self): + ctnr = self.client.create_container( + BUSYBOX, 'true', host_config=self.client.create_host_config( + cpu_rt_period=1000, cpu_rt_runtime=500 + ) + ) + self.tmp_containers.append(ctnr) + config = self.client.inspect_container(ctnr) + assert config['HostConfig']['CpuRealtimeRuntime'] == 500 + assert config['HostConfig']['CpuRealtimePeriod'] == 1000 + class VolumeBindTest(BaseAPIIntegrationTest): def setUp(self): diff --git a/tests/integration/api_network_test.py b/tests/integration/api_network_test.py index f4fefde5b9..10e09dd70d 100644 --- a/tests/integration/api_network_test.py +++ b/tests/integration/api_network_test.py @@ -484,3 +484,10 @@ def test_create_inspect_network_with_scope(self): assert self.client.inspect_network(net_name_swarm, scope='swarm') with pytest.raises(docker.errors.NotFound): self.client.inspect_network(net_name_swarm, scope='local') + + @requires_api_version('1.21') + def test_create_remove_network_with_space_in_name(self): + net_id = self.client.create_network('test 01') + self.tmp_networks.append(net_id) + assert self.client.inspect_network('test 01') + assert self.client.remove_network('test 01') is None # does not raise diff --git a/tests/integration/api_service_test.py b/tests/integration/api_service_test.py index b931154945..4a2093dae6 100644 --- a/tests/integration/api_service_test.py +++ b/tests/integration/api_service_test.py @@ -386,6 +386,24 @@ def test_create_service_with_env(self): assert 'Env' in con_spec assert con_spec['Env'] == ['DOCKER_PY_TEST=1'] + @requires_api_version('1.29') + def test_create_service_with_update_order(self): + container_spec = docker.types.ContainerSpec(BUSYBOX, ['true']) + task_tmpl = docker.types.TaskTemplate(container_spec) + update_config = docker.types.UpdateConfig( + parallelism=10, delay=5, order='start-first' + ) + name = self.get_service_name() + svc_id = self.client.create_service( + task_tmpl, update_config=update_config, name=name + ) + svc_info = self.client.inspect_service(svc_id) + assert 'UpdateConfig' in svc_info['Spec'] + uc = svc_info['Spec']['UpdateConfig'] + assert update_config['Parallelism'] == uc['Parallelism'] + assert update_config['Delay'] == uc['Delay'] + assert update_config['Order'] == uc['Order'] + @requires_api_version('1.25') def test_create_service_with_tty(self): container_spec = docker.types.ContainerSpec( @@ -588,8 +606,8 @@ def test_create_service_with_hosts(self): assert 'Hosts' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] hosts = svc_info['Spec']['TaskTemplate']['ContainerSpec']['Hosts'] assert len(hosts) == 2 - assert 'foobar:127.0.0.1' in hosts - assert 'baz:8.8.8.8' in hosts + assert '127.0.0.1 foobar' in hosts + assert '8.8.8.8 baz' in hosts @requires_api_version('1.25') def test_create_service_with_hostname(self): @@ -710,3 +728,455 @@ def test_create_service_with_privileges(self): svc_info['Spec']['TaskTemplate']['ContainerSpec']['Privileges'] ) assert privileges['SELinuxContext']['Disable'] is True + + @requires_api_version('1.25') + def test_update_service_with_defaults_name(self): + container_spec = docker.types.ContainerSpec( + 'busybox', ['echo', 'hello'] + ) + task_tmpl = docker.types.TaskTemplate(container_spec) + name = self.get_service_name() + svc_id = self.client.create_service(task_tmpl, name=name) + svc_info = self.client.inspect_service(svc_id) + assert 'Name' in svc_info['Spec'] + assert svc_info['Spec']['Name'] == name + version_index = svc_info['Version']['Index'] + + task_tmpl = docker.types.TaskTemplate(container_spec, force_update=10) + self._update_service( + svc_id, name, version_index, task_tmpl, fetch_current_spec=True + ) + svc_info = self.client.inspect_service(svc_id) + new_index = svc_info['Version']['Index'] + assert new_index > version_index + assert 'Name' in svc_info['Spec'] + assert svc_info['Spec']['Name'] == name + + @requires_api_version('1.25') + def test_update_service_with_defaults_labels(self): + container_spec = docker.types.ContainerSpec( + 'busybox', ['echo', 'hello'] + ) + task_tmpl = docker.types.TaskTemplate(container_spec) + name = self.get_service_name() + svc_id = self.client.create_service( + task_tmpl, name=name, labels={'service.label': 'SampleLabel'} + ) + svc_info = self.client.inspect_service(svc_id) + assert 'Labels' in svc_info['Spec'] + assert 'service.label' in svc_info['Spec']['Labels'] + assert svc_info['Spec']['Labels']['service.label'] == 'SampleLabel' + version_index = svc_info['Version']['Index'] + + task_tmpl = docker.types.TaskTemplate(container_spec, force_update=10) + self._update_service( + svc_id, name, version_index, task_tmpl, name=name, + fetch_current_spec=True + ) + svc_info = self.client.inspect_service(svc_id) + new_index = svc_info['Version']['Index'] + assert new_index > version_index + assert 'Labels' in svc_info['Spec'] + assert 'service.label' in svc_info['Spec']['Labels'] + assert svc_info['Spec']['Labels']['service.label'] == 'SampleLabel' + + def test_update_service_with_defaults_mode(self): + container_spec = docker.types.ContainerSpec( + 'busybox', ['echo', 'hello'] + ) + task_tmpl = docker.types.TaskTemplate(container_spec) + name = self.get_service_name() + svc_id = self.client.create_service( + task_tmpl, name=name, + mode=docker.types.ServiceMode(mode='replicated', replicas=2) + ) + svc_info = self.client.inspect_service(svc_id) + assert 'Mode' in svc_info['Spec'] + assert 'Replicated' in svc_info['Spec']['Mode'] + assert 'Replicas' in svc_info['Spec']['Mode']['Replicated'] + assert svc_info['Spec']['Mode']['Replicated']['Replicas'] == 2 + version_index = svc_info['Version']['Index'] + + self._update_service( + svc_id, name, version_index, labels={'force': 'update'}, + fetch_current_spec=True + ) + svc_info = self.client.inspect_service(svc_id) + new_index = svc_info['Version']['Index'] + assert new_index > version_index + assert 'Mode' in svc_info['Spec'] + assert 'Replicated' in svc_info['Spec']['Mode'] + assert 'Replicas' in svc_info['Spec']['Mode']['Replicated'] + assert svc_info['Spec']['Mode']['Replicated']['Replicas'] == 2 + + def test_update_service_with_defaults_container_labels(self): + container_spec = docker.types.ContainerSpec( + 'busybox', ['echo', 'hello'], + labels={'container.label': 'SampleLabel'} + ) + task_tmpl = docker.types.TaskTemplate(container_spec) + name = self.get_service_name() + svc_id = self.client.create_service( + task_tmpl, name=name, labels={'service.label': 'SampleLabel'} + ) + svc_info = self.client.inspect_service(svc_id) + assert 'TaskTemplate' in svc_info['Spec'] + assert 'ContainerSpec' in svc_info['Spec']['TaskTemplate'] + assert 'Labels' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] + labels = svc_info['Spec']['TaskTemplate']['ContainerSpec']['Labels'] + assert labels['container.label'] == 'SampleLabel' + version_index = svc_info['Version']['Index'] + + self._update_service( + svc_id, name, version_index, labels={'force': 'update'}, + fetch_current_spec=True + ) + svc_info = self.client.inspect_service(svc_id) + new_index = svc_info['Version']['Index'] + assert new_index > version_index + assert 'TaskTemplate' in svc_info['Spec'] + assert 'ContainerSpec' in svc_info['Spec']['TaskTemplate'] + assert 'Labels' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] + labels = svc_info['Spec']['TaskTemplate']['ContainerSpec']['Labels'] + assert labels['container.label'] == 'SampleLabel' + + container_spec = docker.types.ContainerSpec( + 'busybox', ['echo', 'hello'] + ) + task_tmpl = docker.types.TaskTemplate(container_spec) + self._update_service( + svc_id, name, new_index, task_tmpl, fetch_current_spec=True + ) + svc_info = self.client.inspect_service(svc_id) + newer_index = svc_info['Version']['Index'] + assert newer_index > new_index + assert 'TaskTemplate' in svc_info['Spec'] + assert 'ContainerSpec' in svc_info['Spec']['TaskTemplate'] + assert 'Labels' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] + labels = svc_info['Spec']['TaskTemplate']['ContainerSpec']['Labels'] + assert labels['container.label'] == 'SampleLabel' + + def test_update_service_with_defaults_update_config(self): + container_spec = docker.types.ContainerSpec(BUSYBOX, ['true']) + task_tmpl = docker.types.TaskTemplate(container_spec) + update_config = docker.types.UpdateConfig( + parallelism=10, delay=5, failure_action='pause' + ) + name = self.get_service_name() + svc_id = self.client.create_service( + task_tmpl, update_config=update_config, name=name + ) + svc_info = self.client.inspect_service(svc_id) + assert 'UpdateConfig' in svc_info['Spec'] + uc = svc_info['Spec']['UpdateConfig'] + assert update_config['Parallelism'] == uc['Parallelism'] + assert update_config['Delay'] == uc['Delay'] + assert update_config['FailureAction'] == uc['FailureAction'] + version_index = svc_info['Version']['Index'] + + self._update_service( + svc_id, name, version_index, labels={'force': 'update'}, + fetch_current_spec=True + ) + svc_info = self.client.inspect_service(svc_id) + new_index = svc_info['Version']['Index'] + assert new_index > version_index + assert 'UpdateConfig' in svc_info['Spec'] + uc = svc_info['Spec']['UpdateConfig'] + assert update_config['Parallelism'] == uc['Parallelism'] + assert update_config['Delay'] == uc['Delay'] + assert update_config['FailureAction'] == uc['FailureAction'] + + def test_update_service_with_defaults_networks(self): + net1 = self.client.create_network( + 'dockerpytest_1', driver='overlay', ipam={'Driver': 'default'} + ) + self.tmp_networks.append(net1['Id']) + net2 = self.client.create_network( + 'dockerpytest_2', driver='overlay', ipam={'Driver': 'default'} + ) + self.tmp_networks.append(net2['Id']) + container_spec = docker.types.ContainerSpec(BUSYBOX, ['true']) + task_tmpl = docker.types.TaskTemplate(container_spec) + name = self.get_service_name() + svc_id = self.client.create_service( + task_tmpl, name=name, networks=[ + 'dockerpytest_1', {'Target': 'dockerpytest_2'} + ] + ) + svc_info = self.client.inspect_service(svc_id) + assert 'Networks' in svc_info['Spec'] + assert svc_info['Spec']['Networks'] == [ + {'Target': net1['Id']}, {'Target': net2['Id']} + ] + + version_index = svc_info['Version']['Index'] + + self._update_service( + svc_id, name, version_index, labels={'force': 'update'}, + fetch_current_spec=True + ) + svc_info = self.client.inspect_service(svc_id) + new_index = svc_info['Version']['Index'] + assert new_index > version_index + assert 'Networks' in svc_info['Spec']['TaskTemplate'] + assert svc_info['Spec']['TaskTemplate']['Networks'] == [ + {'Target': net1['Id']}, {'Target': net2['Id']} + ] + + self._update_service( + svc_id, name, new_index, networks=[net1['Id']], + fetch_current_spec=True + ) + svc_info = self.client.inspect_service(svc_id) + assert 'Networks' in svc_info['Spec']['TaskTemplate'] + assert svc_info['Spec']['TaskTemplate']['Networks'] == [ + {'Target': net1['Id']} + ] + + def test_update_service_with_defaults_endpoint_spec(self): + container_spec = docker.types.ContainerSpec(BUSYBOX, ['true']) + task_tmpl = docker.types.TaskTemplate(container_spec) + name = self.get_service_name() + endpoint_spec = docker.types.EndpointSpec(ports={ + 12357: (1990, 'udp'), + 12562: (678,), + 53243: 8080, + }) + svc_id = self.client.create_service( + task_tmpl, name=name, endpoint_spec=endpoint_spec + ) + svc_info = self.client.inspect_service(svc_id) + print(svc_info) + ports = svc_info['Spec']['EndpointSpec']['Ports'] + for port in ports: + if port['PublishedPort'] == 12562: + assert port['TargetPort'] == 678 + assert port['Protocol'] == 'tcp' + elif port['PublishedPort'] == 53243: + assert port['TargetPort'] == 8080 + assert port['Protocol'] == 'tcp' + elif port['PublishedPort'] == 12357: + assert port['TargetPort'] == 1990 + assert port['Protocol'] == 'udp' + else: + self.fail('Invalid port specification: {0}'.format(port)) + + assert len(ports) == 3 + + svc_info = self.client.inspect_service(svc_id) + version_index = svc_info['Version']['Index'] + + self._update_service( + svc_id, name, version_index, labels={'force': 'update'}, + fetch_current_spec=True + ) + svc_info = self.client.inspect_service(svc_id) + new_index = svc_info['Version']['Index'] + assert new_index > version_index + + ports = svc_info['Spec']['EndpointSpec']['Ports'] + for port in ports: + if port['PublishedPort'] == 12562: + assert port['TargetPort'] == 678 + assert port['Protocol'] == 'tcp' + elif port['PublishedPort'] == 53243: + assert port['TargetPort'] == 8080 + assert port['Protocol'] == 'tcp' + elif port['PublishedPort'] == 12357: + assert port['TargetPort'] == 1990 + assert port['Protocol'] == 'udp' + else: + self.fail('Invalid port specification: {0}'.format(port)) + + assert len(ports) == 3 + + @requires_api_version('1.25') + def test_update_service_remove_healthcheck(self): + second = 1000000000 + hc = docker.types.Healthcheck( + test='true', retries=3, timeout=1 * second, + start_period=3 * second, interval=int(second / 2), + ) + container_spec = docker.types.ContainerSpec( + BUSYBOX, ['sleep', '999'], healthcheck=hc + ) + task_tmpl = docker.types.TaskTemplate(container_spec) + name = self.get_service_name() + svc_id = self.client.create_service(task_tmpl, name=name) + svc_info = self.client.inspect_service(svc_id) + assert ( + 'Healthcheck' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] + ) + assert ( + hc == + svc_info['Spec']['TaskTemplate']['ContainerSpec']['Healthcheck'] + ) + + container_spec = docker.types.ContainerSpec( + BUSYBOX, ['sleep', '999'], healthcheck={} + ) + task_tmpl = docker.types.TaskTemplate(container_spec) + + version_index = svc_info['Version']['Index'] + + self._update_service( + svc_id, name, version_index, task_tmpl, fetch_current_spec=True + ) + svc_info = self.client.inspect_service(svc_id) + new_index = svc_info['Version']['Index'] + assert new_index > version_index + container_spec = svc_info['Spec']['TaskTemplate']['ContainerSpec'] + assert ( + 'Healthcheck' not in container_spec or + not container_spec['Healthcheck'] + ) + + def test_update_service_remove_labels(self): + container_spec = docker.types.ContainerSpec( + 'busybox', ['echo', 'hello'] + ) + task_tmpl = docker.types.TaskTemplate(container_spec) + name = self.get_service_name() + svc_id = self.client.create_service( + task_tmpl, name=name, labels={'service.label': 'SampleLabel'} + ) + svc_info = self.client.inspect_service(svc_id) + assert 'Labels' in svc_info['Spec'] + assert 'service.label' in svc_info['Spec']['Labels'] + assert svc_info['Spec']['Labels']['service.label'] == 'SampleLabel' + version_index = svc_info['Version']['Index'] + + self._update_service( + svc_id, name, version_index, labels={}, fetch_current_spec=True + ) + svc_info = self.client.inspect_service(svc_id) + new_index = svc_info['Version']['Index'] + assert new_index > version_index + assert not svc_info['Spec'].get('Labels') + + def test_update_service_remove_container_labels(self): + container_spec = docker.types.ContainerSpec( + 'busybox', ['echo', 'hello'], + labels={'container.label': 'SampleLabel'} + ) + task_tmpl = docker.types.TaskTemplate(container_spec) + name = self.get_service_name() + svc_id = self.client.create_service( + task_tmpl, name=name, labels={'service.label': 'SampleLabel'} + ) + svc_info = self.client.inspect_service(svc_id) + assert 'TaskTemplate' in svc_info['Spec'] + assert 'ContainerSpec' in svc_info['Spec']['TaskTemplate'] + assert 'Labels' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] + labels = svc_info['Spec']['TaskTemplate']['ContainerSpec']['Labels'] + assert labels['container.label'] == 'SampleLabel' + version_index = svc_info['Version']['Index'] + + container_spec = docker.types.ContainerSpec( + 'busybox', ['echo', 'hello'], + labels={} + ) + task_tmpl = docker.types.TaskTemplate(container_spec) + self._update_service( + svc_id, name, version_index, task_tmpl, fetch_current_spec=True + ) + svc_info = self.client.inspect_service(svc_id) + new_index = svc_info['Version']['Index'] + assert new_index > version_index + assert 'TaskTemplate' in svc_info['Spec'] + assert 'ContainerSpec' in svc_info['Spec']['TaskTemplate'] + container_spec = svc_info['Spec']['TaskTemplate']['ContainerSpec'] + assert not container_spec.get('Labels') + + @requires_api_version('1.29') + def test_update_service_with_network_change(self): + container_spec = docker.types.ContainerSpec( + 'busybox', ['echo', 'hello'] + ) + task_tmpl = docker.types.TaskTemplate(container_spec) + net1 = self.client.create_network( + 'dockerpytest_1', driver='overlay', ipam={'Driver': 'default'} + ) + self.tmp_networks.append(net1['Id']) + net2 = self.client.create_network( + 'dockerpytest_2', driver='overlay', ipam={'Driver': 'default'} + ) + self.tmp_networks.append(net2['Id']) + name = self.get_service_name() + svc_id = self.client.create_service( + task_tmpl, name=name, networks=[net1['Id']] + ) + svc_info = self.client.inspect_service(svc_id) + assert 'Networks' in svc_info['Spec'] + assert len(svc_info['Spec']['Networks']) > 0 + assert svc_info['Spec']['Networks'][0]['Target'] == net1['Id'] + + svc_info = self.client.inspect_service(svc_id) + version_index = svc_info['Version']['Index'] + + task_tmpl = docker.types.TaskTemplate(container_spec) + self._update_service( + svc_id, name, version_index, task_tmpl, name=name, + networks=[net2['Id']], fetch_current_spec=True + ) + svc_info = self.client.inspect_service(svc_id) + task_template = svc_info['Spec']['TaskTemplate'] + assert 'Networks' in task_template + assert len(task_template['Networks']) > 0 + assert task_template['Networks'][0]['Target'] == net2['Id'] + + svc_info = self.client.inspect_service(svc_id) + new_index = svc_info['Version']['Index'] + assert new_index > version_index + + self._update_service( + svc_id, name, new_index, name=name, networks=[net1['Id']], + fetch_current_spec=True + ) + svc_info = self.client.inspect_service(svc_id) + task_template = svc_info['Spec']['TaskTemplate'] + assert 'ContainerSpec' in task_template + new_spec = task_template['ContainerSpec'] + assert 'Image' in new_spec + assert new_spec['Image'].split(':')[0] == 'busybox' + assert 'Command' in new_spec + assert new_spec['Command'] == ['echo', 'hello'] + assert 'Networks' in task_template + assert len(task_template['Networks']) > 0 + assert task_template['Networks'][0]['Target'] == net1['Id'] + + svc_info = self.client.inspect_service(svc_id) + new_index = svc_info['Version']['Index'] + + task_tmpl = docker.types.TaskTemplate( + container_spec, networks=[net2['Id']] + ) + self._update_service( + svc_id, name, new_index, task_tmpl, name=name, + fetch_current_spec=True + ) + svc_info = self.client.inspect_service(svc_id) + task_template = svc_info['Spec']['TaskTemplate'] + assert 'Networks' in task_template + assert len(task_template['Networks']) > 0 + assert task_template['Networks'][0]['Target'] == net2['Id'] + + def _update_service(self, svc_id, *args, **kwargs): + # service update tests seem to be a bit flaky + # give them a chance to retry the update with a new version index + try: + self.client.update_service(*args, **kwargs) + except docker.errors.APIError as e: + if e.explanation.endswith("update out of sequence"): + svc_info = self.client.inspect_service(svc_id) + version_index = svc_info['Version']['Index'] + + if len(args) > 1: + args = (args[0], version_index) + args[2:] + else: + kwargs['version'] = version_index + + self.client.update_service(*args, **kwargs) + else: + raise diff --git a/tests/integration/api_swarm_test.py b/tests/integration/api_swarm_test.py index 34b0879ce4..dbf3786eb0 100644 --- a/tests/integration/api_swarm_test.py +++ b/tests/integration/api_swarm_test.py @@ -10,9 +10,16 @@ class SwarmTest(BaseAPIIntegrationTest): def setUp(self): super(SwarmTest, self).setUp() force_leave_swarm(self.client) + self._unlock_key = None def tearDown(self): super(SwarmTest, self).tearDown() + try: + if self._unlock_key: + self.client.unlock_swarm(self._unlock_key) + except docker.errors.APIError: + pass + force_leave_swarm(self.client) @requires_api_version('1.24') @@ -64,12 +71,16 @@ def test_init_swarm_with_ca_config(self): def test_init_swarm_with_autolock_managers(self): spec = self.client.create_swarm_spec(autolock_managers=True) assert self.init_swarm(swarm_spec=spec) + # save unlock key for tearDown + self._unlock_key = self.client.get_unlock_key() swarm_info = self.client.inspect_swarm() assert ( swarm_info['Spec']['EncryptionConfig']['AutoLockManagers'] is True ) + assert self._unlock_key.get('UnlockKey') + @requires_api_version('1.25') @pytest.mark.xfail( reason="This doesn't seem to be taken into account by the engine" @@ -126,24 +137,6 @@ def test_update_swarm(self): swarm_info_2['JoinTokens']['Worker'] ) - @requires_api_version('1.24') - def test_update_swarm_name(self): - assert self.init_swarm() - swarm_info_1 = self.client.inspect_swarm() - spec = self.client.create_swarm_spec( - node_cert_expiry=7776000000000000, name='reimuhakurei' - ) - assert self.client.update_swarm( - version=swarm_info_1['Version']['Index'], swarm_spec=spec - ) - swarm_info_2 = self.client.inspect_swarm() - - assert ( - swarm_info_1['Version']['Index'] != - swarm_info_2['Version']['Index'] - ) - assert swarm_info_2['Spec']['Name'] == 'reimuhakurei' - @requires_api_version('1.24') def test_list_nodes(self): assert self.init_swarm() diff --git a/tests/integration/models_containers_test.py b/tests/integration/models_containers_test.py index ce3349baa7..d246189d09 100644 --- a/tests/integration/models_containers_test.py +++ b/tests/integration/models_containers_test.py @@ -1,7 +1,9 @@ -import docker import tempfile + +import docker +import pytest from .base import BaseIntegrationTest, TEST_API_VERSION -from ..helpers import random_name +from ..helpers import random_name, requires_api_version class ContainerCollectionTest(BaseIntegrationTest): @@ -95,7 +97,7 @@ def test_run_with_none_driver(self): "alpine", "echo hello", log_config=dict(type='none') ) - self.assertEqual(out, None) + assert out is None def test_run_with_json_file_driver(self): client = docker.from_env(version=TEST_API_VERSION) @@ -104,7 +106,34 @@ def test_run_with_json_file_driver(self): "alpine", "echo hello", log_config=dict(type='json-file') ) - self.assertEqual(out, b'hello\n') + assert out == b'hello\n' + + @requires_api_version('1.25') + def test_run_with_auto_remove(self): + client = docker.from_env(version=TEST_API_VERSION) + out = client.containers.run( + 'alpine', 'echo hello', auto_remove=True + ) + assert out == b'hello\n' + + @requires_api_version('1.25') + def test_run_with_auto_remove_error(self): + client = docker.from_env(version=TEST_API_VERSION) + with pytest.raises(docker.errors.ContainerError) as e: + client.containers.run( + 'alpine', 'sh -c ">&2 echo error && exit 1"', auto_remove=True + ) + assert e.value.exit_status == 1 + assert e.value.stderr is None + + def test_run_with_streamed_logs(self): + client = docker.from_env(version=TEST_API_VERSION) + out = client.containers.run( + 'alpine', 'sh -c "echo hello && echo world"', stream=True + ) + logs = [line for line in out] + assert logs[0] == b'hello\n' + assert logs[1] == b'world\n' def test_get(self): client = docker.from_env(version=TEST_API_VERSION) diff --git a/tests/integration/models_networks_test.py b/tests/integration/models_networks_test.py index 105dcc594a..08d7ad2955 100644 --- a/tests/integration/models_networks_test.py +++ b/tests/integration/models_networks_test.py @@ -3,7 +3,7 @@ from .base import BaseIntegrationTest, TEST_API_VERSION -class ImageCollectionTest(BaseIntegrationTest): +class NetworkCollectionTest(BaseIntegrationTest): def test_create(self): client = docker.from_env(version=TEST_API_VERSION) @@ -47,7 +47,7 @@ def test_list_remove(self): assert network.id not in [n.id for n in client.networks.list()] -class ImageTest(BaseIntegrationTest): +class NetworkTest(BaseIntegrationTest): def test_connect_disconnect(self): client = docker.from_env(version=TEST_API_VERSION) @@ -59,6 +59,12 @@ def test_connect_disconnect(self): network.connect(container) container.start() assert client.networks.get(network.id).containers == [container] + network_containers = list( + c + for net in client.networks.list(ids=[network.id], greedy=True) + for c in net.containers + ) + assert network_containers == [container] network.disconnect(container) assert network.containers == [] assert client.networks.get(network.id).containers == [] diff --git a/tests/integration/models_services_test.py b/tests/integration/models_services_test.py index 6b5dab5312..ca8be48de4 100644 --- a/tests/integration/models_services_test.py +++ b/tests/integration/models_services_test.py @@ -1,7 +1,6 @@ import unittest import docker -import pytest from .. import helpers from .base import TEST_API_VERSION @@ -36,6 +35,25 @@ def test_create(self): assert "alpine" in container_spec['Image'] assert container_spec['Labels'] == {'container': 'label'} + def test_create_with_network(self): + client = docker.from_env(version=TEST_API_VERSION) + name = helpers.random_name() + network = client.networks.create( + helpers.random_name(), driver='overlay' + ) + service = client.services.create( + # create arguments + name=name, + # ContainerSpec arguments + image="alpine", + command="sleep 300", + networks=[network.id] + ) + assert 'Networks' in service.attrs['Spec']['TaskTemplate'] + networks = service.attrs['Spec']['TaskTemplate']['Networks'] + assert len(networks) == 1 + assert networks[0]['Target'] == network.id + def test_get(self): client = docker.from_env(version=TEST_API_VERSION) name = helpers.random_name() @@ -82,7 +100,6 @@ def test_tasks(self): assert len(tasks) == 1 assert tasks[0]['ServiceID'] == service2.id - @pytest.mark.skip(reason="Makes Swarm unstable?") def test_update(self): client = docker.from_env(version=TEST_API_VERSION) service = client.services.create( @@ -101,3 +118,109 @@ def test_update(self): service.reload() container_spec = service.attrs['Spec']['TaskTemplate']['ContainerSpec'] assert container_spec['Command'] == ["sleep", "600"] + + def test_update_retains_service_labels(self): + client = docker.from_env(version=TEST_API_VERSION) + service = client.services.create( + # create arguments + name=helpers.random_name(), + labels={'service.label': 'SampleLabel'}, + # ContainerSpec arguments + image="alpine", + command="sleep 300" + ) + service.update( + # create argument + name=service.name, + # ContainerSpec argument + command="sleep 600" + ) + service.reload() + labels = service.attrs['Spec']['Labels'] + assert labels == {'service.label': 'SampleLabel'} + + def test_update_retains_container_labels(self): + client = docker.from_env(version=TEST_API_VERSION) + service = client.services.create( + # create arguments + name=helpers.random_name(), + # ContainerSpec arguments + image="alpine", + command="sleep 300", + container_labels={'container.label': 'SampleLabel'} + ) + service.update( + # create argument + name=service.name, + # ContainerSpec argument + command="sleep 600" + ) + service.reload() + container_spec = service.attrs['Spec']['TaskTemplate']['ContainerSpec'] + assert container_spec['Labels'] == {'container.label': 'SampleLabel'} + + def test_update_remove_service_labels(self): + client = docker.from_env(version=TEST_API_VERSION) + service = client.services.create( + # create arguments + name=helpers.random_name(), + labels={'service.label': 'SampleLabel'}, + # ContainerSpec arguments + image="alpine", + command="sleep 300" + ) + service.update( + # create argument + name=service.name, + labels={}, + # ContainerSpec argument + command="sleep 600" + ) + service.reload() + assert not service.attrs['Spec'].get('Labels') + + def test_scale_service(self): + client = docker.from_env(version=TEST_API_VERSION) + service = client.services.create( + # create arguments + name=helpers.random_name(), + # ContainerSpec arguments + image="alpine", + command="sleep 300" + ) + tasks = [] + while len(tasks) == 0: + tasks = service.tasks() + assert len(tasks) == 1 + service.update( + mode=docker.types.ServiceMode('replicated', replicas=2), + ) + while len(tasks) == 1: + tasks = service.tasks() + assert len(tasks) >= 2 + # check that the container spec is not overridden with None + service.reload() + spec = service.attrs['Spec']['TaskTemplate']['ContainerSpec'] + assert spec.get('Command') == ['sleep', '300'] + + @helpers.requires_api_version('1.25') + def test_restart_service(self): + client = docker.from_env(version=TEST_API_VERSION) + service = client.services.create( + # create arguments + name=helpers.random_name(), + # ContainerSpec arguments + image="alpine", + command="sleep 300" + ) + initial_version = service.version + service.update( + # create argument + name=service.name, + # task template argument + force_update=10, + # ContainerSpec argument + command="sleep 600" + ) + service.reload() + assert service.version > initial_version diff --git a/tests/unit/dockertypes_test.py b/tests/unit/dockertypes_test.py index 8dbb35ecca..40adbb782f 100644 --- a/tests/unit/dockertypes_test.py +++ b/tests/unit/dockertypes_test.py @@ -206,6 +206,28 @@ def test_create_host_config_with_nano_cpus(self): InvalidVersion, lambda: create_host_config( version='1.24', nano_cpus=1)) + def test_create_host_config_with_cpu_rt_period_types(self): + with pytest.raises(TypeError): + create_host_config(version='1.25', cpu_rt_period='1000') + + def test_create_host_config_with_cpu_rt_period(self): + config = create_host_config(version='1.25', cpu_rt_period=1000) + self.assertEqual(config.get('CPURealtimePeriod'), 1000) + self.assertRaises( + InvalidVersion, lambda: create_host_config( + version='1.24', cpu_rt_period=1000)) + + def test_ctrate_host_config_with_cpu_rt_runtime_types(self): + with pytest.raises(TypeError): + create_host_config(version='1.25', cpu_rt_runtime='1000') + + def test_create_host_config_with_cpu_rt_runtime(self): + config = create_host_config(version='1.25', cpu_rt_runtime=1000) + self.assertEqual(config.get('CPURealtimeRuntime'), 1000) + self.assertRaises( + InvalidVersion, lambda: create_host_config( + version='1.24', cpu_rt_runtime=1000)) + class ContainerConfigTest(unittest.TestCase): def test_create_container_config_volume_driver_warning(self): diff --git a/tests/unit/errors_test.py b/tests/unit/errors_test.py index 9678669c3f..e27a9b1975 100644 --- a/tests/unit/errors_test.py +++ b/tests/unit/errors_test.py @@ -3,7 +3,8 @@ import requests from docker.errors import (APIError, ContainerError, DockerException, - create_unexpected_kwargs_error) + create_unexpected_kwargs_error, + create_api_error_from_http_exception) from .fake_api import FAKE_CONTAINER_ID, FAKE_IMAGE_ID from .fake_api_client import make_fake_client @@ -78,6 +79,19 @@ def test_is_client_error_400(self): err = APIError('', response=resp) assert err.is_client_error() is True + def test_create_error_from_exception(self): + resp = requests.Response() + resp.status_code = 500 + err = APIError('') + try: + resp.raise_for_status() + except requests.exceptions.HTTPError as e: + try: + create_api_error_from_http_exception(e) + except APIError as e: + err = e + assert err.is_server_error() is True + class ContainerErrorTest(unittest.TestCase): def test_container_without_stderr(self): diff --git a/tests/unit/fake_api.py b/tests/unit/fake_api.py index 045c342566..37154a3bd4 100644 --- a/tests/unit/fake_api.py +++ b/tests/unit/fake_api.py @@ -437,6 +437,10 @@ def post_fake_update_node(): return 200, None +def post_fake_join_swarm(): + return 200, None + + def get_fake_network_list(): return 200, [{ "Name": "bridge", @@ -601,6 +605,8 @@ def post_fake_network_disconnect(): CURRENT_VERSION, prefix, FAKE_NODE_ID ), 'POST'): post_fake_update_node, + ('{1}/{0}/swarm/join'.format(CURRENT_VERSION, prefix), 'POST'): + post_fake_join_swarm, ('{1}/{0}/networks'.format(CURRENT_VERSION, prefix), 'GET'): get_fake_network_list, ('{1}/{0}/networks/create'.format(CURRENT_VERSION, prefix), 'POST'): diff --git a/tests/unit/fake_api_client.py b/tests/unit/fake_api_client.py index 47890ace91..f908355101 100644 --- a/tests/unit/fake_api_client.py +++ b/tests/unit/fake_api_client.py @@ -43,7 +43,7 @@ def make_fake_api_client(): fake_api.get_fake_inspect_container()[1], 'inspect_image.return_value': fake_api.get_fake_inspect_image()[1], 'inspect_network.return_value': fake_api.get_fake_network()[1], - 'logs.return_value': 'hello world\n', + 'logs.return_value': [b'hello world\n'], 'networks.return_value': fake_api.get_fake_network_list()[1], 'start.return_value': None, 'wait.return_value': 0, diff --git a/tests/unit/models_containers_test.py b/tests/unit/models_containers_test.py index 5eaa45ac66..a479e836e6 100644 --- a/tests/unit/models_containers_test.py +++ b/tests/unit/models_containers_test.py @@ -12,7 +12,7 @@ def test_run(self): client = make_fake_client() out = client.containers.run("alpine", "echo hello world") - assert out == 'hello world\n' + assert out == b'hello world\n' client.api.create_container.assert_called_with( image="alpine", @@ -24,9 +24,8 @@ def test_run(self): client.api.start.assert_called_with(FAKE_CONTAINER_ID) client.api.wait.assert_called_with(FAKE_CONTAINER_ID) client.api.logs.assert_called_with( - FAKE_CONTAINER_ID, - stderr=False, - stdout=True + FAKE_CONTAINER_ID, stderr=False, stdout=True, stream=True, + follow=True ) def test_create_container_args(self): diff --git a/tests/unit/models_networks_test.py b/tests/unit/models_networks_test.py index 943b904568..58c9fce669 100644 --- a/tests/unit/models_networks_test.py +++ b/tests/unit/models_networks_test.py @@ -4,7 +4,7 @@ from .fake_api_client import make_fake_client -class ImageCollectionTest(unittest.TestCase): +class NetworkCollectionTest(unittest.TestCase): def test_create(self): client = make_fake_client() @@ -37,7 +37,7 @@ def test_list(self): assert client.api.networks.called_once_with(names=["foobar"]) -class ImageTest(unittest.TestCase): +class NetworkTest(unittest.TestCase): def test_connect(self): client = make_fake_client() diff --git a/tests/unit/models_services_test.py b/tests/unit/models_services_test.py index e7e317d52d..247bb4a4aa 100644 --- a/tests/unit/models_services_test.py +++ b/tests/unit/models_services_test.py @@ -35,18 +35,18 @@ def test_get_create_service_kwargs(self): 'labels': {'key': 'value'}, 'mode': 'global', 'update_config': {'update': 'config'}, - 'networks': ['somenet'], 'endpoint_spec': {'blah': 'blah'}, } assert set(task_template.keys()) == set([ 'ContainerSpec', 'Resources', 'RestartPolicy', 'Placement', - 'LogDriver' + 'LogDriver', 'Networks' ]) assert task_template['Placement'] == {'Constraints': ['foo=bar']} assert task_template['LogDriver'] == { 'Name': 'logdriver', 'Options': {'foo': 'bar'} } + assert task_template['Networks'] == [{'Target': 'somenet'}] assert set(task_template['ContainerSpec'].keys()) == set([ 'Image', 'Command', 'Args', 'Hostname', 'Env', 'Dir', 'User', 'Labels', 'Mounts', 'StopGracePeriod' diff --git a/tests/unit/swarm_test.py b/tests/unit/swarm_test.py index 374f8b2473..9a66c0c049 100644 --- a/tests/unit/swarm_test.py +++ b/tests/unit/swarm_test.py @@ -30,3 +30,46 @@ def test_node_update(self): self.assertEqual( args[1]['headers']['Content-Type'], 'application/json' ) + + @requires_api_version('1.24') + def test_join_swarm(self): + remote_addr = ['1.2.3.4:2377'] + listen_addr = '2.3.4.5:2377' + join_token = 'A_BEAUTIFUL_JOIN_TOKEN' + + data = { + 'RemoteAddrs': remote_addr, + 'ListenAddr': listen_addr, + 'JoinToken': join_token + } + + self.client.join_swarm( + remote_addrs=remote_addr, + listen_addr=listen_addr, + join_token=join_token + ) + + args = fake_request.call_args + + assert (args[0][1] == url_prefix + 'swarm/join') + assert (json.loads(args[1]['data']) == data) + assert (args[1]['headers']['Content-Type'] == 'application/json') + + @requires_api_version('1.24') + def test_join_swarm_no_listen_address_takes_default(self): + remote_addr = ['1.2.3.4:2377'] + join_token = 'A_BEAUTIFUL_JOIN_TOKEN' + + data = { + 'RemoteAddrs': remote_addr, + 'ListenAddr': '0.0.0.0:2377', + 'JoinToken': join_token + } + + self.client.join_swarm(remote_addrs=remote_addr, join_token=join_token) + + args = fake_request.call_args + + assert (args[0][1] == url_prefix + 'swarm/join') + assert (json.loads(args[1]['data']) == data) + assert (args[1]['headers']['Content-Type'] == 'application/json')