diff --git a/docker/api/build.py b/docker/api/build.py index 5c34c47b38..cbef4a8b17 100644 --- a/docker/api/build.py +++ b/docker/api/build.py @@ -18,7 +18,7 @@ def build(self, path=None, tag=None, quiet=False, fileobj=None, custom_context=False, encoding=None, pull=False, forcerm=False, dockerfile=None, container_limits=None, decode=False, buildargs=None, gzip=False, shmsize=None, - labels=None, cache_from=None): + labels=None, cache_from=None, target=None, network_mode=None): """ Similar to the ``docker build`` command. Either ``path`` or ``fileobj`` needs to be set. ``path`` can be a local path (to a directory @@ -88,12 +88,16 @@ def build(self, path=None, tag=None, quiet=False, fileobj=None, - cpusetcpus (str): CPUs in which to allow execution, e.g., ``"0-3"``, ``"0,1"`` decode (bool): If set to ``True``, the returned stream will be - decoded into dicts on the fly. Default ``False``. + decoded into dicts on the fly. Default ``False`` shmsize (int): Size of `/dev/shm` in bytes. The size must be - greater than 0. If omitted the system uses 64MB. - labels (dict): A dictionary of labels to set on the image. + greater than 0. If omitted the system uses 64MB + labels (dict): A dictionary of labels to set on the image cache_from (list): A list of images used for build cache - resolution. + resolution + target (str): Name of the build-stage to build in a multi-stage + Dockerfile + network_mode (str): networking mode for the run commands during + build Returns: A generator for the build output. @@ -198,6 +202,22 @@ def build(self, path=None, tag=None, quiet=False, fileobj=None, 'cache_from was only introduced in API version 1.25' ) + if target: + if utils.version_gte(self._version, '1.29'): + params.update({'target': target}) + else: + raise errors.InvalidVersion( + 'target was only introduced in API version 1.29' + ) + + if network_mode: + if utils.version_gte(self._version, '1.25'): + params.update({'networkmode': network_mode}) + else: + raise errors.InvalidVersion( + 'network_mode was only introduced in API version 1.25' + ) + if context is not None: headers = {'Content-Type': 'application/tar'} if encoding: diff --git a/docker/api/client.py b/docker/api/client.py index 54ec6abb45..65b5baa967 100644 --- a/docker/api/client.py +++ b/docker/api/client.py @@ -83,6 +83,12 @@ class APIClient( configuration. user_agent (str): Set a custom user agent for requests to the server. """ + + __attrs__ = requests.Session.__attrs__ + ['_auth_configs', + '_version', + 'base_url', + 'timeout'] + def __init__(self, base_url=None, version=None, timeout=DEFAULT_TIMEOUT_SECONDS, tls=False, user_agent=DEFAULT_USER_AGENT, num_pools=DEFAULT_NUM_POOLS): @@ -248,7 +254,7 @@ def _attach_params(self, override=None): 'stream': 1 } - @check_resource + @check_resource('container') def _attach_websocket(self, container, params=None): url = self._url("/containers/{0}/attach/ws", container) req = requests.Request("POST", url, params=self._attach_params(params)) diff --git a/docker/api/container.py b/docker/api/container.py index 97b5405935..532a9c6d8b 100644 --- a/docker/api/container.py +++ b/docker/api/container.py @@ -10,7 +10,7 @@ class ContainerApiMixin(object): - @utils.check_resource + @utils.check_resource('container') def attach(self, container, stdout=True, stderr=True, stream=False, logs=False): """ @@ -54,7 +54,7 @@ def attach(self, container, stdout=True, stderr=True, return self._read_from_socket(response, stream) - @utils.check_resource + @utils.check_resource('container') def attach_socket(self, container, params=None, ws=False): """ Like ``attach``, but returns the underlying socket-like object for the @@ -93,7 +93,7 @@ def attach_socket(self, container, params=None, ws=False): ) ) - @utils.check_resource + @utils.check_resource('container') def commit(self, container, repository=None, tag=None, message=None, author=None, changes=None, conf=None): """ @@ -195,7 +195,7 @@ def containers(self, quiet=False, all=False, trunc=False, latest=False, x['Id'] = x['Id'][:12] return res - @utils.check_resource + @utils.check_resource('container') def copy(self, container, resource): """ Identical to the ``docker cp`` command. Get files/folders from the @@ -238,7 +238,7 @@ def create_container(self, image, command=None, hostname=None, user=None, memswap_limit=None, cpuset=None, host_config=None, mac_address=None, labels=None, volume_driver=None, stop_signal=None, networking_config=None, - healthcheck=None, stop_timeout=None): + healthcheck=None, stop_timeout=None, runtime=None): """ Creates a container. Parameters are similar to those for the ``docker run`` command except it doesn't support the attach options (``-a``). @@ -391,8 +391,6 @@ def create_container(self, image, command=None, hostname=None, user=None, ``{"PASSWORD": "xxx"}``. dns (:py:class:`list`): DNS name servers. Deprecated since API version 1.10. Use ``host_config`` instead. - dns_opt (:py:class:`list`): Additional options to be added to the - container's ``resolv.conf`` file volumes (str or list): List of paths inside the container to use as volumes. volumes_from (:py:class:`list`): List of container names or Ids to @@ -417,6 +415,9 @@ def create_container(self, image, command=None, hostname=None, user=None, Default: 10 networking_config (dict): A networking configuration generated by :py:meth:`create_networking_config`. + runtime (str): Runtime to use with this container. + healthcheck (dict): Specify a test to perform to check that the + container is healthy. Returns: A dictionary with an image 'Id' key and a 'Warnings' key. @@ -441,7 +442,7 @@ def create_container(self, image, command=None, hostname=None, user=None, network_disabled, entrypoint, cpu_shares, working_dir, domainname, memswap_limit, cpuset, host_config, mac_address, labels, volume_driver, stop_signal, networking_config, healthcheck, - stop_timeout + stop_timeout, runtime ) return self.create_container_from_config(config, name) @@ -495,6 +496,8 @@ def create_host_config(self, *args, **kwargs): to have read-write access to the host's ``/dev/sda`` via a node named ``/dev/xvda`` inside the container. dns (:py:class:`list`): Set custom DNS servers. + dns_opt (:py:class:`list`): Additional options to be added to the + container's ``resolv.conf`` file dns_search (:py:class:`list`): DNS search domains. extra_hosts (dict): Addtional hostnames to resolve inside the container, as a mapping of hostname to IP address. @@ -576,6 +579,7 @@ def create_host_config(self, *args, **kwargs): values are: ``host`` volumes_from (:py:class:`list`): List of container names or IDs to get volumes from. + runtime (str): Runtime to use with this container. Returns: @@ -659,7 +663,7 @@ def create_endpoint_config(self, *args, **kwargs): """ return EndpointConfig(self._version, *args, **kwargs) - @utils.check_resource + @utils.check_resource('container') def diff(self, container): """ Inspect changes on a container's filesystem. @@ -678,7 +682,7 @@ def diff(self, container): self._get(self._url("/containers/{0}/changes", container)), True ) - @utils.check_resource + @utils.check_resource('container') def export(self, container): """ Export the contents of a filesystem as a tar archive. @@ -699,7 +703,7 @@ def export(self, container): self._raise_for_status(res) return res.raw - @utils.check_resource + @utils.check_resource('container') @utils.minimum_version('1.20') def get_archive(self, container, path): """ @@ -730,7 +734,7 @@ def get_archive(self, container, path): utils.decode_json_header(encoded_stat) if encoded_stat else None ) - @utils.check_resource + @utils.check_resource('container') def inspect_container(self, container): """ Identical to the `docker inspect` command, but only for containers. @@ -750,7 +754,7 @@ def inspect_container(self, container): self._get(self._url("/containers/{0}/json", container)), True ) - @utils.check_resource + @utils.check_resource('container') def kill(self, container, signal=None): """ Kill a container or send a signal to a container. @@ -773,7 +777,7 @@ def kill(self, container, signal=None): self._raise_for_status(res) - @utils.check_resource + @utils.check_resource('container') def logs(self, container, stdout=True, stderr=True, stream=False, timestamps=False, tail='all', since=None, follow=None): """ @@ -825,6 +829,11 @@ def logs(self, container, stdout=True, stderr=True, stream=False, params['since'] = utils.datetime_to_timestamp(since) elif (isinstance(since, int) and since > 0): params['since'] = since + else: + raise errors.InvalidArgument( + 'since value should be datetime or int, not {}'. + format(type(since)) + ) url = self._url("/containers/{0}/logs", container) res = self._get(url, params=params, stream=stream) return self._get_result(container, stream, res) @@ -836,7 +845,7 @@ def logs(self, container, stdout=True, stderr=True, stream=False, logs=True ) - @utils.check_resource + @utils.check_resource('container') def pause(self, container): """ Pauses all processes within a container. @@ -852,7 +861,7 @@ def pause(self, container): res = self._post(url) self._raise_for_status(res) - @utils.check_resource + @utils.check_resource('container') def port(self, container, private_port): """ Lookup the public-facing port that is NAT-ed to ``private_port``. @@ -901,7 +910,7 @@ def port(self, container, private_port): return h_ports - @utils.check_resource + @utils.check_resource('container') @utils.minimum_version('1.20') def put_archive(self, container, path, data): """ @@ -949,7 +958,7 @@ def prune_containers(self, filters=None): url = self._url('/containers/prune') return self._result(self._post(url, params=params), True) - @utils.check_resource + @utils.check_resource('container') def remove_container(self, container, v=False, link=False, force=False): """ Remove a container. Similar to the ``docker rm`` command. @@ -973,7 +982,7 @@ def remove_container(self, container, v=False, link=False, force=False): self._raise_for_status(res) @utils.minimum_version('1.17') - @utils.check_resource + @utils.check_resource('container') def rename(self, container, name): """ Rename a container. Similar to the ``docker rename`` command. @@ -991,7 +1000,7 @@ def rename(self, container, name): res = self._post(url, params=params) self._raise_for_status(res) - @utils.check_resource + @utils.check_resource('container') def resize(self, container, height, width): """ Resize the tty session. @@ -1010,7 +1019,7 @@ def resize(self, container, height, width): res = self._post(url, params=params) self._raise_for_status(res) - @utils.check_resource + @utils.check_resource('container') def restart(self, container, timeout=10): """ Restart a container. Similar to the ``docker restart`` command. @@ -1031,7 +1040,7 @@ def restart(self, container, timeout=10): res = self._post(url, params=params) self._raise_for_status(res) - @utils.check_resource + @utils.check_resource('container') def start(self, container, *args, **kwargs): """ Start a container. Similar to the ``docker start`` command, but @@ -1070,7 +1079,7 @@ def start(self, container, *args, **kwargs): self._raise_for_status(res) @utils.minimum_version('1.17') - @utils.check_resource + @utils.check_resource('container') def stats(self, container, decode=None, stream=True): """ Stream statistics for a specific container. Similar to the @@ -1096,7 +1105,7 @@ def stats(self, container, decode=None, stream=True): return self._result(self._get(url, params={'stream': False}), json=True) - @utils.check_resource + @utils.check_resource('container') def stop(self, container, timeout=10): """ Stops a container. Similar to the ``docker stop`` command. @@ -1117,7 +1126,7 @@ def stop(self, container, timeout=10): timeout=(timeout + (self.timeout or 0))) self._raise_for_status(res) - @utils.check_resource + @utils.check_resource('container') def top(self, container, ps_args=None): """ Display the running processes of a container. @@ -1139,7 +1148,7 @@ def top(self, container, ps_args=None): params['ps_args'] = ps_args return self._result(self._get(u, params=params), True) - @utils.check_resource + @utils.check_resource('container') def unpause(self, container): """ Unpause all processes within a container. @@ -1152,7 +1161,7 @@ def unpause(self, container): self._raise_for_status(res) @utils.minimum_version('1.22') - @utils.check_resource + @utils.check_resource('container') def update_container( self, container, blkio_weight=None, cpu_period=None, cpu_quota=None, cpu_shares=None, cpuset_cpus=None, cpuset_mems=None, mem_limit=None, @@ -1217,7 +1226,7 @@ def update_container( res = self._post_json(url, data=data) return self._result(res, True) - @utils.check_resource + @utils.check_resource('container') def wait(self, container, timeout=None): """ Block until a container stops, then return its exit code. Similar to diff --git a/docker/api/exec_api.py b/docker/api/exec_api.py index 3ff65256ef..2b407cef40 100644 --- a/docker/api/exec_api.py +++ b/docker/api/exec_api.py @@ -6,7 +6,7 @@ class ExecApiMixin(object): @utils.minimum_version('1.15') - @utils.check_resource + @utils.check_resource('container') def exec_create(self, container, cmd, stdout=True, stderr=True, stdin=False, tty=False, privileged=False, user='', environment=None): @@ -110,7 +110,7 @@ def exec_resize(self, exec_id, height=None, width=None): self._raise_for_status(res) @utils.minimum_version('1.15') - @utils.check_resource + @utils.check_resource('exec_id') def exec_start(self, exec_id, detach=False, tty=False, stream=False, socket=False): """ diff --git a/docker/api/image.py b/docker/api/image.py index 09eb086d78..181c4a1e4a 100644 --- a/docker/api/image.py +++ b/docker/api/image.py @@ -12,7 +12,7 @@ class ImageApiMixin(object): - @utils.check_resource + @utils.check_resource('image') def get_image(self, image): """ Get a tarball of an image. Similar to the ``docker save`` command. @@ -39,7 +39,7 @@ def get_image(self, image): self._raise_for_status(res) return res.raw - @utils.check_resource + @utils.check_resource('image') def history(self, image): """ Show the history of an image. @@ -228,7 +228,7 @@ def import_image_from_image(self, image, repository=None, tag=None, image=image, repository=repository, tag=tag, changes=changes ) - @utils.check_resource + @utils.check_resource('image') def insert(self, image, url, path): if utils.compare_version('1.12', self._version) >= 0: raise errors.DeprecatedMethod( @@ -241,7 +241,7 @@ def insert(self, image, url, path): } return self._result(self._post(api_url, params=params)) - @utils.check_resource + @utils.check_resource('image') def inspect_image(self, image): """ Get detailed information about an image. Similar to the ``docker @@ -443,7 +443,7 @@ def push(self, repository, tag=None, stream=False, return self._result(response) - @utils.check_resource + @utils.check_resource('image') def remove_image(self, image, force=False, noprune=False): """ Remove an image. Similar to the ``docker rmi`` command. @@ -477,7 +477,7 @@ def search(self, term): True ) - @utils.check_resource + @utils.check_resource('image') def tag(self, image, repository, tag=None, force=False): """ Tag an image into a repository. Similar to the ``docker tag`` command. diff --git a/docker/api/network.py b/docker/api/network.py index 74f4cd2b30..5ebb41af34 100644 --- a/docker/api/network.py +++ b/docker/api/network.py @@ -41,7 +41,8 @@ def networks(self, names=None, ids=None, filters=None): @minimum_version('1.21') def create_network(self, name, driver=None, options=None, ipam=None, check_duplicate=None, internal=False, labels=None, - enable_ipv6=False, attachable=None, scope=None): + enable_ipv6=False, attachable=None, scope=None, + ingress=None): """ Create a network. Similar to the ``docker network create``. @@ -60,6 +61,8 @@ def create_network(self, name, driver=None, options=None, ipam=None, attachable (bool): If enabled, and the network is in the global scope, non-service containers on worker nodes will be able to connect to the network. + ingress (bool): If set, create an ingress network which provides + the routing-mesh in swarm mode. Returns: (dict): The created network reference object @@ -129,6 +132,14 @@ def create_network(self, name, driver=None, options=None, ipam=None, ) data['Attachable'] = attachable + if ingress is not None: + if version_lt(self._version, '1.29'): + raise InvalidVersion( + 'ingress is not supported in API version < 1.29' + ) + + data['Ingress'] = ingress + url = self._url("/networks/create") res = self._post_json(url, data=data) return self._result(res, json=True) @@ -156,6 +167,7 @@ def prune_networks(self, filters=None): return self._result(self._post(url, params=params), True) @minimum_version('1.21') + @check_resource('net_id') def remove_network(self, net_id): """ Remove a network. Similar to the ``docker network rm`` command. @@ -168,6 +180,7 @@ def remove_network(self, net_id): self._raise_for_status(res) @minimum_version('1.21') + @check_resource('net_id') def inspect_network(self, net_id, verbose=None): """ Get detailed information about a network. @@ -187,7 +200,7 @@ def inspect_network(self, net_id, verbose=None): res = self._get(url, params=params) return self._result(res, json=True) - @check_resource + @check_resource('image') @minimum_version('1.21') def connect_container_to_network(self, container, net_id, ipv4_address=None, ipv6_address=None, @@ -224,7 +237,7 @@ def connect_container_to_network(self, container, net_id, res = self._post_json(url, data=data) self._raise_for_status(res) - @check_resource + @check_resource('image') @minimum_version('1.21') def disconnect_container_from_network(self, container, net_id, force=False): diff --git a/docker/api/plugin.py b/docker/api/plugin.py index ba40c88297..87520ccee3 100644 --- a/docker/api/plugin.py +++ b/docker/api/plugin.py @@ -5,7 +5,7 @@ class PluginApiMixin(object): @utils.minimum_version('1.25') - @utils.check_resource + @utils.check_resource('name') def configure_plugin(self, name, options): """ Configure a plugin. @@ -171,7 +171,7 @@ def plugin_privileges(self, name): return self._result(self._get(url, params=params), True) @utils.minimum_version('1.25') - @utils.check_resource + @utils.check_resource('name') def push_plugin(self, name): """ Push a plugin to the registry. @@ -195,7 +195,7 @@ def push_plugin(self, name): return self._stream_helper(res, decode=True) @utils.minimum_version('1.25') - @utils.check_resource + @utils.check_resource('name') def remove_plugin(self, name, force=False): """ Remove an installed plugin. @@ -215,7 +215,7 @@ def remove_plugin(self, name, force=False): return True @utils.minimum_version('1.26') - @utils.check_resource + @utils.check_resource('name') def upgrade_plugin(self, name, remote, privileges): """ Upgrade an installed plugin. diff --git a/docker/api/secret.py b/docker/api/secret.py index 03534a6236..1760a39469 100644 --- a/docker/api/secret.py +++ b/docker/api/secret.py @@ -36,7 +36,7 @@ def create_secret(self, name, data, labels=None): ) @utils.minimum_version('1.25') - @utils.check_resource + @utils.check_resource('id') def inspect_secret(self, id): """ Retrieve secret metadata @@ -54,7 +54,7 @@ def inspect_secret(self, id): return self._result(self._get(url), True) @utils.minimum_version('1.25') - @utils.check_resource + @utils.check_resource('id') def remove_secret(self, id): """ Remove a secret diff --git a/docker/api/service.py b/docker/api/service.py index 4972c16d1c..cc16cc37dd 100644 --- a/docker/api/service.py +++ b/docker/api/service.py @@ -3,6 +3,48 @@ from ..types import ServiceMode +def _check_api_features(version, task_template, update_config): + if update_config is not None: + if utils.version_lt(version, '1.25'): + if 'MaxFailureRatio' in update_config: + raise errors.InvalidVersion( + 'UpdateConfig.max_failure_ratio is not supported in' + ' API version < 1.25' + ) + if 'Monitor' in update_config: + raise errors.InvalidVersion( + 'UpdateConfig.monitor is not supported in' + ' API version < 1.25' + ) + + if task_template is not None: + if 'ForceUpdate' in task_template and utils.version_lt( + version, '1.25'): + raise errors.InvalidVersion( + 'force_update is not supported in API version < 1.25' + ) + + if task_template.get('Placement'): + if utils.version_lt(version, '1.30'): + if task_template['Placement'].get('Platforms'): + raise errors.InvalidVersion( + 'Placement.platforms is not supported in' + ' API version < 1.30' + ) + + if utils.version_lt(version, '1.27'): + if task_template['Placement'].get('Preferences'): + raise errors.InvalidVersion( + 'Placement.preferences is not supported in' + ' API version < 1.27' + ) + if task_template.container_spec.get('TTY'): + if utils.version_lt(version, '1.25'): + raise errors.InvalidVersion( + 'ContainerSpec.TTY is not supported in API version < 1.25' + ) + + class ServiceApiMixin(object): @utils.minimum_version('1.24') def create_service( @@ -43,6 +85,8 @@ def create_service( ) endpoint_spec = endpoint_config + _check_api_features(self._version, task_template, update_config) + url = self._url('/services/create') headers = {} image = task_template.get('ContainerSpec', {}).get('Image', None) @@ -67,17 +111,6 @@ def create_service( } if update_config is not None: - if utils.version_lt(self._version, '1.25'): - if 'MaxFailureRatio' in update_config: - raise errors.InvalidVersion( - 'UpdateConfig.max_failure_ratio is not supported in' - ' API version < 1.25' - ) - if 'Monitor' in update_config: - raise errors.InvalidVersion( - 'UpdateConfig.monitor is not supported in' - ' API version < 1.25' - ) data['UpdateConfig'] = update_config return self._result( @@ -85,7 +118,7 @@ def create_service( ) @utils.minimum_version('1.24') - @utils.check_resource + @utils.check_resource('service') def inspect_service(self, service): """ Return information about a service. @@ -104,7 +137,7 @@ def inspect_service(self, service): return self._result(self._get(url), True) @utils.minimum_version('1.24') - @utils.check_resource + @utils.check_resource('task') def inspect_task(self, task): """ Retrieve information about a task. @@ -123,7 +156,7 @@ def inspect_task(self, task): return self._result(self._get(url), True) @utils.minimum_version('1.24') - @utils.check_resource + @utils.check_resource('service') def remove_service(self, service): """ Stop and remove a service. @@ -167,7 +200,7 @@ def services(self, filters=None): return self._result(self._get(url, params=params), True) @utils.minimum_version('1.25') - @utils.check_resource + @utils.check_resource('service') def service_logs(self, service, details=False, follow=False, stdout=False, stderr=False, since=0, timestamps=False, tail='all', is_tty=None): @@ -241,7 +274,7 @@ def tasks(self, filters=None): return self._result(self._get(url, params=params), True) @utils.minimum_version('1.24') - @utils.check_resource + @utils.check_resource('service') def update_service(self, service, version, task_template=None, name=None, labels=None, mode=None, update_config=None, networks=None, endpoint_config=None, @@ -282,6 +315,8 @@ def update_service(self, service, version, task_template=None, name=None, ) endpoint_spec = endpoint_config + _check_api_features(self._version, task_template, update_config) + url = self._url('/services/{0}/update', service) data = {} headers = {} @@ -294,12 +329,6 @@ def update_service(self, service, version, task_template=None, name=None, mode = ServiceMode(mode) data['Mode'] = mode if task_template is not None: - if 'ForceUpdate' in task_template and utils.version_lt( - self._version, '1.25'): - raise errors.InvalidVersion( - 'force_update is not supported in API version < 1.25' - ) - image = task_template.get('ContainerSpec', {}).get('Image', None) if image is not None: registry, repo_name = auth.resolve_repository_name(image) @@ -308,17 +337,6 @@ def update_service(self, service, version, task_template=None, name=None, headers['X-Registry-Auth'] = auth_header data['TaskTemplate'] = task_template if update_config is not None: - if utils.version_lt(self._version, '1.25'): - if 'MaxFailureRatio' in update_config: - raise errors.InvalidVersion( - 'UpdateConfig.max_failure_ratio is not supported in' - ' API version < 1.25' - ) - if 'Monitor' in update_config: - raise errors.InvalidVersion( - 'UpdateConfig.monitor is not supported in' - ' API version < 1.25' - ) data['UpdateConfig'] = update_config if networks is not None: diff --git a/docker/api/swarm.py b/docker/api/swarm.py index 88770562f2..4fa0c4a120 100644 --- a/docker/api/swarm.py +++ b/docker/api/swarm.py @@ -117,7 +117,7 @@ def inspect_swarm(self): url = self._url('/swarm') return self._result(self._get(url), True) - @utils.check_resource + @utils.check_resource('node_id') @utils.minimum_version('1.24') def inspect_node(self, node_id): """ @@ -228,7 +228,7 @@ def nodes(self, filters=None): return self._result(self._get(url, params=params), True) - @utils.check_resource + @utils.check_resource('node_id') @utils.minimum_version('1.24') def remove_node(self, node_id, force=False): """ diff --git a/docker/auth.py b/docker/auth.py index 7c1ce7618e..ec9c45b97d 100644 --- a/docker/auth.py +++ b/docker/auth.py @@ -70,6 +70,15 @@ def split_repo_name(repo_name): return tuple(parts) +def get_credential_store(authconfig, registry): + if not registry or registry == INDEX_NAME: + registry = 'https://index.docker.io/v1/' + + return authconfig.get('credHelpers', {}).get(registry) or authconfig.get( + 'credsStore' + ) + + def resolve_authconfig(authconfig, registry=None): """ Returns the authentication data from the given auth configuration for a @@ -77,13 +86,17 @@ def resolve_authconfig(authconfig, registry=None): with full URLs are stripped down to hostnames before checking for a match. Returns None if no match was found. """ - if 'credsStore' in authconfig: - log.debug( - 'Using credentials store "{0}"'.format(authconfig['credsStore']) - ) - return _resolve_authconfig_credstore( - authconfig, registry, authconfig['credsStore'] - ) + + if 'credHelpers' in authconfig or 'credsStore' in authconfig: + store_name = get_credential_store(authconfig, registry) + if store_name is not None: + log.debug( + 'Using credentials store "{0}"'.format(store_name) + ) + return _resolve_authconfig_credstore( + authconfig, registry, store_name + ) + # Default to the public index server registry = resolve_index_name(registry) if registry else INDEX_NAME log.debug("Looking for auth entry for {0}".format(repr(registry))) @@ -274,6 +287,9 @@ def load_config(config_path=None): if data.get('credsStore'): log.debug("Found 'credsStore' section") res.update({'credsStore': data['credsStore']}) + if data.get('credHelpers'): + log.debug("Found 'credHelpers' section") + res.update({'credHelpers': data['credHelpers']}) if res: return res else: diff --git a/docker/client.py b/docker/client.py index 09abd63322..ee361bb961 100644 --- a/docker/client.py +++ b/docker/client.py @@ -1,4 +1,5 @@ from .api.client import APIClient +from .constants import DEFAULT_TIMEOUT_SECONDS from .models.containers import ContainerCollection from .models.images import ImageCollection from .models.networks import NetworkCollection @@ -73,7 +74,7 @@ def from_env(cls, **kwargs): .. _`SSL version`: https://docs.python.org/3.5/library/ssl.html#ssl.PROTOCOL_TLSv1 """ - timeout = kwargs.pop('timeout', None) + timeout = kwargs.pop('timeout', DEFAULT_TIMEOUT_SECONDS) version = kwargs.pop('version', None) return cls(timeout=timeout, version=version, **kwargs_from_env(**kwargs)) @@ -119,6 +120,7 @@ def plugins(self): """ return PluginCollection(client=self) + @property def secrets(self): """ An object for managing secrets on the server. See the diff --git a/docker/models/containers.py b/docker/models/containers.py index 4bb2cf8638..cf01b2750a 100644 --- a/docker/models/containers.py +++ b/docker/models/containers.py @@ -516,6 +516,8 @@ def run(self, image, command=None, stdout=True, stderr=False, container, as a mapping of hostname to IP address. group_add (:py:class:`list`): List of additional group names and/or IDs that the container process will run as. + healthcheck (dict): Specify a test to perform to check that the + container is healthy. hostname (str): Optional hostname for the container. init (bool): Run an init inside the container that forwards signals and reaps processes @@ -659,6 +661,7 @@ def run(self, image, command=None, stdout=True, stderr=False, volumes_from (:py:class:`list`): List of container names or IDs to get volumes from. working_dir (str): Path to the working directory. + runtime (str): Runtime to use with this container. Returns: The container logs, either ``STDOUT``, ``STDERR``, or both, @@ -885,6 +888,7 @@ def prune(self, filters=None): 'userns_mode', 'version', 'volumes_from', + 'runtime' ] diff --git a/docker/models/images.py b/docker/models/images.py index 52a44b27bf..d4e24c6060 100644 --- a/docker/models/images.py +++ b/docker/models/images.py @@ -96,7 +96,7 @@ def tag(self, repository, tag=None, **kwargs): Returns: (bool): ``True`` if successful """ - self.client.api.tag(self.id, repository, tag=tag, **kwargs) + return self.client.api.tag(self.id, repository, tag=tag, **kwargs) class ImageCollection(Collection): @@ -126,9 +126,6 @@ def build(self, **kwargs): rm (bool): Remove intermediate containers. The ``docker build`` command now defaults to ``--rm=true``, but we have kept the old default of `False` to preserve backward compatibility - stream (bool): *Deprecated for API version > 1.8 (always True)*. - Return a blocking generator you can iterate over to retrieve - build output as it happens timeout (int): HTTP timeout custom_context (bool): Optional if using ``fileobj`` encoding (str): The encoding for a stream. Set to ``gzip`` for @@ -147,10 +144,15 @@ def build(self, **kwargs): - cpushares (int): CPU shares (relative weight) - cpusetcpus (str): CPUs in which to allow execution, e.g., ``"0-3"``, ``"0,1"`` - decode (bool): If set to ``True``, the returned stream will be - decoded into dicts on the fly. Default ``False``. + shmsize (int): Size of `/dev/shm` in bytes. The size must be + greater than 0. If omitted the system uses 64MB + labels (dict): A dictionary of labels to set on the image cache_from (list): A list of images used for build cache - resolution. + resolution + target (str): Name of the build-stage to build in a multi-stage + Dockerfile + network_mode (str): networking mode for the run commands during + build Returns: (:py:class:`Image`): The built image. @@ -167,19 +169,20 @@ def build(self, **kwargs): if isinstance(resp, six.string_types): return self.get(resp) last_event = None + image_id = None for chunk in json_stream(resp): if 'error' in chunk: raise BuildError(chunk['error']) if 'stream' in chunk: match = re.search( - r'(Successfully built |sha256:)([0-9a-f]+)', + r'(^Successfully built |sha256:)([0-9a-f]+)$', chunk['stream'] ) if match: image_id = match.group(2) - return self.get(image_id) last_event = chunk - + if image_id: + return self.get(image_id) raise BuildError(last_event or 'Unknown') def get(self, name): diff --git a/docker/models/networks.py b/docker/models/networks.py index 586809753b..afb0ebe8b2 100644 --- a/docker/models/networks.py +++ b/docker/models/networks.py @@ -111,6 +111,8 @@ def create(self, name, *args, **kwargs): labels (dict): Map of labels to set on the network. Default ``None``. enable_ipv6 (bool): Enable IPv6 on the network. Default ``False``. + ingress (bool): If set, create an ingress network which provides + the routing-mesh in swarm mode. Returns: (:py:class:`Network`): The network that was created. diff --git a/docker/models/services.py b/docker/models/services.py index c10804dedf..e1e2ea6a44 100644 --- a/docker/models/services.py +++ b/docker/models/services.py @@ -146,6 +146,7 @@ def create(self, image, command=None, **kwargs): of the service. Default: ``None`` user (str): User to run commands as. workdir (str): Working directory for commands to run. + tty (boolean): Whether a pseudo-TTY should be allocated. Returns: (:py:class:`Service`) The created service. @@ -212,6 +213,7 @@ def list(self, **kwargs): 'mounts', 'stop_grace_period', 'secrets', + 'tty' ] # kwargs to copy straight over to TaskTemplate diff --git a/docker/transport/npipeconn.py b/docker/transport/npipeconn.py index db059b445a..ab9b90480a 100644 --- a/docker/transport/npipeconn.py +++ b/docker/transport/npipeconn.py @@ -69,6 +69,11 @@ def _get_conn(self, timeout): class NpipeAdapter(requests.adapters.HTTPAdapter): + + __attrs__ = requests.adapters.HTTPAdapter.__attrs__ + ['npipe_path', + 'pools', + 'timeout'] + def __init__(self, base_url, timeout=60, pool_connections=constants.DEFAULT_NUM_POOLS): self.npipe_path = base_url.replace('npipe://', '') diff --git a/docker/transport/ssladapter.py b/docker/transport/ssladapter.py index 31f45fc459..8fafec3550 100644 --- a/docker/transport/ssladapter.py +++ b/docker/transport/ssladapter.py @@ -25,6 +25,10 @@ class SSLAdapter(HTTPAdapter): '''An HTTPS Transport Adapter that uses an arbitrary SSL version.''' + __attrs__ = HTTPAdapter.__attrs__ + ['assert_fingerprint', + 'assert_hostname', + 'ssl_version'] + def __init__(self, ssl_version=None, assert_hostname=None, assert_fingerprint=None, **kwargs): self.ssl_version = ssl_version diff --git a/docker/transport/unixconn.py b/docker/transport/unixconn.py index 978c87a1bf..3565cfb629 100644 --- a/docker/transport/unixconn.py +++ b/docker/transport/unixconn.py @@ -50,6 +50,11 @@ def _new_conn(self): class UnixAdapter(requests.adapters.HTTPAdapter): + + __attrs__ = requests.adapters.HTTPAdapter.__attrs__ + ['pools', + 'socket_path', + 'timeout'] + def __init__(self, socket_url, timeout=60, pool_connections=constants.DEFAULT_NUM_POOLS): socket_path = socket_url.replace('http+unix://', '') diff --git a/docker/types/__init__.py b/docker/types/__init__.py index 0e88776013..edc919dfcf 100644 --- a/docker/types/__init__.py +++ b/docker/types/__init__.py @@ -3,7 +3,7 @@ from .healthcheck import Healthcheck from .networks import EndpointConfig, IPAMConfig, IPAMPool, NetworkingConfig from .services import ( - ContainerSpec, DriverConfig, EndpointSpec, Mount, Resources, RestartPolicy, - SecretReference, ServiceMode, TaskTemplate, UpdateConfig + ContainerSpec, DriverConfig, EndpointSpec, Mount, Placement, Resources, + RestartPolicy, SecretReference, ServiceMode, TaskTemplate, UpdateConfig ) from .swarm import SwarmSpec, SwarmExternalCA diff --git a/docker/types/containers.py b/docker/types/containers.py index 18d18381da..030e292bc6 100644 --- a/docker/types/containers.py +++ b/docker/types/containers.py @@ -120,7 +120,7 @@ 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): + cpuset_mems=None, runtime=None): if mem_limit is not None: self['Memory'] = parse_bytes(mem_limit) @@ -466,13 +466,18 @@ def __init__(self, version, binds=None, port_bindings=None, self['CpuPercent'] = cpu_percent if nano_cpus: - if not isinstance(nano_cpus, int): + if not isinstance(nano_cpus, six.integer_types): raise host_config_type_error('nano_cpus', nano_cpus, 'int') if version_lt(version, '1.25'): raise host_config_version_error('nano_cpus', '1.25') self['NanoCpus'] = nano_cpus + if runtime: + if version_lt(version, '1.25'): + raise host_config_version_error('runtime', '1.25') + self['Runtime'] = runtime + def host_config_type_error(param, param_value, expected): error_msg = 'Invalid type for {0} param: expected {1} but found {2}' @@ -499,7 +504,7 @@ def __init__( working_dir=None, domainname=None, memswap_limit=None, cpuset=None, host_config=None, mac_address=None, labels=None, volume_driver=None, stop_signal=None, networking_config=None, healthcheck=None, - stop_timeout=None + stop_timeout=None, runtime=None ): if version_gte(version, '1.10'): message = ('{0!r} parameter has no effect on create_container().' @@ -560,10 +565,17 @@ def __init__( 'stop_timeout was only introduced in API version 1.25' ) - if healthcheck is not None and version_lt(version, '1.24'): - raise errors.InvalidVersion( - 'Health options were only introduced in API version 1.24' - ) + if healthcheck is not None: + if version_lt(version, '1.24'): + raise errors.InvalidVersion( + 'Health options were only introduced in API version 1.24' + ) + + if version_lt(version, '1.29') and 'StartPeriod' in healthcheck: + raise errors.InvalidVersion( + 'healthcheck start period was introduced in API ' + 'version 1.29' + ) if isinstance(command, six.string_types): command = split_command(command) @@ -659,5 +671,6 @@ def __init__( 'VolumeDriver': volume_driver, 'StopSignal': stop_signal, 'Healthcheck': healthcheck, - 'StopTimeout': stop_timeout + 'StopTimeout': stop_timeout, + 'Runtime': runtime }) diff --git a/docker/types/healthcheck.py b/docker/types/healthcheck.py index ba63d21ed6..8ea9a35f5b 100644 --- a/docker/types/healthcheck.py +++ b/docker/types/healthcheck.py @@ -12,12 +12,14 @@ def __init__(self, **kwargs): interval = kwargs.get('interval', kwargs.get('Interval')) timeout = kwargs.get('timeout', kwargs.get('Timeout')) retries = kwargs.get('retries', kwargs.get('Retries')) + start_period = kwargs.get('start_period', kwargs.get('StartPeriod')) super(Healthcheck, self).__init__({ 'Test': test, 'Interval': interval, 'Timeout': timeout, - 'Retries': retries + 'Retries': retries, + 'StartPeriod': start_period }) @property @@ -51,3 +53,11 @@ def retries(self): @retries.setter def retries(self, value): self['Retries'] = value + + @property + def start_period(self): + return self['StartPeriod'] + + @start_period.setter + def start_period(self, value): + self['StartPeriod'] = value diff --git a/docker/types/services.py b/docker/types/services.py index 012f7b0199..8411b70a40 100644 --- a/docker/types/services.py +++ b/docker/types/services.py @@ -20,7 +20,9 @@ class TaskTemplate(dict): individual container created as part of the service. restart_policy (RestartPolicy): Specification for the restart policy which applies to containers created as part of this service. - placement (:py:class:`list`): A list of constraints. + 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. force_update (int): A counter that triggers an update even if no relevant parameters have been changed. """ @@ -33,7 +35,7 @@ def __init__(self, container_spec, resources=None, restart_policy=None, self['RestartPolicy'] = restart_policy if placement: if isinstance(placement, list): - placement = {'Constraints': placement} + placement = Placement(constraints=placement) self['Placement'] = placement if log_driver: self['LogDriver'] = log_driver @@ -82,10 +84,11 @@ class ContainerSpec(dict): terminate before forcefully killing it. secrets (list of py:class:`SecretReference`): List of secrets to be made available inside the containers. + tty (boolean): Whether a pseudo-TTY should be allocated. """ def __init__(self, image, command=None, args=None, hostname=None, env=None, workdir=None, user=None, labels=None, mounts=None, - stop_grace_period=None, secrets=None): + stop_grace_period=None, secrets=None, tty=None): self['Image'] = image if isinstance(command, six.string_types): @@ -123,6 +126,9 @@ def __init__(self, image, command=None, args=None, hostname=None, env=None, raise TypeError('secrets must be a list') self['Secrets'] = secrets + if tty is not None: + self['TTY'] = tty + class Mount(dict): """ @@ -441,7 +447,7 @@ class SecretReference(dict): gid (string): GID of the secret file's group. Default: 0 mode (int): File access mode inside the container. Default: 0o444 """ - @check_resource + @check_resource('secret_id') def __init__(self, secret_id, secret_name, filename=None, uid=None, gid=None, mode=0o444): self['SecretName'] = secret_name @@ -452,3 +458,28 @@ def __init__(self, secret_id, secret_name, filename=None, uid=None, 'GID': gid or '0', 'Mode': mode } + + +class Placement(dict): + """ + Placement constraints to be used as part of a :py:class:`TaskTemplate` + + Args: + constraints (list): A list of constraints + preferences (list): Preferences provide a way to make the + scheduler aware of factors such as topology. They are provided + in order from highest to lowest precedence. + platforms (list): A list of platforms expressed as ``(arch, os)`` + tuples + """ + def __init__(self, constraints=None, preferences=None, platforms=None): + if constraints is not None: + self['Constraints'] = constraints + if preferences is not None: + self['Preferences'] = preferences + if platforms: + self['Platforms'] = [] + for plat in platforms: + self['Platforms'].append({ + 'Architecture': plat[0], 'OS': plat[1] + }) diff --git a/docker/utils/decorators.py b/docker/utils/decorators.py index 18cde412ff..5e195c0ea6 100644 --- a/docker/utils/decorators.py +++ b/docker/utils/decorators.py @@ -4,22 +4,21 @@ from . import utils -def check_resource(f): - @functools.wraps(f) - def wrapped(self, resource_id=None, *args, **kwargs): - if resource_id is None: - if kwargs.get('container'): - resource_id = kwargs.pop('container') - elif kwargs.get('image'): - resource_id = kwargs.pop('image') - if isinstance(resource_id, dict): - resource_id = resource_id.get('Id', resource_id.get('ID')) - if not resource_id: - raise errors.NullResource( - 'Resource ID was not provided' - ) - return f(self, resource_id, *args, **kwargs) - return wrapped +def check_resource(resource_name): + def decorator(f): + @functools.wraps(f) + def wrapped(self, resource_id=None, *args, **kwargs): + if resource_id is None and kwargs.get(resource_name): + resource_id = kwargs.pop(resource_name) + if isinstance(resource_id, dict): + resource_id = resource_id.get('Id', resource_id.get('ID')) + if not resource_id: + raise errors.NullResource( + 'Resource ID was not provided' + ) + return f(self, resource_id, *args, **kwargs) + return wrapped + return decorator def minimum_version(version): diff --git a/docker/utils/ports.py b/docker/utils/ports.py index 3708958d4e..57332deee4 100644 --- a/docker/utils/ports.py +++ b/docker/utils/ports.py @@ -1,3 +1,16 @@ +import re + +PORT_SPEC = re.compile( + "^" # Match full string + "(" # External part + "((?P[a-fA-F\d.:]+):)?" # Address + "(?P[\d]*)(-(?P[\d]+))?:" # External range + ")?" + "(?P[\d]+)(-(?P[\d]+))?" # Internal range + "(?P/(udp|tcp))?" # Protocol + "$" # Match full string +) + def add_port_mapping(port_bindings, internal_port, external): if internal_port in port_bindings: @@ -24,81 +37,41 @@ def build_port_bindings(ports): return port_bindings -def to_port_range(port, randomly_available_port=False): - if not port: - return None - - protocol = "" - if "/" in port: - parts = port.split("/") - if len(parts) != 2: - _raise_invalid_port(port) - - port, protocol = parts - protocol = "/" + protocol - - if randomly_available_port: - return ["%s%s" % (port, protocol)] - - parts = str(port).split('-') - - if len(parts) == 1: - return ["%s%s" % (port, protocol)] - - if len(parts) == 2: - full_port_range = range(int(parts[0]), int(parts[1]) + 1) - return ["%s%s" % (p, protocol) for p in full_port_range] - - raise ValueError('Invalid port range "%s", should be ' - 'port or startport-endport' % port) - - def _raise_invalid_port(port): raise ValueError('Invalid port "%s", should be ' '[[remote_ip:]remote_port[-remote_port]:]' 'port[/protocol]' % port) -def split_port(port): - parts = str(port).split(':') - - if not 1 <= len(parts) <= 3: - _raise_invalid_port(port) - - if len(parts) == 1: - internal_port, = parts - if not internal_port: - _raise_invalid_port(port) - return to_port_range(internal_port), None - if len(parts) == 2: - external_port, internal_port = parts - - internal_range = to_port_range(internal_port) - if internal_range is None: - _raise_invalid_port(port) - - external_range = to_port_range(external_port, len(internal_range) == 1) - if external_range is None: - _raise_invalid_port(port) - - if len(internal_range) != len(external_range): - raise ValueError('Port ranges don\'t match in length') - - return internal_range, external_range +def port_range(start, end, proto, randomly_available_port=False): + if not start: + return start + if not end: + return [start + proto] + if randomly_available_port: + return ['{}-{}'.format(start, end) + proto] + return [str(port) + proto for port in range(int(start), int(end) + 1)] - external_ip, external_port, internal_port = parts - if not internal_port: +def split_port(port): + match = PORT_SPEC.match(port) + if match is None: _raise_invalid_port(port) + parts = match.groupdict() - internal_range = to_port_range(internal_port) - external_range = to_port_range(external_port, len(internal_range) == 1) - - if not external_range: - external_range = [None] * len(internal_range) - - if len(internal_range) != len(external_range): - raise ValueError('Port ranges don\'t match in length') + host = parts['host'] + proto = parts['proto'] or '' + internal = port_range(parts['int'], parts['int_end'], proto) + external = port_range( + parts['ext'], parts['ext_end'], '', len(internal) == 1) - return internal_range, [(external_ip, ex_port or None) - for ex_port in external_range] + if host is None: + if external is not None and len(internal) != len(external): + raise ValueError('Port ranges don\'t match in length') + return internal, external + else: + if not external: + external = [None] * len(internal) + elif len(internal) != len(external): + raise ValueError('Port ranges don\'t match in length') + return internal, [(host, ext_port) for ext_port in external] diff --git a/docker/version.py b/docker/version.py index c734a16114..8f40f467a4 100644 --- a/docker/version.py +++ b/docker/version.py @@ -1,2 +1,2 @@ -version = "2.3.0" +version = "2.4.0" version_info = tuple([int(d) for d in version.split("-")[0].split(".")]) diff --git a/docs/api.rst b/docs/api.rst index 52cd26b2ca..0b10f387db 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -128,6 +128,7 @@ Configuration types .. autoclass:: DriverConfig .. autoclass:: EndpointSpec .. autoclass:: Mount +.. autoclass:: Placement .. autoclass:: Resources .. autoclass:: RestartPolicy .. autoclass:: SecretReference diff --git a/docs/change-log.md b/docs/change-log.md index 3d58f931ff..194f734795 100644 --- a/docs/change-log.md +++ b/docs/change-log.md @@ -1,6 +1,44 @@ Change log ========== +2.4.0 +----- + +[List of PRs / issues for this release](https://github.com/docker/docker-py/milestone/33?closed=1) + +### Features + +* Added support for the `target` and `network_mode` parameters in + `APIClient.build` and `DockerClient.images.build`. +* Added support for the `runtime` parameter in `APIClient.create_container` + and `DockerClient.containers.run`. +* Added support for the `ingress` parameter in `APIClient.create_network` and + `DockerClient.networks.create`. +* Added support for `placement` configuration in `docker.types.TaskTemplate`. +* Added support for `tty` configuration in `docker.types.ContainerSpec`. +* Added support for `start_period` configuration in `docker.types.Healthcheck`. +* The `credHelpers` section in Docker's configuration file is now recognized. +* Port specifications including IPv6 endpoints are now supported. + +### Bugfixes + +* Fixed a bug where instantiating a `DockerClient` using `docker.from_env` + wouldn't correctly set the default timeout value. +* Fixed a bug where `DockerClient.secrets` was not accessible as a property. +* Fixed a bug where `DockerClient.build` would sometimes return the wrong + image. +* Fixed a bug where values for `HostConfig.nano_cpus` exceeding 2^32 would + raise a type error. +* `Image.tag` now properly returns `True` when the operation is successful. +* `APIClient.logs` and `Container.logs` now raise an exception if the `since` + argument uses an unsupported type instead of ignoring the value. +* Fixed a bug where some methods would raise a `NullResource` exception when + the resource ID was provided using a keyword argument. + +### Miscellaneous + +* `APIClient` instances can now be pickled. + 2.3.0 ----- diff --git a/setup.py b/setup.py index 9fc4ad66e9..31180d2397 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', + 'requests >= 2.5.2, != 2.11.0, != 2.12.2, != 2.18.0', 'six >= 1.4.0', 'websocket-client >= 0.32.0', 'docker-pycreds >= 0.2.1' diff --git a/tests/integration/api_build_test.py b/tests/integration/api_build_test.py index fe5d994dd6..609964f0b0 100644 --- a/tests/integration/api_build_test.py +++ b/tests/integration/api_build_test.py @@ -5,6 +5,7 @@ from docker import errors +import pytest import six from .base import BaseAPIIntegrationTest @@ -189,6 +190,60 @@ def test_build_with_cache_from(self): counter += 1 assert counter == 0 + @requires_api_version('1.29') + def test_build_container_with_target(self): + script = io.BytesIO('\n'.join([ + 'FROM busybox as first', + 'RUN mkdir -p /tmp/test', + 'RUN touch /tmp/silence.tar.gz', + 'FROM alpine:latest', + 'WORKDIR /root/' + 'COPY --from=first /tmp/silence.tar.gz .', + 'ONBUILD RUN echo "This should not be in the final image"' + ]).encode('ascii')) + + stream = self.client.build( + fileobj=script, target='first', tag='build1' + ) + self.tmp_imgs.append('build1') + for chunk in stream: + pass + + info = self.client.inspect_image('build1') + self.assertEqual(info['Config']['OnBuild'], []) + + @requires_api_version('1.25') + def test_build_with_network_mode(self): + script = io.BytesIO('\n'.join([ + 'FROM busybox', + 'RUN wget http://google.com' + ]).encode('ascii')) + + stream = self.client.build( + fileobj=script, network_mode='bridge', + tag='dockerpytest_bridgebuild' + ) + + self.tmp_imgs.append('dockerpytest_bridgebuild') + for chunk in stream: + pass + + assert self.client.inspect_image('dockerpytest_bridgebuild') + + script.seek(0) + stream = self.client.build( + fileobj=script, network_mode='none', + tag='dockerpytest_nonebuild', nocache=True, decode=True + ) + + self.tmp_imgs.append('dockerpytest_nonebuild') + logs = [chunk for chunk in stream] + assert 'errorDetail' in logs[-1] + assert logs[-1]['errorDetail']['code'] == 1 + + with pytest.raises(errors.NotFound): + self.client.inspect_image('dockerpytest_nonebuild') + def test_build_stderr_data(self): control_chars = ['\x1b[91m', '\x1b[0m'] snippet = 'Ancient Temple (Mystic Oriental Dream ~ Ancient Temple)' diff --git a/tests/integration/api_container_test.py b/tests/integration/api_container_test.py index fb4c4e4adc..f8b474a113 100644 --- a/tests/integration/api_container_test.py +++ b/tests/integration/api_container_test.py @@ -1139,7 +1139,9 @@ def test_pause_unpause(self): class PruneTest(BaseAPIIntegrationTest): @requires_api_version('1.25') def test_prune_containers(self): - container1 = self.client.create_container(BUSYBOX, ['echo', 'hello']) + container1 = self.client.create_container( + BUSYBOX, ['sh', '-c', 'echo hello > /data.txt'] + ) container2 = self.client.create_container(BUSYBOX, ['sleep', '9999']) self.client.start(container1) self.client.start(container2) @@ -1255,6 +1257,15 @@ def test_container_cpuset(self): inspect_data = self.client.inspect_container(container) self.assertEqual(inspect_data['HostConfig']['CpusetCpus'], cpuset_cpus) + @requires_api_version('1.25') + def test_create_with_runtime(self): + container = self.client.create_container( + BUSYBOX, ['echo', 'test'], runtime='runc' + ) + self.tmp_containers.append(container['Id']) + config = self.client.inspect_container(container) + assert config['HostConfig']['Runtime'] == 'runc' + class LinkTest(BaseAPIIntegrationTest): def test_remove_link(self): diff --git a/tests/integration/api_healthcheck_test.py b/tests/integration/api_healthcheck_test.py index afe1dea21c..211042d486 100644 --- a/tests/integration/api_healthcheck_test.py +++ b/tests/integration/api_healthcheck_test.py @@ -28,8 +28,8 @@ def test_healthcheck_passes(self): container = self.client.create_container( BUSYBOX, 'top', healthcheck=dict( test="true", - interval=1*SECOND, - timeout=1*SECOND, + interval=1 * SECOND, + timeout=1 * SECOND, retries=1, )) self.tmp_containers.append(container) @@ -41,10 +41,27 @@ def test_healthcheck_fails(self): container = self.client.create_container( BUSYBOX, 'top', healthcheck=dict( test="false", - interval=1*SECOND, - timeout=1*SECOND, + interval=1 * SECOND, + timeout=1 * SECOND, retries=1, )) self.tmp_containers.append(container) self.client.start(container) wait_on_health_status(self.client, container, "unhealthy") + + @helpers.requires_api_version('1.29') + def test_healthcheck_start_period(self): + container = self.client.create_container( + BUSYBOX, 'top', healthcheck=dict( + test="echo 'x' >> /counter.txt && " + "test `cat /counter.txt | wc -l` -ge 3", + interval=1 * SECOND, + timeout=1 * SECOND, + retries=1, + start_period=3 * SECOND + ) + ) + + self.tmp_containers.append(container) + self.client.start(container) + wait_on_health_status(self.client, container, "healthy") diff --git a/tests/integration/api_image_test.py b/tests/integration/api_image_test.py index 11146a8a00..917bc50555 100644 --- a/tests/integration/api_image_test.py +++ b/tests/integration/api_image_test.py @@ -42,7 +42,7 @@ def test_pull(self): self.client.remove_image('hello-world') except docker.errors.APIError: pass - res = self.client.pull('hello-world') + res = self.client.pull('hello-world', tag='latest') self.tmp_imgs.append('hello-world') self.assertEqual(type(res), six.text_type) self.assertGreaterEqual( @@ -56,7 +56,8 @@ def test_pull_streaming(self): self.client.remove_image('hello-world') except docker.errors.APIError: pass - stream = self.client.pull('hello-world', stream=True, decode=True) + stream = self.client.pull( + 'hello-world', tag='latest', stream=True, decode=True) self.tmp_imgs.append('hello-world') for chunk in stream: assert isinstance(chunk, dict) @@ -300,7 +301,7 @@ def test_prune_images(self): ctnr = self.client.create_container(BUSYBOX, ['sleep', '9999']) self.tmp_containers.append(ctnr) - self.client.pull('hello-world') + self.client.pull('hello-world', tag='latest') self.tmp_imgs.append('hello-world') img_id = self.client.inspect_image('hello-world')['Id'] result = self.client.prune_images() diff --git a/tests/integration/api_network_test.py b/tests/integration/api_network_test.py index b3ae512080..5439dd7b2e 100644 --- a/tests/integration/api_network_test.py +++ b/tests/integration/api_network_test.py @@ -452,6 +452,14 @@ def test_create_network_attachable(self): net = self.client.inspect_network(net_id) assert net['Attachable'] is True + @requires_api_version('1.29') + def test_create_network_ingress(self): + assert self.client.init_swarm('eth0') + self.client.remove_network('ingress') + _, net_id = self.create_network(driver='overlay', ingress=True) + net = self.client.inspect_network(net_id) + assert net['Ingress'] is True + @requires_api_version('1.25') def test_prune_networks(self): net_name, _ = self.create_network() diff --git a/tests/integration/api_service_test.py b/tests/integration/api_service_test.py index 914e516bc4..54111a7bb1 100644 --- a/tests/integration/api_service_test.py +++ b/tests/integration/api_service_test.py @@ -270,6 +270,49 @@ def test_create_service_with_placement(self): assert (svc_info['Spec']['TaskTemplate']['Placement'] == {'Constraints': ['node.id=={}'.format(node_id)]}) + def test_create_service_with_placement_object(self): + node_id = self.client.nodes()[0]['ID'] + container_spec = docker.types.ContainerSpec(BUSYBOX, ['true']) + placemt = docker.types.Placement( + constraints=['node.id=={}'.format(node_id)] + ) + task_tmpl = docker.types.TaskTemplate( + container_spec, placement=placemt + ) + name = self.get_service_name() + svc_id = self.client.create_service(task_tmpl, name=name) + svc_info = self.client.inspect_service(svc_id) + assert 'Placement' in svc_info['Spec']['TaskTemplate'] + assert svc_info['Spec']['TaskTemplate']['Placement'] == placemt + + @requires_api_version('1.30') + def test_create_service_with_placement_platform(self): + container_spec = docker.types.ContainerSpec(BUSYBOX, ['true']) + placemt = docker.types.Placement(platforms=[('x86_64', 'linux')]) + task_tmpl = docker.types.TaskTemplate( + container_spec, placement=placemt + ) + name = self.get_service_name() + svc_id = self.client.create_service(task_tmpl, name=name) + svc_info = self.client.inspect_service(svc_id) + assert 'Placement' in svc_info['Spec']['TaskTemplate'] + assert svc_info['Spec']['TaskTemplate']['Placement'] == placemt + + @requires_api_version('1.27') + def test_create_service_with_placement_preferences(self): + container_spec = docker.types.ContainerSpec(BUSYBOX, ['true']) + placemt = docker.types.Placement(preferences=[ + {'Spread': {'SpreadDescriptor': 'com.dockerpy.test'}} + ]) + task_tmpl = docker.types.TaskTemplate( + container_spec, placement=placemt + ) + name = self.get_service_name() + svc_id = self.client.create_service(task_tmpl, name=name) + svc_info = self.client.inspect_service(svc_id) + assert 'Placement' in svc_info['Spec']['TaskTemplate'] + assert svc_info['Spec']['TaskTemplate']['Placement'] == placemt + def test_create_service_with_endpoint_spec(self): container_spec = docker.types.ContainerSpec(BUSYBOX, ['true']) task_tmpl = docker.types.TaskTemplate(container_spec) @@ -316,6 +359,23 @@ def test_create_service_with_env(self): assert 'Env' in con_spec assert con_spec['Env'] == ['DOCKER_PY_TEST=1'] + @requires_api_version('1.25') + def test_create_service_with_tty(self): + container_spec = docker.types.ContainerSpec( + BUSYBOX, ['true'], tty=True + ) + 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 'TaskTemplate' in svc_info['Spec'] + assert 'ContainerSpec' in svc_info['Spec']['TaskTemplate'] + con_spec = svc_info['Spec']['TaskTemplate']['ContainerSpec'] + assert 'TTY' in con_spec + assert con_spec['TTY'] is True + def test_create_service_global_mode(self): container_spec = docker.types.ContainerSpec( BUSYBOX, ['echo', 'hello'] diff --git a/tests/integration/api_swarm_test.py b/tests/integration/api_swarm_test.py index 0a2f69f3c9..666c689f55 100644 --- a/tests/integration/api_swarm_test.py +++ b/tests/integration/api_swarm_test.py @@ -173,4 +173,4 @@ def test_remove_main_node(self): with pytest.raises(docker.errors.APIError) as e: self.client.remove_node(node_id, True) - assert e.value.response.status_code == 500 + assert e.value.response.status_code >= 400 diff --git a/tests/integration/models_images_test.py b/tests/integration/models_images_test.py index 6d61e4977c..8f812d9390 100644 --- a/tests/integration/models_images_test.py +++ b/tests/integration/models_images_test.py @@ -39,6 +39,17 @@ def test_build_with_multiple_success(self): self.tmp_imgs.append(image.id) assert client.containers.run(image) == b"hello world\n" + def test_build_with_success_build_output(self): + client = docker.from_env(version=TEST_API_VERSION) + image = client.images.build( + tag='dup-txt-tag', fileobj=io.BytesIO( + "FROM alpine\n" + "CMD echo Successfully built abcd1234".encode('ascii') + ) + ) + self.tmp_imgs.append(image.id) + assert client.containers.run(image) == b"Successfully built abcd1234\n" + def test_list(self): client = docker.from_env(version=TEST_API_VERSION) image = client.images.pull('alpine:latest') @@ -71,7 +82,8 @@ def test_tag_and_remove(self): client = docker.from_env(version=TEST_API_VERSION) image = client.images.pull('alpine:latest') - image.tag(repo, tag) + result = image.tag(repo, tag) + assert result is True self.tmp_imgs.append(identifier) assert image.id in get_ids(client.images.list(repo)) assert image.id in get_ids(client.images.list(identifier)) diff --git a/tests/unit/api_container_test.py b/tests/unit/api_container_test.py index 662d3f5908..3b135a8135 100644 --- a/tests/unit/api_container_test.py +++ b/tests/unit/api_container_test.py @@ -1421,6 +1421,13 @@ def test_log_since_with_datetime(self): stream=False ) + def test_log_since_with_invalid_value_raises_error(self): + with mock.patch('docker.api.client.APIClient.inspect_container', + fake_inspect_container): + with self.assertRaises(docker.errors.InvalidArgument): + self.client.logs(fake_api.FAKE_CONTAINER_ID, stream=False, + follow=False, since=42.42) + def test_log_tty(self): m = mock.Mock() with mock.patch('docker.api.client.APIClient.inspect_container', diff --git a/tests/unit/auth_test.py b/tests/unit/auth_test.py index f9f6fc1462..56fd50c250 100644 --- a/tests/unit/auth_test.py +++ b/tests/unit/auth_test.py @@ -272,6 +272,57 @@ def test_resolve_registry_and_auth_unauthenticated_registry(self): ) +class CredStoreTest(unittest.TestCase): + def test_get_credential_store(self): + auth_config = { + 'credHelpers': { + 'registry1.io': 'truesecret', + 'registry2.io': 'powerlock' + }, + 'credsStore': 'blackbox', + } + + assert auth.get_credential_store( + auth_config, 'registry1.io' + ) == 'truesecret' + assert auth.get_credential_store( + auth_config, 'registry2.io' + ) == 'powerlock' + assert auth.get_credential_store( + auth_config, 'registry3.io' + ) == 'blackbox' + + def test_get_credential_store_no_default(self): + auth_config = { + 'credHelpers': { + 'registry1.io': 'truesecret', + 'registry2.io': 'powerlock' + }, + } + assert auth.get_credential_store( + auth_config, 'registry2.io' + ) == 'powerlock' + assert auth.get_credential_store( + auth_config, 'registry3.io' + ) is None + + def test_get_credential_store_default_index(self): + auth_config = { + 'credHelpers': { + 'https://index.docker.io/v1/': 'powerlock' + }, + 'credsStore': 'truesecret' + } + + assert auth.get_credential_store(auth_config, None) == 'powerlock' + assert auth.get_credential_store( + auth_config, 'docker.io' + ) == 'powerlock' + assert auth.get_credential_store( + auth_config, 'images.io' + ) == 'truesecret' + + class FindConfigFileTest(unittest.TestCase): def tmpdir(self, name): tmpdir = ensuretemp(name) diff --git a/tests/unit/client_test.py b/tests/unit/client_test.py index b79c68e155..c4996f1330 100644 --- a/tests/unit/client_test.py +++ b/tests/unit/client_test.py @@ -1,6 +1,9 @@ import datetime import docker from docker.utils import kwargs_from_env +from docker.constants import ( + DEFAULT_DOCKER_API_VERSION, DEFAULT_TIMEOUT_SECONDS +) import os import unittest @@ -96,3 +99,13 @@ def test_from_env_with_version(self): client = docker.from_env(version='2.32') self.assertEqual(client.api.base_url, "https://192.168.59.103:2376") self.assertEqual(client.api._version, '2.32') + + def test_from_env_without_version_uses_default(self): + client = docker.from_env() + + self.assertEqual(client.api._version, DEFAULT_DOCKER_API_VERSION) + + def test_from_env_without_timeout_uses_default(self): + client = docker.from_env() + + self.assertEqual(client.api.timeout, DEFAULT_TIMEOUT_SECONDS) diff --git a/tests/unit/utils_test.py b/tests/unit/utils_test.py index 25ed0f9b7f..c25881d142 100644 --- a/tests/unit/utils_test.py +++ b/tests/unit/utils_test.py @@ -552,6 +552,12 @@ def test_split_port_range_with_protocol(self): self.assertEqual(external_port, [("127.0.0.1", "1000"), ("127.0.0.1", "1001")]) + def test_split_port_with_ipv6_address(self): + internal_port, external_port = split_port( + "2001:abcd:ef00::2:1000:2000") + self.assertEqual(internal_port, ["2000"]) + self.assertEqual(external_port, [("2001:abcd:ef00::2", "1000")]) + def test_split_port_invalid(self): self.assertRaises(ValueError, lambda: split_port("0.0.0.0:1000:2000:tcp"))