From ba7580d6b94324b7fe97e90e3f40693a12f5ce2c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 17 Aug 2017 13:52:13 -0700 Subject: [PATCH 01/34] Bump 2.6.0-dev Signed-off-by: Joffrey F --- docker/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/version.py b/docker/version.py index 066b62e7..3b899fb5 100644 --- a/docker/version.py +++ b/docker/version.py @@ -1,2 +1,2 @@ -version = "2.5.0" +version = "2.6.0-dev" version_info = tuple([int(d) for d in version.split("-")[0].split(".")]) From fc6773d6732a433bda71dde24712584b7885deb8 Mon Sep 17 00:00:00 2001 From: Veli-Matti Lintu Date: Fri, 18 Aug 2017 11:48:09 +0300 Subject: [PATCH 02/34] Commit d798afca made changes for the handling of '**' patterns in .dockerignore. This causes an IndexError with patterns ending with '**', e.g. 'subdir/**'. This adds a missing boundary check before checking for trailing '/'. Signed-off-by: Veli-Matti Lintu --- docker/utils/fnmatch.py | 2 +- tests/unit/utils_test.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/docker/utils/fnmatch.py b/docker/utils/fnmatch.py index e51bd815..42461dd7 100644 --- a/docker/utils/fnmatch.py +++ b/docker/utils/fnmatch.py @@ -75,7 +75,7 @@ def translate(pat): # is some flavor of "**" i = i + 1 # Treat **/ as ** so eat the "/" - if pat[i] == '/': + if i < n and pat[i] == '/': i = i + 1 if i >= n: # is "**EOF" - to align with .gitignore just accept all diff --git a/tests/unit/utils_test.py b/tests/unit/utils_test.py index 4a391fac..2fa1d051 100644 --- a/tests/unit/utils_test.py +++ b/tests/unit/utils_test.py @@ -874,6 +874,23 @@ class ExcludePathsTest(unittest.TestCase): ) ) + def test_trailing_double_wildcard(self): + assert self.exclude(['subdir/**']) == convert_paths( + self.all_paths - set( + ['subdir/file.txt', + 'subdir/target/file.txt', + 'subdir/target/subdir/file.txt', + 'subdir/subdir2/file.txt', + 'subdir/subdir2/target/file.txt', + 'subdir/subdir2/target/subdir/file.txt', + 'subdir/target', + 'subdir/target/subdir', + 'subdir/subdir2', + 'subdir/subdir2/target', + 'subdir/subdir2/target/subdir'] + ) + ) + class TarTest(unittest.TestCase): def test_tar_with_excludes(self): From 0c2b4e4d3aa15efbf2cfac68571e7506ea86b8c9 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 21 Aug 2017 14:41:10 -0700 Subject: [PATCH 03/34] Always send attach request as streaming Signed-off-by: Joffrey F --- docker/api/container.py | 2 +- tests/integration/api_container_test.py | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/docker/api/container.py b/docker/api/container.py index dde13254..918f8a3a 100644 --- a/docker/api/container.py +++ b/docker/api/container.py @@ -50,7 +50,7 @@ class ContainerApiMixin(object): } u = self._url("/containers/{0}/attach", container) - response = self._post(u, headers=headers, params=params, stream=stream) + response = self._post(u, headers=headers, params=params, stream=True) return self._read_from_socket( response, stream, self._check_is_tty(container) diff --git a/tests/integration/api_container_test.py b/tests/integration/api_container_test.py index f8b474a1..a972c1cd 100644 --- a/tests/integration/api_container_test.py +++ b/tests/integration/api_container_test.py @@ -1092,20 +1092,28 @@ class AttachContainerTest(BaseAPIIntegrationTest): command = "printf '{0}'".format(line) container = self.client.create_container(BUSYBOX, command, detach=True, tty=False) - ident = container['Id'] - self.tmp_containers.append(ident) + self.tmp_containers.append(container) opts = {"stdout": 1, "stream": 1, "logs": 1} - pty_stdout = self.client.attach_socket(ident, opts) + pty_stdout = self.client.attach_socket(container, opts) self.addCleanup(pty_stdout.close) - self.client.start(ident) + self.client.start(container) next_size = next_frame_size(pty_stdout) self.assertEqual(next_size, len(line)) data = read_exactly(pty_stdout, next_size) self.assertEqual(data.decode('utf-8'), line) + def test_attach_no_stream(self): + container = self.client.create_container( + BUSYBOX, 'echo hello' + ) + self.tmp_containers.append(container) + self.client.start(container) + output = self.client.attach(container, stream=False, logs=True) + assert output == 'hello\n'.encode(encoding='ascii') + class PauseTest(BaseAPIIntegrationTest): def test_pause_unpause(self): From e9fe07768145d848fd67c8d84b7766bd39ec5e38 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 22 Aug 2017 16:38:15 -0700 Subject: [PATCH 04/34] Bump 2.5.1 Signed-off-by: Joffrey F --- docker/version.py | 2 +- docs/change-log.md | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/docker/version.py b/docker/version.py index 3b899fb5..273270d5 100644 --- a/docker/version.py +++ b/docker/version.py @@ -1,2 +1,2 @@ -version = "2.6.0-dev" +version = "2.5.1" 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 199e5ce8..9fe15e19 100644 --- a/docs/change-log.md +++ b/docs/change-log.md @@ -1,6 +1,18 @@ Change log ========== +2.5.1 +----- + +[List of PRs / issues for this release](https://github.com/docker/docker-py/milestone/37?closed=1) + +### Bugfixes + +* Fixed a bug where patterns ending with `**` in `.dockerignore` would + raise an exception +* Fixed a bug where using `attach` with the `stream` argument set to `False` + would raise an exception + 2.5.0 ----- From f470955a7750b9b471ebecd921cf2c4269956aae Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 22 Jun 2017 16:42:15 -0700 Subject: [PATCH 05/34] Shift test matrix forward Signed-off-by: Joffrey F --- Jenkinsfile | 1 - 1 file changed, 1 deletion(-) diff --git a/Jenkinsfile b/Jenkinsfile index a83d7bf1..178653a8 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -5,7 +5,6 @@ def imageNamePy2 def imageNamePy3 def images = [:] - def dockerVersions = ["1.13.1", "17.04.0-ce", "17.05.0-ce", "17.06.0-ce", "17.07.0-ce-rc3"] def buildImage = { name, buildargs, pyTag -> From 303b303855a4862abd35db5a6afd914ec1a3ec89 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 31 Aug 2017 12:22:59 -0700 Subject: [PATCH 06/34] Use unambiguous advertise-addr when initializing a swarm Signed-off-by: Joffrey F --- Makefile | 4 ++-- tests/integration/api_network_test.py | 4 ++-- tests/integration/base.py | 2 +- tests/integration/models_nodes_test.py | 2 +- tests/integration/models_services_test.py | 2 +- tests/integration/models_swarm_test.py | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index c6c6d56c..991b93a1 100644 --- a/Makefile +++ b/Makefile @@ -47,7 +47,7 @@ TEST_ENGINE_VERSION ?= 17.06.0-ce .PHONY: integration-dind integration-dind: build build-py3 docker rm -vf dpy-dind || : - docker run -d --name dpy-dind --privileged dockerswarm/dind:${TEST_ENGINE_VERSION} docker daemon\ + docker run -d --name dpy-dind --privileged dockerswarm/dind:${TEST_ENGINE_VERSION} dockerd\ -H tcp://0.0.0.0:2375 --experimental docker run --rm --env="DOCKER_HOST=tcp://docker:2375" --env="DOCKER_TEST_API_VERSION=${TEST_API_VERSION}"\ --link=dpy-dind:docker docker-sdk-python py.test tests/integration @@ -60,7 +60,7 @@ integration-dind-ssl: build-dind-certs build build-py3 docker run -d --name dpy-dind-certs dpy-dind-certs docker run -d --env="DOCKER_HOST=tcp://localhost:2375" --env="DOCKER_TLS_VERIFY=1"\ --env="DOCKER_CERT_PATH=/certs" --volumes-from dpy-dind-certs --name dpy-dind-ssl\ - -v /tmp --privileged dockerswarm/dind:${TEST_ENGINE_VERSION} docker daemon --tlsverify\ + -v /tmp --privileged dockerswarm/dind:${TEST_ENGINE_VERSION} dockerd --tlsverify\ --tlscacert=/certs/ca.pem --tlscert=/certs/server-cert.pem\ --tlskey=/certs/server-key.pem -H tcp://0.0.0.0:2375 --experimental docker run --rm --volumes-from dpy-dind-ssl --env="DOCKER_HOST=tcp://docker:2375"\ diff --git a/tests/integration/api_network_test.py b/tests/integration/api_network_test.py index 5439dd7b..1cc632fa 100644 --- a/tests/integration/api_network_test.py +++ b/tests/integration/api_network_test.py @@ -447,14 +447,14 @@ class TestNetworks(BaseAPIIntegrationTest): @requires_api_version('1.25') def test_create_network_attachable(self): - assert self.client.init_swarm('eth0') + assert self.init_swarm() _, net_id = self.create_network(driver='overlay', attachable=True) 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') + assert self.init_swarm() self.client.remove_network('ingress') _, net_id = self.create_network(driver='overlay', ingress=True) net = self.client.inspect_network(net_id) diff --git a/tests/integration/base.py b/tests/integration/base.py index 3c01689a..0c0cd065 100644 --- a/tests/integration/base.py +++ b/tests/integration/base.py @@ -110,5 +110,5 @@ class BaseAPIIntegrationTest(BaseIntegrationTest): def init_swarm(self, **kwargs): return self.client.init_swarm( - 'eth0', listen_addr=helpers.swarm_listen_addr(), **kwargs + '127.0.0.1', listen_addr=helpers.swarm_listen_addr(), **kwargs ) diff --git a/tests/integration/models_nodes_test.py b/tests/integration/models_nodes_test.py index 5823e6b1..3c8d48ad 100644 --- a/tests/integration/models_nodes_test.py +++ b/tests/integration/models_nodes_test.py @@ -15,7 +15,7 @@ class NodesTest(unittest.TestCase): def test_list_get_update(self): client = docker.from_env(version=TEST_API_VERSION) - client.swarm.init('eth0', listen_addr=helpers.swarm_listen_addr()) + client.swarm.init('127.0.0.1', listen_addr=helpers.swarm_listen_addr()) nodes = client.nodes.list() assert len(nodes) == 1 assert nodes[0].attrs['Spec']['Role'] == 'manager' diff --git a/tests/integration/models_services_test.py b/tests/integration/models_services_test.py index 9b5676d6..6b5dab53 100644 --- a/tests/integration/models_services_test.py +++ b/tests/integration/models_services_test.py @@ -12,7 +12,7 @@ class ServiceTest(unittest.TestCase): def setUpClass(cls): client = docker.from_env(version=TEST_API_VERSION) helpers.force_leave_swarm(client) - client.swarm.init('eth0', listen_addr=helpers.swarm_listen_addr()) + client.swarm.init('127.0.0.1', listen_addr=helpers.swarm_listen_addr()) @classmethod def tearDownClass(cls): diff --git a/tests/integration/models_swarm_test.py b/tests/integration/models_swarm_test.py index e45ff3cb..ac180305 100644 --- a/tests/integration/models_swarm_test.py +++ b/tests/integration/models_swarm_test.py @@ -16,7 +16,7 @@ class SwarmTest(unittest.TestCase): def test_init_update_leave(self): client = docker.from_env(version=TEST_API_VERSION) client.swarm.init( - advertise_addr='eth0', snapshot_interval=5000, + advertise_addr='127.0.0.1', snapshot_interval=5000, listen_addr=helpers.swarm_listen_addr() ) assert client.swarm.attrs['Spec']['Raft']['SnapshotInterval'] == 5000 From a49d73e9df5c1bf1e37f65b8f66ae4a8bad1fc9b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 31 Aug 2017 16:09:52 -0700 Subject: [PATCH 07/34] Fix prune_images docstring Signed-off-by: Joffrey F --- docker/api/image.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/api/image.py b/docker/api/image.py index 41cc267e..44e60e20 100644 --- a/docker/api/image.py +++ b/docker/api/image.py @@ -305,8 +305,8 @@ class ImageApiMixin(object): Args: filters (dict): Filters to process on the prune list. Available filters: - - dangling (bool): When set to true (or 1), prune only - unused and untagged images. + - dangling (bool): When set to true (or 1), prune only + unused and untagged images. Returns: (dict): A dict containing a list of deleted image IDs and From 7107e265b1b972b05ec1dc34a9bb3006a90a0c3e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 31 Aug 2017 15:58:28 -0700 Subject: [PATCH 08/34] Do not interrupt streaming when encountering 0-length frames Signed-off-by: Joffrey F --- docker/utils/socket.py | 4 ++-- tests/unit/fake_api.py | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docker/utils/socket.py b/docker/utils/socket.py index 54392d2b..c3a5f90f 100644 --- a/docker/utils/socket.py +++ b/docker/utils/socket.py @@ -59,7 +59,7 @@ def next_frame_size(socket): try: data = read_exactly(socket, 8) except SocketError: - return 0 + return -1 _, actual = struct.unpack('>BxxxL', data) return actual @@ -71,7 +71,7 @@ def frames_iter(socket): """ while True: n = next_frame_size(socket) - if n == 0: + if n < 0: break while n > 0: result = read(socket, n) diff --git a/tests/unit/fake_api.py b/tests/unit/fake_api.py index 2ba85bbf..045c3425 100644 --- a/tests/unit/fake_api.py +++ b/tests/unit/fake_api.py @@ -205,7 +205,9 @@ def get_fake_wait(): def get_fake_logs(): status_code = 200 - response = (b'\x01\x00\x00\x00\x00\x00\x00\x11Flowering Nights\n' + response = (b'\x01\x00\x00\x00\x00\x00\x00\x00' + b'\x02\x00\x00\x00\x00\x00\x00\x00' + b'\x01\x00\x00\x00\x00\x00\x00\x11Flowering Nights\n' b'\x01\x00\x00\x00\x00\x00\x00\x10(Sakuya Iyazoi)\n') return status_code, response From ba66b09e2b48651a9ecd7d783797938336c134a5 Mon Sep 17 00:00:00 2001 From: brett55 Date: Wed, 13 Sep 2017 15:40:37 -0600 Subject: [PATCH 09/34] Fix docs, incorrect param name Signed-off-by: brett55 --- docker/models/images.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/models/images.py b/docker/models/images.py index d1b29ad8..2aae46d8 100644 --- a/docker/models/images.py +++ b/docker/models/images.py @@ -254,7 +254,7 @@ class ImageCollection(Collection): low-level API. Args: - repository (str): The repository to pull + name (str): The repository to pull tag (str): The tag to pull insecure_registry (bool): Use an insecure registry auth_config (dict): Override the credentials that From 1d77ef9e53dac9b325797fdb7984f42f09434375 Mon Sep 17 00:00:00 2001 From: Steve Clark Date: Mon, 18 Sep 2017 11:35:53 +0100 Subject: [PATCH 10/34] Adding swarm id_attribute to match docker output Swarm id is returned in a attribute with the key ID. The swarm model was using the default behaviour and looking for Id. Signed-off-by: Steve Clark --- docker/models/swarm.py | 2 ++ tests/integration/models_swarm_test.py | 1 + 2 files changed, 3 insertions(+) diff --git a/docker/models/swarm.py b/docker/models/swarm.py index d3d07ee7..df3afd36 100644 --- a/docker/models/swarm.py +++ b/docker/models/swarm.py @@ -9,6 +9,8 @@ class Swarm(Model): The server's Swarm state. This a singleton that must be reloaded to get the current state of the Swarm. """ + id_attribute = 'ID' + def __init__(self, *args, **kwargs): super(Swarm, self).__init__(*args, **kwargs) if self.client: diff --git a/tests/integration/models_swarm_test.py b/tests/integration/models_swarm_test.py index ac180305..dadd77d9 100644 --- a/tests/integration/models_swarm_test.py +++ b/tests/integration/models_swarm_test.py @@ -22,6 +22,7 @@ class SwarmTest(unittest.TestCase): assert client.swarm.attrs['Spec']['Raft']['SnapshotInterval'] == 5000 client.swarm.update(snapshot_interval=10000) assert client.swarm.attrs['Spec']['Raft']['SnapshotInterval'] == 10000 + assert client.swarm.id assert client.swarm.leave(force=True) with self.assertRaises(docker.errors.APIError) as cm: client.swarm.reload() From f94fae3aa88ec968575cfd3aaebee97d3c954356 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 2 Oct 2017 12:24:17 -0700 Subject: [PATCH 11/34] Remove superfluous version validation Signed-off-by: Joffrey F --- tests/integration/api_client_test.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/integration/api_client_test.py b/tests/integration/api_client_test.py index cc641582..cfb45a3e 100644 --- a/tests/integration/api_client_test.py +++ b/tests/integration/api_client_test.py @@ -16,7 +16,6 @@ class InformationTest(BaseAPIIntegrationTest): res = self.client.version() self.assertIn('GoVersion', res) self.assertIn('Version', res) - self.assertEqual(len(res['Version'].split('.')), 3) def test_info(self): res = self.client.info() From 87426093917470b1937ec7bb5eb54d36418033d1 Mon Sep 17 00:00:00 2001 From: Jan Losinski Date: Tue, 17 Oct 2017 02:46:35 +0200 Subject: [PATCH 12/34] Fix simple documentation copy/paste error. Signed-off-by: Jan Losinski --- docker/api/image.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/api/image.py b/docker/api/image.py index 44e60e20..77553122 100644 --- a/docker/api/image.py +++ b/docker/api/image.py @@ -245,10 +245,10 @@ class ImageApiMixin(object): def inspect_image(self, image): """ Get detailed information about an image. Similar to the ``docker - inspect`` command, but only for containers. + inspect`` command, but only for images. Args: - container (str): The container to inspect + image (str): The image to inspect Returns: (dict): Similar to the output of ``docker inspect``, but as a From eee9cbbf0810fed71f68bac3fd4d93cfe16208a5 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 24 Oct 2017 16:16:39 -0700 Subject: [PATCH 13/34] Add support for new types and options to docker.types.Mount Signed-off-by: Joffrey F --- docker/types/services.py | 55 ++++++++++++++++++++++++++++++---------- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/docker/types/services.py b/docker/types/services.py index 8411b70a..c2767404 100644 --- a/docker/types/services.py +++ b/docker/types/services.py @@ -2,7 +2,9 @@ import six from .. import errors from ..constants import IS_WINDOWS_PLATFORM -from ..utils import check_resource, format_environment, split_command +from ..utils import ( + check_resource, format_environment, parse_bytes, split_command +) class TaskTemplate(dict): @@ -140,9 +142,11 @@ class Mount(dict): target (string): Container path. source (string): Mount source (e.g. a volume name or a host path). - type (string): The mount type (``bind`` or ``volume``). - Default: ``volume``. + type (string): The mount type (``bind`` / ``volume`` / ``tmpfs`` / + ``npipe``). Default: ``volume``. read_only (bool): Whether the mount should be read-only. + consistency (string): The consistency requirement for the mount. One of + ``default```, ``consistent``, ``cached``, ``delegated``. propagation (string): A propagation mode with the value ``[r]private``, ``[r]shared``, or ``[r]slave``. Only valid for the ``bind`` type. no_copy (bool): False if the volume should be populated with the data @@ -152,30 +156,36 @@ class Mount(dict): for the ``volume`` type. driver_config (DriverConfig): Volume driver configuration. Only valid for the ``volume`` type. + tmpfs_size (int or string): The size for the tmpfs mount in bytes. + tmpfs_mode (int): The permission mode for the tmpfs mount. """ def __init__(self, target, source, type='volume', read_only=False, - propagation=None, no_copy=False, labels=None, - driver_config=None): + consistency=None, propagation=None, no_copy=False, + labels=None, driver_config=None, tmpfs_size=None, + tmpfs_mode=None): self['Target'] = target self['Source'] = source - if type not in ('bind', 'volume'): + if type not in ('bind', 'volume', 'tmpfs', 'npipe'): raise errors.InvalidArgument( - 'Only acceptable mount types are `bind` and `volume`.' + 'Unsupported mount type: "{}"'.format(type) ) self['Type'] = type self['ReadOnly'] = read_only + if consistency: + self['Consistency'] = consistency + if type == 'bind': if propagation is not None: self['BindOptions'] = { 'Propagation': propagation } - if any([labels, driver_config, no_copy]): + if any([labels, driver_config, no_copy, tmpfs_size, tmpfs_mode]): raise errors.InvalidArgument( - 'Mount type is binding but volume options have been ' - 'provided.' + 'Incompatible options have been provided for the bind ' + 'type mount.' ) - else: + elif type == 'volume': volume_opts = {} if no_copy: volume_opts['NoCopy'] = True @@ -185,10 +195,27 @@ class Mount(dict): volume_opts['DriverConfig'] = driver_config if volume_opts: self['VolumeOptions'] = volume_opts - if propagation: + if any([propagation, tmpfs_size, tmpfs_mode]): raise errors.InvalidArgument( - 'Mount type is volume but `propagation` argument has been ' - 'provided.' + 'Incompatible options have been provided for the volume ' + 'type mount.' + ) + elif type == 'tmpfs': + tmpfs_opts = {} + if tmpfs_mode: + if not isinstance(tmpfs_mode, six.integer_types): + raise errors.InvalidArgument( + 'tmpfs_mode must be an integer' + ) + tmpfs_opts['Mode'] = tmpfs_mode + if tmpfs_size: + tmpfs_opts['SizeBytes'] = parse_bytes(tmpfs_size) + if tmpfs_opts: + self['TmpfsOptions'] = tmpfs_opts + if any([propagation, labels, driver_config, no_copy]): + raise errors.InvalidArgument( + 'Incompatible options have been provided for the tmpfs ' + 'type mount.' ) @classmethod From 5d1b6522462c6672482555b479630f3a4c6b7dcf Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 24 Oct 2017 16:17:25 -0700 Subject: [PATCH 14/34] Add support for mounts in HostConfig Signed-off-by: Joffrey F --- docker/api/container.py | 4 ++ docker/models/containers.py | 5 ++ docker/types/containers.py | 7 ++- tests/integration/api_container_test.py | 65 +++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 1 deletion(-) diff --git a/docker/api/container.py b/docker/api/container.py index 918f8a3a..f3c33c97 100644 --- a/docker/api/container.py +++ b/docker/api/container.py @@ -529,6 +529,10 @@ class ContainerApiMixin(object): behavior. Accepts number between 0 and 100. memswap_limit (str or int): Maximum amount of memory + swap a container is allowed to consume. + mounts (:py:class:`list`): Specification for mounts to be added to + the container. More powerful alternative to ``binds``. Each + item in the list is expected to be a + :py:class:`docker.types.Mount` object. network_mode (str): One of: - ``bridge`` Create a new network stack for the container on diff --git a/docker/models/containers.py b/docker/models/containers.py index 688decca..ea8c10b5 100644 --- a/docker/models/containers.py +++ b/docker/models/containers.py @@ -549,6 +549,10 @@ class ContainerCollection(Collection): behavior. Accepts number between 0 and 100. memswap_limit (str or int): Maximum amount of memory + swap a container is allowed to consume. + mounts (:py:class:`list`): Specification for mounts to be added to + the container. More powerful alternative to ``volumes``. Each + item in the list is expected to be a + :py:class:`docker.types.Mount` object. name (str): The name for this container. nano_cpus (int): CPU quota in units of 10-9 CPUs. network (str): Name of the network this container will be connected @@ -888,6 +892,7 @@ RUN_HOST_CONFIG_KWARGS = [ 'mem_reservation', 'mem_swappiness', 'memswap_limit', + 'mounts', 'nano_cpus', 'network_mode', 'oom_kill_disable', diff --git a/docker/types/containers.py b/docker/types/containers.py index 030e292b..3fc13d9d 100644 --- a/docker/types/containers.py +++ b/docker/types/containers.py @@ -120,7 +120,7 @@ class HostConfig(dict): 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): + cpuset_mems=None, runtime=None, mounts=None): if mem_limit is not None: self['Memory'] = parse_bytes(mem_limit) @@ -478,6 +478,11 @@ class HostConfig(dict): raise host_config_version_error('runtime', '1.25') self['Runtime'] = runtime + if mounts is not None: + if version_lt(version, '1.30'): + raise host_config_version_error('mounts', '1.30') + self['Mounts'] = mounts + def host_config_type_error(param, param_value, expected): error_msg = 'Invalid type for {0} param: expected {1} but found {2}' diff --git a/tests/integration/api_container_test.py b/tests/integration/api_container_test.py index a972c1cd..f03ccdb4 100644 --- a/tests/integration/api_container_test.py +++ b/tests/integration/api_container_test.py @@ -522,6 +522,71 @@ class VolumeBindTest(BaseAPIIntegrationTest): inspect_data = self.client.inspect_container(container) self.check_container_data(inspect_data, False) + @pytest.mark.xfail( + IS_WINDOWS_PLATFORM, reason='Test not designed for Windows platform' + ) + @requires_api_version('1.30') + def test_create_with_mounts(self): + mount = docker.types.Mount( + type="bind", source=self.mount_origin, target=self.mount_dest + ) + host_config = self.client.create_host_config(mounts=[mount]) + container = self.run_container( + BUSYBOX, ['ls', self.mount_dest], + host_config=host_config + ) + assert container + logs = self.client.logs(container) + if six.PY3: + logs = logs.decode('utf-8') + assert self.filename in logs + inspect_data = self.client.inspect_container(container) + self.check_container_data(inspect_data, True) + + @pytest.mark.xfail( + IS_WINDOWS_PLATFORM, reason='Test not designed for Windows platform' + ) + @requires_api_version('1.30') + def test_create_with_mounts_ro(self): + mount = docker.types.Mount( + type="bind", source=self.mount_origin, target=self.mount_dest, + read_only=True + ) + host_config = self.client.create_host_config(mounts=[mount]) + container = self.run_container( + BUSYBOX, ['ls', self.mount_dest], + host_config=host_config + ) + assert container + logs = self.client.logs(container) + if six.PY3: + logs = logs.decode('utf-8') + assert self.filename in logs + inspect_data = self.client.inspect_container(container) + self.check_container_data(inspect_data, False) + + @requires_api_version('1.30') + def test_create_with_volume_mount(self): + mount = docker.types.Mount( + type="volume", source=helpers.random_name(), + target=self.mount_dest, labels={'com.dockerpy.test': 'true'} + ) + host_config = self.client.create_host_config(mounts=[mount]) + container = self.client.create_container( + BUSYBOX, ['true'], host_config=host_config, + ) + assert container + inspect_data = self.client.inspect_container(container) + assert 'Mounts' in inspect_data + filtered = list(filter( + lambda x: x['Destination'] == self.mount_dest, + inspect_data['Mounts'] + )) + assert len(filtered) == 1 + mount_data = filtered[0] + assert mount['Source'] == mount_data['Name'] + assert mount_data['RW'] is True + def check_container_data(self, inspect_data, rw): if docker.utils.compare_version('1.20', self.client._version) < 0: self.assertIn('Volumes', inspect_data) From cdf9acb185557c82fde6d4a55f3c9aea45d0cbd2 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 24 Oct 2017 16:26:16 -0700 Subject: [PATCH 15/34] Pin flake8 version Signed-off-by: Joffrey F --- test-requirements.txt | 2 +- tox.ini | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/test-requirements.txt b/test-requirements.txt index 460db107..f79e8159 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -2,4 +2,4 @@ mock==1.0.1 pytest==2.9.1 coverage==3.7.1 pytest-cov==2.1.0 -flake8==2.4.1 +flake8==3.4.1 diff --git a/tox.ini b/tox.ini index 5a5e5415..3bf2b716 100644 --- a/tox.ini +++ b/tox.ini @@ -12,4 +12,5 @@ deps = [testenv:flake8] commands = flake8 docker tests setup.py -deps = flake8 +deps = + -r{toxinidir}/test-requirements.txt From 53582a9cf53b66aa898e23a2ff987a0db8ccdae3 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 25 Oct 2017 14:30:18 -0700 Subject: [PATCH 16/34] Add support for extra_hosts option in build Signed-off-by: Joffrey F --- docker/api/build.py | 15 +++++++++++++- docker/models/images.py | 4 ++++ tests/integration/api_build_test.py | 32 +++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/docker/api/build.py b/docker/api/build.py index f9678a39..42a1a296 100644 --- a/docker/api/build.py +++ b/docker/api/build.py @@ -19,7 +19,7 @@ class BuildApiMixin(object): forcerm=False, dockerfile=None, container_limits=None, decode=False, buildargs=None, gzip=False, shmsize=None, labels=None, cache_from=None, target=None, network_mode=None, - squash=None): + squash=None, extra_hosts=None): """ Similar to the ``docker build`` command. Either ``path`` or ``fileobj`` needs to be set. ``path`` can be a local path (to a directory @@ -101,6 +101,8 @@ class BuildApiMixin(object): build squash (bool): Squash the resulting images layers into a single layer. + extra_hosts (dict): Extra hosts to add to /etc/hosts in building + containers, as a mapping of hostname to IP address. Returns: A generator for the build output. @@ -229,6 +231,17 @@ class BuildApiMixin(object): 'squash was only introduced in API version 1.25' ) + if extra_hosts is not None: + if utils.version_lt(self._version, '1.27'): + raise errors.InvalidVersion( + 'extra_hosts was only introduced in API version 1.27' + ) + + encoded_extra_hosts = [ + '{}:{}'.format(k, v) for k, v in extra_hosts.items() + ] + params.update({'extrahosts': encoded_extra_hosts}) + if context is not None: headers = {'Content-Type': 'application/tar'} if encoding: diff --git a/docker/models/images.py b/docker/models/images.py index 2aae46d8..82ca5413 100644 --- a/docker/models/images.py +++ b/docker/models/images.py @@ -153,6 +153,10 @@ class ImageCollection(Collection): Dockerfile network_mode (str): networking mode for the run commands during build + squash (bool): Squash the resulting images layers into a + single layer. + extra_hosts (dict): Extra hosts to add to /etc/hosts in building + containers, as a mapping of hostname to IP address. Returns: (:py:class:`Image`): The built image. diff --git a/tests/integration/api_build_test.py b/tests/integration/api_build_test.py index d0aa5c21..21464ff6 100644 --- a/tests/integration/api_build_test.py +++ b/tests/integration/api_build_test.py @@ -244,6 +244,38 @@ class BuildTest(BaseAPIIntegrationTest): with pytest.raises(errors.NotFound): self.client.inspect_image('dockerpytest_nonebuild') + @requires_api_version('1.27') + def test_build_with_extra_hosts(self): + img_name = 'dockerpytest_extrahost_build' + self.tmp_imgs.append(img_name) + + script = io.BytesIO('\n'.join([ + 'FROM busybox', + 'RUN ping -c1 hello.world.test', + 'RUN ping -c1 extrahost.local.test', + 'RUN cp /etc/hosts /hosts-file' + ]).encode('ascii')) + + stream = self.client.build( + fileobj=script, tag=img_name, + extra_hosts={ + 'extrahost.local.test': '127.0.0.1', + 'hello.world.test': '8.8.8.8', + }, decode=True + ) + for chunk in stream: + if 'errorDetail' in chunk: + pytest.fail(chunk) + + assert self.client.inspect_image(img_name) + ctnr = self.run_container(img_name, 'cat /hosts-file') + self.tmp_containers.append(ctnr) + logs = self.client.logs(ctnr) + if six.PY3: + logs = logs.decode('utf-8') + assert '127.0.0.1\textrahost.local.test' in logs + assert '8.8.8.8\thello.world.test' in logs + @requires_experimental(until=None) @requires_api_version('1.25') def test_build_squash(self): From bb437e921ee926350df132b7d5cf55bcbda0cbea Mon Sep 17 00:00:00 2001 From: Erik Johnson Date: Thu, 26 Oct 2017 10:29:21 -0500 Subject: [PATCH 17/34] Fix indentation in docstring The incorrect indentation causes improper formatting when the docs are published. Signed-off-by: Erik Johnson --- docker/api/network.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/api/network.py b/docker/api/network.py index befbb583..071a12a6 100644 --- a/docker/api/network.py +++ b/docker/api/network.py @@ -223,7 +223,7 @@ class NetworkApiMixin(object): ipv6_address (str): The IP address of this container on the network, using the IPv6 protocol. Defaults to ``None``. link_local_ips (:py:class:`list`): A list of link-local - (IPv4/IPv6) addresses. + (IPv4/IPv6) addresses. """ data = { "Container": container, From cd47a1f9f5e7273fde56e1058927991b2e609cae Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 25 Oct 2017 19:03:12 -0700 Subject: [PATCH 18/34] Add support for new ContainerSpec parameters Signed-off-by: Joffrey F --- docker/api/build.py | 7 +- docker/api/service.py | 65 +++++++++------ docker/models/services.py | 37 +++++++-- docker/types/__init__.py | 5 +- docker/types/containers.py | 9 +-- docker/types/healthcheck.py | 24 ++++++ docker/types/services.py | 152 ++++++++++++++++++++++++++++++++++-- docker/utils/__init__.py | 2 +- docker/utils/utils.py | 6 ++ docs/api.rst | 8 +- 10 files changed, 265 insertions(+), 50 deletions(-) diff --git a/docker/api/build.py b/docker/api/build.py index 42a1a296..25f271a4 100644 --- a/docker/api/build.py +++ b/docker/api/build.py @@ -237,10 +237,9 @@ class BuildApiMixin(object): 'extra_hosts was only introduced in API version 1.27' ) - encoded_extra_hosts = [ - '{}:{}'.format(k, v) for k, v in extra_hosts.items() - ] - params.update({'extrahosts': encoded_extra_hosts}) + if isinstance(extra_hosts, dict): + extra_hosts = utils.format_extra_hosts(extra_hosts) + params.update({'extrahosts': extra_hosts}) if context is not None: headers = {'Content-Type': 'application/tar'} diff --git a/docker/api/service.py b/docker/api/service.py index 4b555a5f..9ce830ca 100644 --- a/docker/api/service.py +++ b/docker/api/service.py @@ -4,45 +4,62 @@ from ..types import ServiceMode def _check_api_features(version, task_template, update_config): + + def raise_version_error(param, min_version): + raise errors.InvalidVersion( + '{} is not supported in API version < {}'.format( + param, min_version + ) + ) + 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' - ) + raise_version_error('UpdateConfig.max_failure_ratio', '1.25') if 'Monitor' in update_config: - raise errors.InvalidVersion( - 'UpdateConfig.monitor is not supported in' - ' API version < 1.25' - ) + raise_version_error('UpdateConfig.monitor', '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' - ) + raise_version_error('force_update', '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' - ) - + raise_version_error('Placement.platforms', '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.get('ContainerSpec', {}).get('TTY'): + raise_version_error('Placement.preferences', '1.27') + + if task_template.get('ContainerSpec'): + container_spec = task_template.get('ContainerSpec') + if utils.version_lt(version, '1.25'): - raise errors.InvalidVersion( - 'ContainerSpec.TTY is not supported in API version < 1.25' - ) + if container_spec.get('TTY'): + raise_version_error('ContainerSpec.tty', '1.25') + if container_spec.get('Hostname') is not None: + raise_version_error('ContainerSpec.hostname', '1.25') + if container_spec.get('Hosts') is not None: + raise_version_error('ContainerSpec.hosts', '1.25') + if container_spec.get('Groups') is not None: + raise_version_error('ContainerSpec.groups', '1.25') + if container_spec.get('DNSConfig') is not None: + raise_version_error('ContainerSpec.dns_config', '1.25') + if container_spec.get('Healthcheck') is not None: + raise_version_error('ContainerSpec.healthcheck', '1.25') + + if utils.version_lt(version, '1.28'): + if container_spec.get('ReadOnly') is not None: + raise_version_error('ContainerSpec.dns_config', '1.28') + if container_spec.get('StopSignal') is not None: + raise_version_error('ContainerSpec.stop_signal', '1.28') + + if utils.version_lt(version, '1.30'): + if container_spec.get('Configs') is not None: + raise_version_error('ContainerSpec.configs', '1.30') + if container_spec.get('Privileges') is not None: + raise_version_error('ContainerSpec.privileges', '1.30') class ServiceApiMixin(object): diff --git a/docker/models/services.py b/docker/models/services.py index e1e2ea6a..d45621bb 100644 --- a/docker/models/services.py +++ b/docker/models/services.py @@ -147,6 +147,22 @@ class ServiceCollection(Collection): user (str): User to run commands as. workdir (str): Working directory for commands to run. tty (boolean): Whether a pseudo-TTY should be allocated. + groups (:py:class:`list`): A list of additional groups that the + container process will run as. + open_stdin (boolean): Open ``stdin`` + read_only (boolean): Mount the container's root filesystem as read + only. + stop_signal (string): Set signal to stop the service's containers + healthcheck (Healthcheck): Healthcheck + configuration for this service. + hosts (:py:class:`dict`): A set of host to IP mappings to add to + the container's `hosts` file. + dns_config (DNSConfig): Specification for DNS + related configurations in resolver configuration file. + configs (:py:class:`list`): List of :py:class:`ConfigReference` + that will be exposed to the service. + privileges (Privileges): Security options for the service's + containers. Returns: (:py:class:`Service`) The created service. @@ -202,18 +218,27 @@ class ServiceCollection(Collection): # kwargs to copy straight over to ContainerSpec CONTAINER_SPEC_KWARGS = [ - 'image', - 'command', 'args', + 'command', + 'configs', + 'dns_config', 'env', + 'groups', + 'healthcheck', 'hostname', - 'workdir', - 'user', + 'hosts', + 'image', 'labels', 'mounts', - 'stop_grace_period', + 'open_stdin', + 'privileges' + 'read_only', 'secrets', - 'tty' + 'stop_grace_period', + 'stop_signal', + 'tty', + 'user', + 'workdir', ] # kwargs to copy straight over to TaskTemplate diff --git a/docker/types/__init__.py b/docker/types/__init__.py index edc919df..39c93e34 100644 --- a/docker/types/__init__.py +++ b/docker/types/__init__.py @@ -3,7 +3,8 @@ from .containers import ContainerConfig, HostConfig, LogConfig, Ulimit from .healthcheck import Healthcheck from .networks import EndpointConfig, IPAMConfig, IPAMPool, NetworkingConfig from .services import ( - ContainerSpec, DriverConfig, EndpointSpec, Mount, Placement, Resources, - RestartPolicy, SecretReference, ServiceMode, TaskTemplate, UpdateConfig + ConfigReference, ContainerSpec, DNSConfig, DriverConfig, EndpointSpec, + Mount, Placement, Privileges, Resources, RestartPolicy, SecretReference, + ServiceMode, TaskTemplate, UpdateConfig ) from .swarm import SwarmSpec, SwarmExternalCA diff --git a/docker/types/containers.py b/docker/types/containers.py index 3fc13d9d..13bea713 100644 --- a/docker/types/containers.py +++ b/docker/types/containers.py @@ -4,8 +4,8 @@ import warnings from .. import errors from ..utils.utils import ( convert_port_bindings, convert_tmpfs_mounts, convert_volume_binds, - format_environment, normalize_links, parse_bytes, parse_devices, - split_command, version_gte, version_lt, + format_environment, format_extra_hosts, normalize_links, parse_bytes, + parse_devices, split_command, version_gte, version_lt, ) from .base import DictType from .healthcheck import Healthcheck @@ -257,10 +257,7 @@ class HostConfig(dict): if extra_hosts is not None: if isinstance(extra_hosts, dict): - extra_hosts = [ - '{0}:{1}'.format(k, v) - for k, v in sorted(six.iteritems(extra_hosts)) - ] + extra_hosts = format_extra_hosts(extra_hosts) self['ExtraHosts'] = extra_hosts diff --git a/docker/types/healthcheck.py b/docker/types/healthcheck.py index 8ea9a35f..5a6a9315 100644 --- a/docker/types/healthcheck.py +++ b/docker/types/healthcheck.py @@ -4,6 +4,30 @@ import six class Healthcheck(DictType): + """ + Defines a healthcheck configuration for a container or service. + + Args: + + test (:py:class:`list` or str): Test to perform to determine + container health. Possible values: + - Empty list: Inherit healthcheck from parent image + - ``["NONE"]``: Disable healthcheck + - ``["CMD", args...]``: exec arguments directly. + - ``["CMD-SHELL", command]``: RUn command in the system's + default shell. + If a string is provided, it will be used as a ``CMD-SHELL`` + command. + interval (int): The time to wait between checks in nanoseconds. It + should be 0 or at least 1000000 (1 ms). + timeout (int): The time to wait before considering the check to + have hung. It should be 0 or at least 1000000 (1 ms). + retries (integer): The number of consecutive failures needed to + consider a container as unhealthy. + start_period (integer): Start period for the container to + initialize before starting health-retries countdown in + nanoseconds. It should be 0 or at least 1000000 (1 ms). + """ def __init__(self, **kwargs): test = kwargs.get('test', kwargs.get('Test')) if isinstance(test, six.string_types): diff --git a/docker/types/services.py b/docker/types/services.py index c2767404..c77db166 100644 --- a/docker/types/services.py +++ b/docker/types/services.py @@ -3,7 +3,8 @@ import six from .. import errors from ..constants import IS_WINDOWS_PLATFORM from ..utils import ( - check_resource, format_environment, parse_bytes, split_command + check_resource, format_environment, format_extra_hosts, parse_bytes, + split_command, ) @@ -84,13 +85,31 @@ class ContainerSpec(dict): :py:class:`~docker.types.Mount` class for details. stop_grace_period (int): Amount of time to wait for the container to terminate before forcefully killing it. - secrets (list of py:class:`SecretReference`): List of secrets to be + secrets (:py:class:`list`): List of :py:class:`SecretReference` to be made available inside the containers. tty (boolean): Whether a pseudo-TTY should be allocated. + groups (:py:class:`list`): A list of additional groups that the + container process will run as. + open_stdin (boolean): Open ``stdin`` + read_only (boolean): Mount the container's root filesystem as read + only. + stop_signal (string): Set signal to stop the service's containers + healthcheck (Healthcheck): Healthcheck + configuration for this service. + hosts (:py:class:`dict`): A set of host to IP mappings to add to + the container's `hosts` file. + dns_config (DNSConfig): Specification for DNS + related configurations in resolver configuration file. + configs (:py:class:`list`): List of :py:class:`ConfigReference` that + will be exposed to the service. + privileges (Privileges): Security options for the service's containers. """ 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, tty=None): + stop_grace_period=None, secrets=None, tty=None, groups=None, + open_stdin=None, read_only=None, stop_signal=None, + healthcheck=None, hosts=None, dns_config=None, configs=None, + privileges=None): self['Image'] = image if isinstance(command, six.string_types): @@ -109,8 +128,17 @@ class ContainerSpec(dict): self['Dir'] = workdir if user is not None: self['User'] = user + if groups is not None: + self['Groups'] = groups + if stop_signal is not None: + self['StopSignal'] = stop_signal + if stop_grace_period is not None: + self['StopGracePeriod'] = stop_grace_period if labels is not None: self['Labels'] = labels + if hosts is not None: + self['Hosts'] = format_extra_hosts(hosts) + if mounts is not None: parsed_mounts = [] for mount in mounts: @@ -120,16 +148,30 @@ class ContainerSpec(dict): # If mount already parsed parsed_mounts.append(mount) self['Mounts'] = parsed_mounts - if stop_grace_period is not None: - self['StopGracePeriod'] = stop_grace_period if secrets is not None: if not isinstance(secrets, list): raise TypeError('secrets must be a list') self['Secrets'] = secrets + if configs is not None: + if not isinstance(configs, list): + raise TypeError('configs must be a list') + self['Configs'] = configs + + if dns_config is not None: + self['DNSConfig'] = dns_config + if privileges is not None: + self['Privileges'] = privileges + if healthcheck is not None: + self['Healthcheck'] = healthcheck + if tty is not None: self['TTY'] = tty + if open_stdin is not None: + self['OpenStdin'] = open_stdin + if read_only is not None: + self['ReadOnly'] = read_only class Mount(dict): @@ -487,6 +529,34 @@ class SecretReference(dict): } +class ConfigReference(dict): + """ + Config reference to be used as part of a :py:class:`ContainerSpec`. + Describes how a config is made accessible inside the service's + containers. + + Args: + config_id (string): Config's ID + config_name (string): Config's name as defined at its creation. + filename (string): Name of the file containing the config. Defaults + to the config's name if not specified. + uid (string): UID of the config file's owner. Default: 0 + gid (string): GID of the config file's group. Default: 0 + mode (int): File access mode inside the container. Default: 0o444 + """ + @check_resource('config_id') + def __init__(self, config_id, config_name, filename=None, uid=None, + gid=None, mode=0o444): + self['ConfigName'] = config_name + self['ConfigID'] = config_id + self['File'] = { + 'Name': filename or config_name, + 'UID': uid or '0', + 'GID': gid or '0', + 'Mode': mode + } + + class Placement(dict): """ Placement constraints to be used as part of a :py:class:`TaskTemplate` @@ -510,3 +580,75 @@ class Placement(dict): self['Platforms'].append({ 'Architecture': plat[0], 'OS': plat[1] }) + + +class DNSConfig(dict): + """ + Specification for DNS related configurations in resolver configuration + file (``resolv.conf``). Part of a :py:class:`ContainerSpec` definition. + + Args: + nameservers (:py:class:`list`): The IP addresses of the name + servers. + search (:py:class:`list`): A search list for host-name lookup. + options (:py:class:`list`): A list of internal resolver variables + to be modified (e.g., ``debug``, ``ndots:3``, etc.). + """ + def __init__(self, nameservers=None, search=None, options=None): + self['Nameservers'] = nameservers + self['Search'] = search + self['Options'] = options + + +class Privileges(dict): + """ + Security options for a service's containers. + Part of a :py:class:`ContainerSpec` definition. + + Args: + credentialspec_file (str): Load credential spec from this file. + The file is read by the daemon, and must be present in the + CredentialSpecs subdirectory in the docker data directory, + which defaults to ``C:\ProgramData\Docker\`` on Windows. + Can not be combined with credentialspec_registry. + + credentialspec_registry (str): Load credential spec from this value + in the Windows registry. The specified registry value must be + located in: ``HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion + \Virtualization\Containers\CredentialSpecs``. + Can not be combined with credentialspec_file. + + selinux_disable (boolean): Disable SELinux + selinux_user (string): SELinux user label + selinux_role (string): SELinux role label + selinux_type (string): SELinux type label + selinux_level (string): SELinux level label + """ + def __init__(self, credentialspec_file=None, credentialspec_registry=None, + selinux_disable=None, selinux_user=None, selinux_role=None, + selinux_type=None, selinux_level=None): + credential_spec = {} + if credentialspec_registry is not None: + credential_spec['Registry'] = credentialspec_registry + if credentialspec_file is not None: + credential_spec['File'] = credentialspec_file + + if len(credential_spec) > 1: + raise errors.InvalidArgument( + 'credentialspec_file and credentialspec_registry are mutually' + ' exclusive' + ) + + selinux_context = { + 'Disable': selinux_disable, + 'User': selinux_user, + 'Role': selinux_role, + 'Type': selinux_type, + 'Level': selinux_level, + } + + if len(credential_spec) > 0: + self['CredentialSpec'] = credential_spec + + if len(selinux_context) > 0: + self['SELinuxContext'] = selinux_context diff --git a/docker/utils/__init__.py b/docker/utils/__init__.py index b758cbd4..c162e3bd 100644 --- a/docker/utils/__init__.py +++ b/docker/utils/__init__.py @@ -8,6 +8,6 @@ from .utils import ( create_host_config, parse_bytes, ping_registry, parse_env_file, version_lt, version_gte, decode_json_header, split_command, create_ipam_config, create_ipam_pool, parse_devices, normalize_links, convert_service_networks, - format_environment, create_archive + format_environment, create_archive, format_extra_hosts ) diff --git a/docker/utils/utils.py b/docker/utils/utils.py index d9a6d7c1..a123fd8f 100644 --- a/docker/utils/utils.py +++ b/docker/utils/utils.py @@ -564,6 +564,12 @@ def format_environment(environment): return [format_env(*var) for var in six.iteritems(environment)] +def format_extra_hosts(extra_hosts): + return [ + '{}:{}'.format(k, v) for k, v in sorted(six.iteritems(extra_hosts)) + ] + + def create_host_config(self, *args, **kwargs): raise errors.DeprecatedMethod( 'utils.create_host_config has been removed. Please use a ' diff --git a/docs/api.rst b/docs/api.rst index 0b10f387..2fce0a77 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -122,13 +122,17 @@ Configuration types .. py:module:: docker.types -.. autoclass:: IPAMConfig -.. autoclass:: IPAMPool +.. autoclass:: ConfigReference .. autoclass:: ContainerSpec +.. autoclass:: DNSConfig .. autoclass:: DriverConfig .. autoclass:: EndpointSpec +.. autoclass:: Healthcheck +.. autoclass:: IPAMConfig +.. autoclass:: IPAMPool .. autoclass:: Mount .. autoclass:: Placement +.. autoclass:: Privileges .. autoclass:: Resources .. autoclass:: RestartPolicy .. autoclass:: SecretReference From b1301637cf2669205b048df80f7d21c2ac3c4d68 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 26 Oct 2017 16:13:05 -0700 Subject: [PATCH 19/34] Add support for configs management Signed-off-by: Joffrey F --- docker/api/client.py | 2 + docker/api/config.py | 91 +++++++++++++ docker/client.py | 9 ++ docker/models/configs.py | 69 ++++++++++ docs/api.rst | 10 ++ docs/client.rst | 1 + docs/configs.rst | 30 +++++ docs/index.rst | 1 + tests/integration/api_config_test.py | 69 ++++++++++ tests/integration/api_service_test.py | 178 +++++++++++++++++++++++++- tests/integration/base.py | 7 + 11 files changed, 464 insertions(+), 3 deletions(-) create mode 100644 docker/api/config.py create mode 100644 docker/models/configs.py create mode 100644 docs/configs.rst create mode 100644 tests/integration/api_config_test.py diff --git a/docker/api/client.py b/docker/api/client.py index 1de10c77..cbe74b91 100644 --- a/docker/api/client.py +++ b/docker/api/client.py @@ -9,6 +9,7 @@ import six import websocket from .build import BuildApiMixin +from .config import ConfigApiMixin from .container import ContainerApiMixin from .daemon import DaemonApiMixin from .exec_api import ExecApiMixin @@ -43,6 +44,7 @@ except ImportError: class APIClient( requests.Session, BuildApiMixin, + ConfigApiMixin, ContainerApiMixin, DaemonApiMixin, ExecApiMixin, diff --git a/docker/api/config.py b/docker/api/config.py new file mode 100644 index 00000000..b46b09c7 --- /dev/null +++ b/docker/api/config.py @@ -0,0 +1,91 @@ +import base64 + +import six + +from .. import utils + + +class ConfigApiMixin(object): + @utils.minimum_version('1.25') + def create_config(self, name, data, labels=None): + """ + Create a config + + Args: + name (string): Name of the config + data (bytes): Config data to be stored + labels (dict): A mapping of labels to assign to the config + + Returns (dict): ID of the newly created config + """ + if not isinstance(data, bytes): + data = data.encode('utf-8') + + data = base64.b64encode(data) + if six.PY3: + data = data.decode('ascii') + body = { + 'Data': data, + 'Name': name, + 'Labels': labels + } + + url = self._url('/configs/create') + return self._result( + self._post_json(url, data=body), True + ) + + @utils.minimum_version('1.25') + @utils.check_resource('id') + def inspect_config(self, id): + """ + Retrieve config metadata + + Args: + id (string): Full ID of the config to remove + + Returns (dict): A dictionary of metadata + + Raises: + :py:class:`docker.errors.NotFound` + if no config with that ID exists + """ + url = self._url('/configs/{0}', id) + return self._result(self._get(url), True) + + @utils.minimum_version('1.25') + @utils.check_resource('id') + def remove_config(self, id): + """ + Remove a config + + Args: + id (string): Full ID of the config to remove + + Returns (boolean): True if successful + + Raises: + :py:class:`docker.errors.NotFound` + if no config with that ID exists + """ + url = self._url('/configs/{0}', id) + res = self._delete(url) + self._raise_for_status(res) + return True + + @utils.minimum_version('1.25') + def configs(self, filters=None): + """ + List configs + + Args: + filters (dict): A map of filters to process on the configs + list. Available filters: ``names`` + + Returns (list): A list of configs + """ + url = self._url('/configs') + params = {} + if filters: + params['filters'] = utils.convert_filters(filters) + return self._result(self._get(url, params=params), True) diff --git a/docker/client.py b/docker/client.py index ee361bb9..29968c1f 100644 --- a/docker/client.py +++ b/docker/client.py @@ -1,5 +1,6 @@ from .api.client import APIClient from .constants import DEFAULT_TIMEOUT_SECONDS +from .models.configs import ConfigCollection from .models.containers import ContainerCollection from .models.images import ImageCollection from .models.networks import NetworkCollection @@ -80,6 +81,14 @@ class DockerClient(object): **kwargs_from_env(**kwargs)) # Resources + @property + def configs(self): + """ + An object for managing configs on the server. See the + :doc:`configs documentation ` for full details. + """ + return ConfigCollection(client=self) + @property def containers(self): """ diff --git a/docker/models/configs.py b/docker/models/configs.py new file mode 100644 index 00000000..7f23f650 --- /dev/null +++ b/docker/models/configs.py @@ -0,0 +1,69 @@ +from ..api import APIClient +from .resource import Model, Collection + + +class Config(Model): + """A config.""" + id_attribute = 'ID' + + def __repr__(self): + return "<%s: '%s'>" % (self.__class__.__name__, self.name) + + @property + def name(self): + return self.attrs['Spec']['Name'] + + def remove(self): + """ + Remove this config. + + Raises: + :py:class:`docker.errors.APIError` + If config failed to remove. + """ + return self.client.api.remove_config(self.id) + + +class ConfigCollection(Collection): + """Configs on the Docker server.""" + model = Config + + def create(self, **kwargs): + obj = self.client.api.create_config(**kwargs) + return self.prepare_model(obj) + create.__doc__ = APIClient.create_config.__doc__ + + def get(self, config_id): + """ + Get a config. + + Args: + config_id (str): Config ID. + + Returns: + (:py:class:`Config`): The config. + + Raises: + :py:class:`docker.errors.NotFound` + If the config does not exist. + :py:class:`docker.errors.APIError` + If the server returns an error. + """ + return self.prepare_model(self.client.api.inspect_config(config_id)) + + def list(self, **kwargs): + """ + List configs. Similar to the ``docker config ls`` command. + + Args: + filters (dict): Server-side list filtering options. + + Returns: + (list of :py:class:`Config`): The configs. + + Raises: + :py:class:`docker.errors.APIError` + If the server returns an error. + """ + resp = self.client.api.configs(**kwargs) + return [self.prepare_model(obj) for obj in resp] diff --git a/docs/api.rst b/docs/api.rst index 2fce0a77..18993ad3 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -9,6 +9,16 @@ It's possible to use :py:class:`APIClient` directly. Some basic things (e.g. run .. autoclass:: docker.api.client.APIClient +Configs +------- + +.. py:module:: docker.api.config + +.. rst-class:: hide-signature +.. autoclass:: ConfigApiMixin + :members: + :undoc-members: + Containers ---------- diff --git a/docs/client.rst b/docs/client.rst index ac7a256a..43d7c63b 100644 --- a/docs/client.rst +++ b/docs/client.rst @@ -15,6 +15,7 @@ Client reference .. autoclass:: DockerClient() + .. autoattribute:: configs .. autoattribute:: containers .. autoattribute:: images .. autoattribute:: networks diff --git a/docs/configs.rst b/docs/configs.rst new file mode 100644 index 00000000..d907ad42 --- /dev/null +++ b/docs/configs.rst @@ -0,0 +1,30 @@ +Configs +======= + +.. py:module:: docker.models.configs + +Manage configs on the server. + +Methods available on ``client.configs``: + +.. rst-class:: hide-signature +.. py:class:: ConfigCollection + + .. automethod:: create + .. automethod:: get + .. automethod:: list + + +Config objects +-------------- + +.. autoclass:: Config() + + .. autoattribute:: id + .. autoattribute:: name + .. py:attribute:: attrs + + The raw representation of this object from the server. + + .. automethod:: reload + .. automethod:: remove diff --git a/docs/index.rst b/docs/index.rst index 9113bffc..39426b68 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -80,6 +80,7 @@ That's just a taste of what you can do with the Docker SDK for Python. For more, :maxdepth: 2 client + configs containers images networks diff --git a/tests/integration/api_config_test.py b/tests/integration/api_config_test.py new file mode 100644 index 00000000..fb6002a7 --- /dev/null +++ b/tests/integration/api_config_test.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- + +import docker +import pytest + +from ..helpers import force_leave_swarm, requires_api_version +from .base import BaseAPIIntegrationTest + + +@requires_api_version('1.30') +class ConfigAPITest(BaseAPIIntegrationTest): + def setUp(self): + super(ConfigAPITest, self).setUp() + self.init_swarm() + + def tearDown(self): + super(ConfigAPITest, self).tearDown() + force_leave_swarm(self.client) + + def test_create_config(self): + config_id = self.client.create_config( + 'favorite_character', 'sakuya izayoi' + ) + self.tmp_configs.append(config_id) + assert 'ID' in config_id + data = self.client.inspect_config(config_id) + assert data['Spec']['Name'] == 'favorite_character' + + def test_create_config_unicode_data(self): + config_id = self.client.create_config( + 'favorite_character', u'いざよいさくや' + ) + self.tmp_configs.append(config_id) + assert 'ID' in config_id + data = self.client.inspect_config(config_id) + assert data['Spec']['Name'] == 'favorite_character' + + def test_inspect_config(self): + config_name = 'favorite_character' + config_id = self.client.create_config( + config_name, 'sakuya izayoi' + ) + self.tmp_configs.append(config_id) + data = self.client.inspect_config(config_id) + assert data['Spec']['Name'] == config_name + assert 'ID' in data + assert 'Version' in data + + def test_remove_config(self): + config_name = 'favorite_character' + config_id = self.client.create_config( + config_name, 'sakuya izayoi' + ) + self.tmp_configs.append(config_id) + + assert self.client.remove_config(config_id) + with pytest.raises(docker.errors.NotFound): + self.client.inspect_config(config_id) + + def test_list_configs(self): + config_name = 'favorite_character' + config_id = self.client.create_config( + config_name, 'sakuya izayoi' + ) + self.tmp_configs.append(config_id) + + data = self.client.configs(filters={'name': ['favorite_character']}) + assert len(data) == 1 + assert data[0]['ID'] == config_id['ID'] diff --git a/tests/integration/api_service_test.py b/tests/integration/api_service_test.py index c966916e..56c3e683 100644 --- a/tests/integration/api_service_test.py +++ b/tests/integration/api_service_test.py @@ -473,7 +473,7 @@ class ServiceTest(BaseAPIIntegrationTest): secret_data = u'東方花映塚' secret_id = self.client.create_secret(secret_name, secret_data) self.tmp_secrets.append(secret_id) - secret_ref = docker.types.SecretReference(secret_id, secret_name) + secret_ref = docker.types.ConfigReference(secret_id, secret_name) container_spec = docker.types.ContainerSpec( 'busybox', ['sleep', '999'], secrets=[secret_ref] ) @@ -481,8 +481,8 @@ class ServiceTest(BaseAPIIntegrationTest): name = self.get_service_name() svc_id = self.client.create_service(task_tmpl, name=name) svc_info = self.client.inspect_service(svc_id) - assert 'Secrets' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] - secrets = svc_info['Spec']['TaskTemplate']['ContainerSpec']['Secrets'] + assert 'Configs' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] + secrets = svc_info['Spec']['TaskTemplate']['ContainerSpec']['Configs'] assert secrets[0] == secret_ref container = self.get_service_container(name) @@ -493,3 +493,175 @@ class ServiceTest(BaseAPIIntegrationTest): container_secret = self.client.exec_start(exec_id) container_secret = container_secret.decode('utf-8') assert container_secret == secret_data + + @requires_api_version('1.25') + def test_create_service_with_config(self): + config_name = 'favorite_touhou' + config_data = b'phantasmagoria of flower view' + config_id = self.client.create_config(config_name, config_data) + self.tmp_configs.append(config_id) + config_ref = docker.types.ConfigReference(config_id, config_name) + container_spec = docker.types.ContainerSpec( + 'busybox', ['sleep', '999'], configs=[config_ref] + ) + 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 'Configs' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] + configs = svc_info['Spec']['TaskTemplate']['ContainerSpec']['Configs'] + assert configs[0] == config_ref + + container = self.get_service_container(name) + assert container is not None + exec_id = self.client.exec_create( + container, 'cat /run/configs/{0}'.format(config_name) + ) + assert self.client.exec_start(exec_id) == config_data + + @requires_api_version('1.25') + def test_create_service_with_unicode_config(self): + config_name = 'favorite_touhou' + config_data = u'東方花映塚' + config_id = self.client.create_config(config_name, config_data) + self.tmp_configs.append(config_id) + config_ref = docker.types.ConfigReference(config_id, config_name) + container_spec = docker.types.ContainerSpec( + 'busybox', ['sleep', '999'], configs=[config_ref] + ) + 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 'Configs' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] + configs = svc_info['Spec']['TaskTemplate']['ContainerSpec']['Configs'] + assert configs[0] == config_ref + + container = self.get_service_container(name) + assert container is not None + exec_id = self.client.exec_create( + container, 'cat /run/configs/{0}'.format(config_name) + ) + container_config = self.client.exec_start(exec_id) + container_config = container_config.decode('utf-8') + assert container_config == config_data + + @requires_api_version('1.25') + def test_create_service_with_hosts(self): + container_spec = docker.types.ContainerSpec( + 'busybox', ['sleep', '999'], hosts={ + 'foobar': '127.0.0.1', + 'baz': '8.8.8.8', + } + ) + 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 '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 + + @requires_api_version('1.25') + def test_create_service_with_hostname(self): + container_spec = docker.types.ContainerSpec( + 'busybox', ['sleep', '999'], hostname='foobar.baz.com' + ) + 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 'Hostname' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] + assert ( + svc_info['Spec']['TaskTemplate']['ContainerSpec']['Hostname'] == + 'foobar.baz.com' + ) + + @requires_api_version('1.25') + def test_create_service_with_groups(self): + container_spec = docker.types.ContainerSpec( + 'busybox', ['sleep', '999'], groups=['shrinemaidens', 'youkais'] + ) + 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 'Groups' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] + groups = svc_info['Spec']['TaskTemplate']['ContainerSpec']['Groups'] + assert len(groups) == 2 + assert 'shrinemaidens' in groups + assert 'youkais' in groups + + @requires_api_version('1.25') + def test_create_service_with_dns_config(self): + dns_config = docker.types.DNSConfig( + nameservers=['8.8.8.8', '8.8.4.4'], + search=['local'], options=['debug'] + ) + container_spec = docker.types.ContainerSpec( + BUSYBOX, ['sleep', '999'], dns_config=dns_config + ) + 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 'DNSConfig' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] + assert ( + dns_config == + svc_info['Spec']['TaskTemplate']['ContainerSpec']['DNSConfig'] + ) + + @requires_api_version('1.25') + def test_create_service_with_healthcheck(self): + second = 1000000000 + hc = docker.types.Healthcheck( + test='true', retries=3, timeout=1 * second, + start_period=3 * second, interval=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'] + ) + + @requires_api_version('1.28') + def test_create_service_with_readonly(self): + container_spec = docker.types.ContainerSpec( + BUSYBOX, ['sleep', '999'], read_only=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 ( + 'ReadOnly' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] + ) + assert svc_info['Spec']['TaskTemplate']['ContainerSpec']['ReadOnly'] + + @requires_api_version('1.28') + def test_create_service_with_stop_signal(self): + container_spec = docker.types.ContainerSpec( + BUSYBOX, ['sleep', '999'], stop_signal='SIGINT' + ) + 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 ( + 'StopSignal' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] + ) + assert ( + svc_info['Spec']['TaskTemplate']['ContainerSpec']['StopSignal'] == + 'SIGINT' + ) diff --git a/tests/integration/base.py b/tests/integration/base.py index 0c0cd065..701e7fc2 100644 --- a/tests/integration/base.py +++ b/tests/integration/base.py @@ -29,6 +29,7 @@ class BaseIntegrationTest(unittest.TestCase): self.tmp_networks = [] self.tmp_plugins = [] self.tmp_secrets = [] + self.tmp_configs = [] def tearDown(self): client = docker.from_env(version=TEST_API_VERSION) @@ -59,6 +60,12 @@ class BaseIntegrationTest(unittest.TestCase): except docker.errors.APIError: pass + for config in self.tmp_configs: + try: + client.api.remove_config(config) + except docker.errors.APIError: + pass + for folder in self.tmp_folders: shutil.rmtree(folder) From 2cb78062b14029c2834b30d6a407168bcf200ed3 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 26 Oct 2017 16:50:53 -0700 Subject: [PATCH 20/34] More ContainerSpec tests Signed-off-by: Joffrey F --- tests/integration/api_config_test.py | 15 ++++---- tests/integration/api_secret_test.py | 15 ++++---- tests/integration/api_service_test.py | 51 +++++++++++++++++++-------- tests/integration/base.py | 20 +++++++---- 4 files changed, 69 insertions(+), 32 deletions(-) diff --git a/tests/integration/api_config_test.py b/tests/integration/api_config_test.py index fb6002a7..0ffd7675 100644 --- a/tests/integration/api_config_test.py +++ b/tests/integration/api_config_test.py @@ -9,13 +9,16 @@ from .base import BaseAPIIntegrationTest @requires_api_version('1.30') class ConfigAPITest(BaseAPIIntegrationTest): - def setUp(self): - super(ConfigAPITest, self).setUp() - self.init_swarm() + @classmethod + def setup_class(cls): + client = cls.get_client_instance() + force_leave_swarm(client) + cls._init_swarm(client) - def tearDown(self): - super(ConfigAPITest, self).tearDown() - force_leave_swarm(self.client) + @classmethod + def teardown_class(cls): + client = cls.get_client_instance() + force_leave_swarm(client) def test_create_config(self): config_id = self.client.create_config( diff --git a/tests/integration/api_secret_test.py b/tests/integration/api_secret_test.py index dcd880f4..b3d93b8f 100644 --- a/tests/integration/api_secret_test.py +++ b/tests/integration/api_secret_test.py @@ -9,13 +9,16 @@ from .base import BaseAPIIntegrationTest @requires_api_version('1.25') class SecretAPITest(BaseAPIIntegrationTest): - def setUp(self): - super(SecretAPITest, self).setUp() - self.init_swarm() + @classmethod + def setup_class(cls): + client = cls.get_client_instance() + force_leave_swarm(client) + cls._init_swarm(client) - def tearDown(self): - super(SecretAPITest, self).tearDown() - force_leave_swarm(self.client) + @classmethod + def teardown_class(cls): + client = cls.get_client_instance() + force_leave_swarm(client) def test_create_secret(self): secret_id = self.client.create_secret( diff --git a/tests/integration/api_service_test.py b/tests/integration/api_service_test.py index 56c3e683..baa6afa7 100644 --- a/tests/integration/api_service_test.py +++ b/tests/integration/api_service_test.py @@ -13,19 +13,24 @@ from .base import BaseAPIIntegrationTest, BUSYBOX class ServiceTest(BaseAPIIntegrationTest): - def setUp(self): - super(ServiceTest, self).setUp() - force_leave_swarm(self.client) - self.init_swarm() + @classmethod + def setup_class(cls): + client = cls.get_client_instance() + force_leave_swarm(client) + cls._init_swarm(client) + + @classmethod + def teardown_class(cls): + client = cls.get_client_instance() + force_leave_swarm(client) def tearDown(self): - super(ServiceTest, self).tearDown() for service in self.client.services(filters={'name': 'dockerpytest_'}): try: self.client.remove_service(service['ID']) except docker.errors.APIError: pass - force_leave_swarm(self.client) + super(ServiceTest, self).tearDown() def get_service_name(self): return 'dockerpytest_{0:x}'.format(random.getrandbits(64)) @@ -473,7 +478,7 @@ class ServiceTest(BaseAPIIntegrationTest): secret_data = u'東方花映塚' secret_id = self.client.create_secret(secret_name, secret_data) self.tmp_secrets.append(secret_id) - secret_ref = docker.types.ConfigReference(secret_id, secret_name) + secret_ref = docker.types.SecretReference(secret_id, secret_name) container_spec = docker.types.ContainerSpec( 'busybox', ['sleep', '999'], secrets=[secret_ref] ) @@ -481,8 +486,8 @@ class ServiceTest(BaseAPIIntegrationTest): name = self.get_service_name() svc_id = self.client.create_service(task_tmpl, name=name) svc_info = self.client.inspect_service(svc_id) - assert 'Configs' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] - secrets = svc_info['Spec']['TaskTemplate']['ContainerSpec']['Configs'] + assert 'Secrets' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] + secrets = svc_info['Spec']['TaskTemplate']['ContainerSpec']['Secrets'] assert secrets[0] == secret_ref container = self.get_service_container(name) @@ -494,7 +499,7 @@ class ServiceTest(BaseAPIIntegrationTest): container_secret = container_secret.decode('utf-8') assert container_secret == secret_data - @requires_api_version('1.25') + @requires_api_version('1.30') def test_create_service_with_config(self): config_name = 'favorite_touhou' config_data = b'phantasmagoria of flower view' @@ -515,11 +520,11 @@ class ServiceTest(BaseAPIIntegrationTest): container = self.get_service_container(name) assert container is not None exec_id = self.client.exec_create( - container, 'cat /run/configs/{0}'.format(config_name) + container, 'cat /{0}'.format(config_name) ) assert self.client.exec_start(exec_id) == config_data - @requires_api_version('1.25') + @requires_api_version('1.30') def test_create_service_with_unicode_config(self): config_name = 'favorite_touhou' config_data = u'東方花映塚' @@ -540,7 +545,7 @@ class ServiceTest(BaseAPIIntegrationTest): container = self.get_service_container(name) assert container is not None exec_id = self.client.exec_create( - container, 'cat /run/configs/{0}'.format(config_name) + container, 'cat /{0}'.format(config_name) ) container_config = self.client.exec_start(exec_id) container_config = container_config.decode('utf-8') @@ -618,7 +623,7 @@ class ServiceTest(BaseAPIIntegrationTest): second = 1000000000 hc = docker.types.Healthcheck( test='true', retries=3, timeout=1 * second, - start_period=3 * second, interval=second / 2, + start_period=3 * second, interval=int(second / 2), ) container_spec = docker.types.ContainerSpec( BUSYBOX, ['sleep', '999'], healthcheck=hc @@ -665,3 +670,21 @@ class ServiceTest(BaseAPIIntegrationTest): svc_info['Spec']['TaskTemplate']['ContainerSpec']['StopSignal'] == 'SIGINT' ) + + @requires_api_version('1.30') + def test_create_service_with_privileges(self): + priv = docker.types.Privileges(selinux_disable=True) + container_spec = docker.types.ContainerSpec( + BUSYBOX, ['sleep', '999'], privileges=priv + ) + 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 ( + 'Privileges' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] + ) + privileges = ( + svc_info['Spec']['TaskTemplate']['ContainerSpec']['Privileges'] + ) + assert privileges['SELinuxContext']['Disable'] is True diff --git a/tests/integration/base.py b/tests/integration/base.py index 701e7fc2..4f929014 100644 --- a/tests/integration/base.py +++ b/tests/integration/base.py @@ -78,14 +78,24 @@ class BaseAPIIntegrationTest(BaseIntegrationTest): def setUp(self): super(BaseAPIIntegrationTest, self).setUp() - self.client = docker.APIClient( - version=TEST_API_VERSION, timeout=60, **kwargs_from_env() - ) + self.client = self.get_client_instance() def tearDown(self): super(BaseAPIIntegrationTest, self).tearDown() self.client.close() + @staticmethod + def get_client_instance(): + return docker.APIClient( + version=TEST_API_VERSION, timeout=60, **kwargs_from_env() + ) + + @staticmethod + def _init_swarm(client, **kwargs): + return client.init_swarm( + '127.0.0.1', listen_addr=helpers.swarm_listen_addr(), **kwargs + ) + def run_container(self, *args, **kwargs): container = self.client.create_container(*args, **kwargs) self.tmp_containers.append(container) @@ -116,6 +126,4 @@ class BaseAPIIntegrationTest(BaseIntegrationTest): assert actual_exit_code == exit_code, msg def init_swarm(self, **kwargs): - return self.client.init_swarm( - '127.0.0.1', listen_addr=helpers.swarm_listen_addr(), **kwargs - ) + return self._init_swarm(self.client, **kwargs) From 80985cb5b2262dcd5263ef78635aa66609e132eb Mon Sep 17 00:00:00 2001 From: Alessandro Baldo Date: Wed, 1 Nov 2017 01:44:21 +0100 Subject: [PATCH 21/34] Improve docs for service list filters - add "label" and "mode" to the list of available filter keys in the high-level service API - add "label" and "mode" to the list of available filter keys in the low-level service API - add integration tests Signed-off-by: Alessandro Baldo --- docker/api/service.py | 3 ++- docker/models/services.py | 3 ++- tests/integration/api_service_test.py | 15 +++++++++++++-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/docker/api/service.py b/docker/api/service.py index 9ce830ca..e6b48768 100644 --- a/docker/api/service.py +++ b/docker/api/service.py @@ -201,7 +201,8 @@ class ServiceApiMixin(object): Args: filters (dict): Filters to process on the nodes list. Valid - filters: ``id`` and ``name``. Default: ``None``. + filters: ``id``, ``name`` , ``label`` and ``mode``. + Default: ``None``. Returns: A list of dictionaries containing data about each service. diff --git a/docker/models/services.py b/docker/models/services.py index d45621bb..f2a5d355 100644 --- a/docker/models/services.py +++ b/docker/models/services.py @@ -201,7 +201,8 @@ class ServiceCollection(Collection): Args: filters (dict): Filters to process on the nodes list. Valid - filters: ``id`` and ``name``. Default: ``None``. + filters: ``id``, ``name`` , ``label`` and ``mode``. + Default: ``None``. Returns: (list of :py:class:`Service`): The services. diff --git a/tests/integration/api_service_test.py b/tests/integration/api_service_test.py index baa6afa7..8c6d4af5 100644 --- a/tests/integration/api_service_test.py +++ b/tests/integration/api_service_test.py @@ -52,7 +52,7 @@ class ServiceTest(BaseAPIIntegrationTest): return None time.sleep(interval) - def create_simple_service(self, name=None): + def create_simple_service(self, name=None, labels=None): if name: name = 'dockerpytest_{0}'.format(name) else: @@ -62,7 +62,9 @@ class ServiceTest(BaseAPIIntegrationTest): BUSYBOX, ['echo', 'hello'] ) task_tmpl = docker.types.TaskTemplate(container_spec) - return name, self.client.create_service(task_tmpl, name=name) + return name, self.client.create_service( + task_tmpl, name=name, labels=labels + ) @requires_api_version('1.24') def test_list_services(self): @@ -76,6 +78,15 @@ class ServiceTest(BaseAPIIntegrationTest): assert len(test_services) == 1 assert 'dockerpytest_' in test_services[0]['Spec']['Name'] + @requires_api_version('1.24') + def test_list_services_filter_by_label(self): + test_services = self.client.services(filters={'label': 'test_label'}) + assert len(test_services) == 0 + self.create_simple_service(labels={'test_label': 'testing'}) + test_services = self.client.services(filters={'label': 'test_label'}) + assert len(test_services) == 1 + assert test_services[0]['Spec']['Labels']['test_label'] == 'testing' + def test_inspect_service_by_id(self): svc_name, svc_id = self.create_simple_service() svc_info = self.client.inspect_service(svc_id) From a0853622f98abebe4cb0a837d4fc287000e9805a Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 1 Nov 2017 17:13:09 -0700 Subject: [PATCH 22/34] Add support for secret driver in create_secret Signed-off-by: Joffrey F --- docker/api/secret.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docker/api/secret.py b/docker/api/secret.py index 1760a394..fa4c2ab8 100644 --- a/docker/api/secret.py +++ b/docker/api/secret.py @@ -2,12 +2,13 @@ import base64 import six +from .. import errors from .. import utils class SecretApiMixin(object): @utils.minimum_version('1.25') - def create_secret(self, name, data, labels=None): + def create_secret(self, name, data, labels=None, driver=None): """ Create a secret @@ -15,6 +16,8 @@ class SecretApiMixin(object): name (string): Name of the secret data (bytes): Secret data to be stored labels (dict): A mapping of labels to assign to the secret + driver (DriverConfig): A custom driver configuration. If + unspecified, the default ``internal`` driver will be used Returns (dict): ID of the newly created secret """ @@ -30,6 +33,14 @@ class SecretApiMixin(object): 'Labels': labels } + if driver is not None: + if utils.version_lt(self._version, '1.31'): + raise errors.InvalidVersion( + 'Secret driver is only available for API version > 1.31' + ) + + body['Driver'] = driver + url = self._url('/secrets/create') return self._result( self._post_json(url, data=body), True From 114512a9bf5aaccaf4c1fc58f86c3677c80436f1 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 1 Nov 2017 17:13:28 -0700 Subject: [PATCH 23/34] Doc fixes Signed-off-by: Joffrey F --- docker/api/build.py | 4 ++-- docker/api/plugin.py | 8 ++++---- docker/types/healthcheck.py | 13 +++++++------ docker/types/services.py | 17 +++++++++-------- 4 files changed, 22 insertions(+), 20 deletions(-) diff --git a/docker/api/build.py b/docker/api/build.py index 25f271a4..9ff2dfb3 100644 --- a/docker/api/build.py +++ b/docker/api/build.py @@ -93,8 +93,8 @@ class BuildApiMixin(object): 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 + cache_from (:py:class:`list`): A list of images used for build + cache 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 diff --git a/docker/api/plugin.py b/docker/api/plugin.py index 87520cce..73f18525 100644 --- a/docker/api/plugin.py +++ b/docker/api/plugin.py @@ -110,8 +110,8 @@ class PluginApiMixin(object): remote (string): Remote reference for the plugin to install. The ``:latest`` tag is optional, and is the default if omitted. - privileges (list): A list of privileges the user consents to - grant to the plugin. Can be retrieved using + privileges (:py:class:`list`): A list of privileges the user + consents to grant to the plugin. Can be retrieved using :py:meth:`~plugin_privileges`. name (string): Local name for the pulled plugin. The ``:latest`` tag is optional, and is the default if omitted. @@ -225,8 +225,8 @@ class PluginApiMixin(object): tag is optional and is the default if omitted. remote (string): Remote reference to upgrade to. The ``:latest`` tag is optional and is the default if omitted. - privileges (list): A list of privileges the user consents to - grant to the plugin. Can be retrieved using + privileges (:py:class:`list`): A list of privileges the user + consents to grant to the plugin. Can be retrieved using :py:meth:`~plugin_privileges`. Returns: diff --git a/docker/types/healthcheck.py b/docker/types/healthcheck.py index 5a6a9315..61857c21 100644 --- a/docker/types/healthcheck.py +++ b/docker/types/healthcheck.py @@ -8,14 +8,15 @@ class Healthcheck(DictType): Defines a healthcheck configuration for a container or service. Args: - test (:py:class:`list` or str): Test to perform to determine container health. Possible values: - - Empty list: Inherit healthcheck from parent image - - ``["NONE"]``: Disable healthcheck - - ``["CMD", args...]``: exec arguments directly. - - ``["CMD-SHELL", command]``: RUn command in the system's - default shell. + + - Empty list: Inherit healthcheck from parent image + - ``["NONE"]``: Disable healthcheck + - ``["CMD", args...]``: exec arguments directly. + - ``["CMD-SHELL", command]``: RUn command in the system's + default shell. + If a string is provided, it will be used as a ``CMD-SHELL`` command. interval (int): The time to wait between checks in nanoseconds. It diff --git a/docker/types/services.py b/docker/types/services.py index c77db166..9031e609 100644 --- a/docker/types/services.py +++ b/docker/types/services.py @@ -405,8 +405,9 @@ class DriverConfig(dict): """ Indicates which driver to use, as well as its configuration. Can be used as ``log_driver`` in a :py:class:`~docker.types.ContainerSpec`, - and for the `driver_config` in a volume - :py:class:`~docker.types.Mount`. + for the `driver_config` in a volume :py:class:`~docker.types.Mount`, or + as the driver object in + :py:meth:`create_secret`. Args: @@ -562,12 +563,12 @@ 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 + constraints (:py:class:`list`): A list of constraints + preferences (:py:class:`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 (:py:class:`list`): A list of platforms expressed as + ``(arch, os)`` tuples """ def __init__(self, constraints=None, preferences=None, platforms=None): if constraints is not None: From 047c67b31e2087d5e900072166921d55649f8b6f Mon Sep 17 00:00:00 2001 From: Chris Harris Date: Thu, 5 Oct 2017 12:14:17 -0400 Subject: [PATCH 24/34] Prevent data loss when attaching to container The use of buffering within httplib.HTTPResponse can cause data to be lost. socket.makefile() is called without a bufsize, which causes a buffer to be used when recieving data. The attach methods do a HTTP upgrade to tcp before the raw socket is using to stream data from the container. The problem is that if the container starts stream data while httplib/http.client is reading the response to the attach request part of the data ends will end up in the buffer of fileobject created within the HTTPResponse object. This data is lost as after the attach request data is read directly from the raw socket. Signed-off-by: Chris Harris --- docker/transport/unixconn.py | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/docker/transport/unixconn.py b/docker/transport/unixconn.py index 3565cfb6..16e22a8e 100644 --- a/docker/transport/unixconn.py +++ b/docker/transport/unixconn.py @@ -34,6 +34,25 @@ class UnixHTTPConnection(httplib.HTTPConnection, object): self.sock = sock +class AttachHTTPResponse(httplib.HTTPResponse): + ''' + A HTTPResponse object that doesn't use a buffered fileobject. + ''' + def __init__(self, sock, *args, **kwargs): + # Delegate to super class + httplib.HTTPResponse.__init__(self, sock, *args, **kwargs) + + # Override fp with a fileobject that doesn't buffer + self.fp = sock.makefile('rb', 0) + + +class AttachUnixHTTPConnection(UnixHTTPConnection): + ''' + A HTTPConnection that returns responses that don't used buffering. + ''' + response_class = AttachHTTPResponse + + class UnixHTTPConnectionPool(urllib3.connectionpool.HTTPConnectionPool): def __init__(self, base_url, socket_path, timeout=60, maxsize=10): super(UnixHTTPConnectionPool, self).__init__( @@ -44,9 +63,17 @@ class UnixHTTPConnectionPool(urllib3.connectionpool.HTTPConnectionPool): self.timeout = timeout def _new_conn(self): - return UnixHTTPConnection( - self.base_url, self.socket_path, self.timeout - ) + # Special case for attach url, as we do a http upgrade to tcp and + # a buffered connection can cause data loss. + path = urllib3.util.parse_url(self.base_url).path + if path.endswith('attach'): + return AttachUnixHTTPConnection( + self.base_url, self.socket_path, self.timeout + ) + else: + return UnixHTTPConnection( + self.base_url, self.socket_path, self.timeout + ) class UnixAdapter(requests.adapters.HTTPAdapter): From e055729104dbc60bb9bbebce09686ac8a94c5809 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 1 Nov 2017 18:03:29 -0700 Subject: [PATCH 25/34] Disable buffering based on presence of Connection Upgrade headers Signed-off-by: Joffrey F --- Makefile | 20 +++++++------- docker/transport/unixconn.py | 52 +++++++++++++++++------------------- 2 files changed, 35 insertions(+), 37 deletions(-) diff --git a/Makefile b/Makefile index 991b93a1..efa4232e 100644 --- a/Makefile +++ b/Makefile @@ -27,19 +27,19 @@ test: flake8 unit-test unit-test-py3 integration-dind integration-dind-ssl .PHONY: unit-test unit-test: build - docker run --rm docker-sdk-python py.test tests/unit + docker run -t --rm docker-sdk-python py.test tests/unit .PHONY: unit-test-py3 unit-test-py3: build-py3 - docker run --rm docker-sdk-python3 py.test tests/unit + docker run -t --rm docker-sdk-python3 py.test tests/unit .PHONY: integration-test integration-test: build - docker run --rm -v /var/run/docker.sock:/var/run/docker.sock docker-sdk-python py.test tests/integration/${file} + docker run -t --rm -v /var/run/docker.sock:/var/run/docker.sock docker-sdk-python py.test -v tests/integration/${file} .PHONY: integration-test-py3 integration-test-py3: build-py3 - docker run --rm -v /var/run/docker.sock:/var/run/docker.sock docker-sdk-python3 py.test tests/integration/${file} + docker run -t --rm -v /var/run/docker.sock:/var/run/docker.sock docker-sdk-python3 py.test tests/integration/${file} TEST_API_VERSION ?= 1.30 TEST_ENGINE_VERSION ?= 17.06.0-ce @@ -49,9 +49,9 @@ integration-dind: build build-py3 docker rm -vf dpy-dind || : docker run -d --name dpy-dind --privileged dockerswarm/dind:${TEST_ENGINE_VERSION} dockerd\ -H tcp://0.0.0.0:2375 --experimental - docker run --rm --env="DOCKER_HOST=tcp://docker:2375" --env="DOCKER_TEST_API_VERSION=${TEST_API_VERSION}"\ + docker run -t --rm --env="DOCKER_HOST=tcp://docker:2375" --env="DOCKER_TEST_API_VERSION=${TEST_API_VERSION}"\ --link=dpy-dind:docker docker-sdk-python py.test tests/integration - docker run --rm --env="DOCKER_HOST=tcp://docker:2375" --env="DOCKER_TEST_API_VERSION=${TEST_API_VERSION}"\ + docker run -t --rm --env="DOCKER_HOST=tcp://docker:2375" --env="DOCKER_TEST_API_VERSION=${TEST_API_VERSION}"\ --link=dpy-dind:docker docker-sdk-python3 py.test tests/integration docker rm -vf dpy-dind @@ -63,21 +63,21 @@ integration-dind-ssl: build-dind-certs build build-py3 -v /tmp --privileged dockerswarm/dind:${TEST_ENGINE_VERSION} dockerd --tlsverify\ --tlscacert=/certs/ca.pem --tlscert=/certs/server-cert.pem\ --tlskey=/certs/server-key.pem -H tcp://0.0.0.0:2375 --experimental - docker run --rm --volumes-from dpy-dind-ssl --env="DOCKER_HOST=tcp://docker:2375"\ + docker run -t --rm --volumes-from dpy-dind-ssl --env="DOCKER_HOST=tcp://docker:2375"\ --env="DOCKER_TLS_VERIFY=1" --env="DOCKER_CERT_PATH=/certs" --env="DOCKER_TEST_API_VERSION=${TEST_API_VERSION}"\ --link=dpy-dind-ssl:docker docker-sdk-python py.test tests/integration - docker run --rm --volumes-from dpy-dind-ssl --env="DOCKER_HOST=tcp://docker:2375"\ + docker run -t --rm --volumes-from dpy-dind-ssl --env="DOCKER_HOST=tcp://docker:2375"\ --env="DOCKER_TLS_VERIFY=1" --env="DOCKER_CERT_PATH=/certs" --env="DOCKER_TEST_API_VERSION=${TEST_API_VERSION}"\ --link=dpy-dind-ssl:docker docker-sdk-python3 py.test tests/integration docker rm -vf dpy-dind-ssl dpy-dind-certs .PHONY: flake8 flake8: build - docker run --rm docker-sdk-python flake8 docker tests + docker run -t --rm docker-sdk-python flake8 docker tests .PHONY: docs docs: build-docs - docker run --rm -it -v `pwd`:/src docker-sdk-python-docs sphinx-build docs docs/_build + docker run --rm -t -v `pwd`:/src docker-sdk-python-docs sphinx-build docs docs/_build .PHONY: shell shell: build diff --git a/docker/transport/unixconn.py b/docker/transport/unixconn.py index 16e22a8e..7cb87714 100644 --- a/docker/transport/unixconn.py +++ b/docker/transport/unixconn.py @@ -18,7 +18,20 @@ except ImportError: RecentlyUsedContainer = urllib3._collections.RecentlyUsedContainer +class UnixHTTPResponse(httplib.HTTPResponse, object): + def __init__(self, sock, *args, **kwargs): + disable_buffering = kwargs.pop('disable_buffering', False) + super(UnixHTTPResponse, self).__init__(sock, *args, **kwargs) + if disable_buffering is True: + # We must first create a new pointer then close the old one + # to avoid closing the underlying socket. + new_fp = sock.makefile('rb', 0) + self.fp.close() + self.fp = new_fp + + class UnixHTTPConnection(httplib.HTTPConnection, object): + def __init__(self, base_url, unix_socket, timeout=60): super(UnixHTTPConnection, self).__init__( 'localhost', timeout=timeout @@ -26,6 +39,7 @@ class UnixHTTPConnection(httplib.HTTPConnection, object): self.base_url = base_url self.unix_socket = unix_socket self.timeout = timeout + self.disable_buffering = False def connect(self): sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) @@ -33,24 +47,16 @@ class UnixHTTPConnection(httplib.HTTPConnection, object): sock.connect(self.unix_socket) self.sock = sock + def putheader(self, header, *values): + super(UnixHTTPConnection, self).putheader(header, *values) + if header == 'Connection' and 'Upgrade' in values: + self.disable_buffering = True -class AttachHTTPResponse(httplib.HTTPResponse): - ''' - A HTTPResponse object that doesn't use a buffered fileobject. - ''' - def __init__(self, sock, *args, **kwargs): - # Delegate to super class - httplib.HTTPResponse.__init__(self, sock, *args, **kwargs) + def response_class(self, sock, *args, **kwargs): + if self.disable_buffering: + kwargs['disable_buffering'] = True - # Override fp with a fileobject that doesn't buffer - self.fp = sock.makefile('rb', 0) - - -class AttachUnixHTTPConnection(UnixHTTPConnection): - ''' - A HTTPConnection that returns responses that don't used buffering. - ''' - response_class = AttachHTTPResponse + return UnixHTTPResponse(sock, *args, **kwargs) class UnixHTTPConnectionPool(urllib3.connectionpool.HTTPConnectionPool): @@ -63,17 +69,9 @@ class UnixHTTPConnectionPool(urllib3.connectionpool.HTTPConnectionPool): self.timeout = timeout def _new_conn(self): - # Special case for attach url, as we do a http upgrade to tcp and - # a buffered connection can cause data loss. - path = urllib3.util.parse_url(self.base_url).path - if path.endswith('attach'): - return AttachUnixHTTPConnection( - self.base_url, self.socket_path, self.timeout - ) - else: - return UnixHTTPConnection( - self.base_url, self.socket_path, self.timeout - ) + return UnixHTTPConnection( + self.base_url, self.socket_path, self.timeout + ) class UnixAdapter(requests.adapters.HTTPAdapter): From 9756a4ec4c7235ca6aea1c63a97e82313613f0fe Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 2 Nov 2017 14:06:05 -0700 Subject: [PATCH 26/34] Fix build tests to not rely on internet connectivity Signed-off-by: Joffrey F --- tests/integration/api_build_test.py | 30 +++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/tests/integration/api_build_test.py b/tests/integration/api_build_test.py index 21464ff6..f72c7e6c 100644 --- a/tests/integration/api_build_test.py +++ b/tests/integration/api_build_test.py @@ -8,8 +8,8 @@ from docker import errors import pytest import six -from .base import BaseAPIIntegrationTest -from ..helpers import requires_api_version, requires_experimental +from .base import BaseAPIIntegrationTest, BUSYBOX +from ..helpers import random_name, requires_api_version, requires_experimental class BuildTest(BaseAPIIntegrationTest): @@ -214,21 +214,31 @@ class BuildTest(BaseAPIIntegrationTest): @requires_api_version('1.25') def test_build_with_network_mode(self): + # Set up pingable endpoint on custom network + network = self.client.create_network(random_name())['Id'] + self.tmp_networks.append(network) + container = self.client.create_container(BUSYBOX, 'top') + self.tmp_containers.append(container) + self.client.start(container) + self.client.connect_container_to_network( + container, network, aliases=['pingtarget.docker'] + ) + script = io.BytesIO('\n'.join([ 'FROM busybox', - 'RUN wget http://google.com' + 'RUN ping -c1 pingtarget.docker' ]).encode('ascii')) stream = self.client.build( - fileobj=script, network_mode='bridge', - tag='dockerpytest_bridgebuild' + fileobj=script, network_mode=network, + tag='dockerpytest_customnetbuild' ) - self.tmp_imgs.append('dockerpytest_bridgebuild') + self.tmp_imgs.append('dockerpytest_customnetbuild') for chunk in stream: - pass + print chunk - assert self.client.inspect_image('dockerpytest_bridgebuild') + assert self.client.inspect_image('dockerpytest_customnetbuild') script.seek(0) stream = self.client.build( @@ -260,7 +270,7 @@ class BuildTest(BaseAPIIntegrationTest): fileobj=script, tag=img_name, extra_hosts={ 'extrahost.local.test': '127.0.0.1', - 'hello.world.test': '8.8.8.8', + 'hello.world.test': '127.0.0.1', }, decode=True ) for chunk in stream: @@ -274,7 +284,7 @@ class BuildTest(BaseAPIIntegrationTest): if six.PY3: logs = logs.decode('utf-8') assert '127.0.0.1\textrahost.local.test' in logs - assert '8.8.8.8\thello.world.test' in logs + assert '127.0.0.1\thello.world.test' in logs @requires_experimental(until=None) @requires_api_version('1.25') From 6e1f9333d35cc3c50e1d225a3c690c31cbf57843 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 2 Nov 2017 14:10:13 -0700 Subject: [PATCH 27/34] Oops Signed-off-by: Joffrey F --- tests/integration/api_build_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/api_build_test.py b/tests/integration/api_build_test.py index f72c7e6c..7a0e6b16 100644 --- a/tests/integration/api_build_test.py +++ b/tests/integration/api_build_test.py @@ -236,7 +236,7 @@ class BuildTest(BaseAPIIntegrationTest): self.tmp_imgs.append('dockerpytest_customnetbuild') for chunk in stream: - print chunk + pass assert self.client.inspect_image('dockerpytest_customnetbuild') From a0f6758c76d2524c54ef212aff5744ca19c6a975 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 2 Nov 2017 15:35:43 -0700 Subject: [PATCH 28/34] Add support for scope filter in inspect_network Fix missing scope implementation in create_network Signed-off-by: Joffrey F --- docker/api/network.py | 17 ++++++++++++++++- docker/models/networks.py | 14 +++++++++++--- tests/integration/api_network_test.py | 19 +++++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/docker/api/network.py b/docker/api/network.py index 071a12a6..79778085 100644 --- a/docker/api/network.py +++ b/docker/api/network.py @@ -61,6 +61,8 @@ class NetworkApiMixin(object): 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. + scope (str): Specify the network's scope (``local``, ``global`` or + ``swarm``) ingress (bool): If set, create an ingress network which provides the routing-mesh in swarm mode. @@ -140,6 +142,13 @@ class NetworkApiMixin(object): data['Ingress'] = ingress + if scope is not None: + if version_lt(self._version, '1.30'): + raise InvalidVersion( + 'scope is not supported in API version < 1.30' + ) + data['Scope'] = scope + url = self._url("/networks/create") res = self._post_json(url, data=data) return self._result(res, json=True) @@ -181,7 +190,7 @@ class NetworkApiMixin(object): @minimum_version('1.21') @check_resource('net_id') - def inspect_network(self, net_id, verbose=None): + def inspect_network(self, net_id, verbose=None, scope=None): """ Get detailed information about a network. @@ -189,12 +198,18 @@ class NetworkApiMixin(object): net_id (str): ID of network verbose (bool): Show the service details across the cluster in swarm mode. + scope (str): Filter the network by scope (``swarm``, ``global`` + or ``local``). """ params = {} if verbose is not None: if version_lt(self._version, '1.28'): raise InvalidVersion('verbose was introduced in API 1.28') params['verbose'] = verbose + if scope is not None: + if version_lt(self._version, '1.31'): + raise InvalidVersion('scope was introduced in API 1.31') + params['scope'] = scope url = self._url("/networks/{0}", net_id) res = self._get(url, params=params) diff --git a/docker/models/networks.py b/docker/models/networks.py index afb0ebe8..158af99b 100644 --- a/docker/models/networks.py +++ b/docker/models/networks.py @@ -102,15 +102,19 @@ class NetworkCollection(Collection): name (str): Name of the network driver (str): Name of the driver used to create the network options (dict): Driver options as a key-value dictionary - ipam (dict): Optional custom IP scheme for the network. - Created with :py:class:`~docker.types.IPAMConfig`. + ipam (IPAMConfig): Optional custom IP scheme for the network. check_duplicate (bool): Request daemon to check for networks with - same name. Default: ``True``. + same name. Default: ``None``. internal (bool): Restrict external access to the network. Default ``False``. labels (dict): Map of labels to set on the network. Default ``None``. enable_ipv6 (bool): Enable IPv6 on the network. Default ``False``. + 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. + scope (str): Specify the network's scope (``local``, ``global`` or + ``swarm``) ingress (bool): If set, create an ingress network which provides the routing-mesh in swarm mode. @@ -155,6 +159,10 @@ class NetworkCollection(Collection): Args: network_id (str): The ID of the network. + verbose (bool): Retrieve the service details across the cluster in + swarm mode. + scope (str): Filter the network by scope (``swarm``, ``global`` + or ``local``). Returns: (:py:class:`Network`) The network. diff --git a/tests/integration/api_network_test.py b/tests/integration/api_network_test.py index 1cc632fa..f4fefde5 100644 --- a/tests/integration/api_network_test.py +++ b/tests/integration/api_network_test.py @@ -465,3 +465,22 @@ class TestNetworks(BaseAPIIntegrationTest): net_name, _ = self.create_network() result = self.client.prune_networks() assert net_name in result['NetworksDeleted'] + + @requires_api_version('1.31') + def test_create_inspect_network_with_scope(self): + assert self.init_swarm() + net_name_loc, net_id_loc = self.create_network(scope='local') + + assert self.client.inspect_network(net_name_loc) + assert self.client.inspect_network(net_name_loc, scope='local') + with pytest.raises(docker.errors.NotFound): + self.client.inspect_network(net_name_loc, scope='global') + + net_name_swarm, net_id_swarm = self.create_network( + driver='overlay', scope='swarm' + ) + + assert self.client.inspect_network(net_name_swarm) + 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') From ecca6e0740a24521808c193ae7d4b9499c1f5637 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 2 Nov 2017 18:46:25 -0700 Subject: [PATCH 29/34] Update SwarmSpec to include new parameters Signed-off-by: Joffrey F --- docker/api/swarm.py | 29 ++++++--- docker/models/swarm.py | 17 +++++- docker/types/swarm.py | 92 ++++++++++++++++++++++++++--- docs/api.rst | 2 + tests/integration/api_swarm_test.py | 38 ++++++++++++ 5 files changed, 160 insertions(+), 18 deletions(-) diff --git a/docker/api/swarm.py b/docker/api/swarm.py index 4fa0c4a1..576fd79b 100644 --- a/docker/api/swarm.py +++ b/docker/api/swarm.py @@ -9,8 +9,8 @@ class SwarmApiMixin(object): def create_swarm_spec(self, *args, **kwargs): """ - Create a ``docker.types.SwarmSpec`` instance that can be used as the - ``swarm_spec`` argument in + Create a :py:class:`docker.types.SwarmSpec` instance that can be used + as the ``swarm_spec`` argument in :py:meth:`~docker.api.swarm.SwarmApiMixin.init_swarm`. Args: @@ -29,13 +29,25 @@ class SwarmApiMixin(object): dispatcher_heartbeat_period (int): The delay for an agent to send a heartbeat to the dispatcher. node_cert_expiry (int): Automatic expiry for nodes certificates. - external_ca (dict): Configuration for forwarding signing requests - to an external certificate authority. Use - ``docker.types.SwarmExternalCA``. + external_cas (:py:class:`list`): Configuration for forwarding + signing requests to an external certificate authority. Use + a list of :py:class:`docker.types.SwarmExternalCA`. name (string): Swarm's name + labels (dict): User-defined key/value metadata. + signing_ca_cert (str): The desired signing CA certificate for all + swarm node TLS leaf certificates, in PEM format. + signing_ca_key (str): The desired signing CA key for all swarm + node TLS leaf certificates, in PEM format. + ca_force_rotate (int): An integer whose purpose is to force swarm + to generate a new signing CA certificate and key, if none have + been specified. + autolock_managers (boolean): If set, generate a key and use it to + lock data stored on the managers. + log_driver (DriverConfig): The default log driver to use for tasks + created in the orchestrator. Returns: - ``docker.types.SwarmSpec`` instance. + :py:class:`docker.types.SwarmSpec` Raises: :py:class:`docker.errors.APIError` @@ -51,7 +63,10 @@ class SwarmApiMixin(object): force_new_cluster=False, swarm_spec=spec ) """ - return types.SwarmSpec(*args, **kwargs) + ext_ca = kwargs.pop('external_ca', None) + if ext_ca: + kwargs['external_cas'] = [ext_ca] + return types.SwarmSpec(self._version, *args, **kwargs) @utils.minimum_version('1.24') def init_swarm(self, advertise_addr=None, listen_addr='0.0.0.0:2377', diff --git a/docker/models/swarm.py b/docker/models/swarm.py index df3afd36..5a253c57 100644 --- a/docker/models/swarm.py +++ b/docker/models/swarm.py @@ -1,6 +1,5 @@ from docker.api import APIClient from docker.errors import APIError -from docker.types import SwarmSpec from .resource import Model @@ -72,6 +71,18 @@ class Swarm(Model): to an external certificate authority. Use ``docker.types.SwarmExternalCA``. name (string): Swarm's name + labels (dict): User-defined key/value metadata. + signing_ca_cert (str): The desired signing CA certificate for all + swarm node TLS leaf certificates, in PEM format. + signing_ca_key (str): The desired signing CA key for all swarm + node TLS leaf certificates, in PEM format. + ca_force_rotate (int): An integer whose purpose is to force swarm + to generate a new signing CA certificate and key, if none have + been specified. + autolock_managers (boolean): If set, generate a key and use it to + lock data stored on the managers. + log_driver (DriverConfig): The default log driver to use for tasks + created in the orchestrator. Returns: ``True`` if the request went through. @@ -94,7 +105,7 @@ class Swarm(Model): 'listen_addr': listen_addr, 'force_new_cluster': force_new_cluster } - init_kwargs['swarm_spec'] = SwarmSpec(**kwargs) + init_kwargs['swarm_spec'] = self.client.api.create_swarm_spec(**kwargs) self.client.api.init_swarm(**init_kwargs) self.reload() @@ -143,7 +154,7 @@ class Swarm(Model): return self.client.api.update_swarm( version=self.version, - swarm_spec=SwarmSpec(**kwargs), + swarm_spec=self.client.api.create_swarm_spec(**kwargs), rotate_worker_token=rotate_worker_token, rotate_manager_token=rotate_manager_token ) diff --git a/docker/types/swarm.py b/docker/types/swarm.py index 49beaa11..9687a82d 100644 --- a/docker/types/swarm.py +++ b/docker/types/swarm.py @@ -1,9 +1,21 @@ +from ..errors import InvalidVersion +from ..utils import version_lt + + class SwarmSpec(dict): - def __init__(self, task_history_retention_limit=None, + """ + Describe a Swarm's configuration and options. Use + :py:meth:`~docker.api.swarm.SwarmApiMixin.create_swarm_spec` + to instantiate. + """ + def __init__(self, version, task_history_retention_limit=None, snapshot_interval=None, keep_old_snapshots=None, log_entries_for_slow_followers=None, heartbeat_tick=None, election_tick=None, dispatcher_heartbeat_period=None, - node_cert_expiry=None, external_ca=None, name=None): + node_cert_expiry=None, external_cas=None, name=None, + labels=None, signing_ca_cert=None, signing_ca_key=None, + ca_force_rotate=None, autolock_managers=None, + log_driver=None): if task_history_retention_limit is not None: self['Orchestration'] = { 'TaskHistoryRetentionLimit': task_history_retention_limit @@ -26,18 +38,82 @@ class SwarmSpec(dict): 'HeartbeatPeriod': dispatcher_heartbeat_period } - if node_cert_expiry or external_ca: - self['CAConfig'] = { - 'NodeCertExpiry': node_cert_expiry, - 'ExternalCA': external_ca - } + ca_config = {} + if node_cert_expiry is not None: + ca_config['NodeCertExpiry'] = node_cert_expiry + if external_cas: + if version_lt(version, '1.25'): + if len(external_cas) > 1: + raise InvalidVersion( + 'Support for multiple external CAs is not available ' + 'for API version < 1.25' + ) + ca_config['ExternalCA'] = external_cas[0] + else: + ca_config['ExternalCAs'] = external_cas + if signing_ca_key: + if version_lt(version, '1.30'): + raise InvalidVersion( + 'signing_ca_key is not supported in API version < 1.30' + ) + ca_config['SigningCAKey'] = signing_ca_key + if signing_ca_cert: + if version_lt(version, '1.30'): + raise InvalidVersion( + 'signing_ca_cert is not supported in API version < 1.30' + ) + ca_config['SigningCACert'] = signing_ca_cert + if ca_force_rotate is not None: + if version_lt(version, '1.30'): + raise InvalidVersion( + 'force_rotate is not supported in API version < 1.30' + ) + ca_config['ForceRotate'] = ca_force_rotate + if ca_config: + self['CAConfig'] = ca_config + + if autolock_managers is not None: + if version_lt(version, '1.25'): + raise InvalidVersion( + 'autolock_managers is not supported in API version < 1.25' + ) + + self['EncryptionConfig'] = {'AutoLockManagers': autolock_managers} + + if log_driver is not None: + if version_lt(version, '1.25'): + raise InvalidVersion( + 'log_driver is not supported in API version < 1.25' + ) + + self['TaskDefaults'] = {'LogDriver': log_driver} if name is not None: self['Name'] = name + if labels is not None: + self['Labels'] = labels class SwarmExternalCA(dict): - def __init__(self, url, protocol=None, options=None): + """ + Configuration for forwarding signing requests to an external + certificate authority. + + Args: + url (string): URL where certificate signing requests should be + sent. + protocol (string): Protocol for communication with the external CA. + options (dict): An object with key/value pairs that are interpreted + as protocol-specific options for the external CA driver. + ca_cert (string): The root CA certificate (in PEM format) this + external CA uses to issue TLS certificates (assumed to be to + the current swarm root CA certificate if not provided). + + + + """ + def __init__(self, url, protocol=None, options=None, ca_cert=None): self['URL'] = url self['Protocol'] = protocol self['Options'] = options + self['CACert'] = ca_cert diff --git a/docs/api.rst b/docs/api.rst index 18993ad3..ff466a17 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -147,5 +147,7 @@ Configuration types .. autoclass:: RestartPolicy .. autoclass:: SecretReference .. autoclass:: ServiceMode +.. autoclass:: SwarmExternalCA +.. autoclass:: SwarmSpec(*args, **kwargs) .. autoclass:: TaskTemplate .. autoclass:: UpdateConfig diff --git a/tests/integration/api_swarm_test.py b/tests/integration/api_swarm_test.py index 666c689f..34b0879c 100644 --- a/tests/integration/api_swarm_test.py +++ b/tests/integration/api_swarm_test.py @@ -45,6 +45,44 @@ class SwarmTest(BaseAPIIntegrationTest): assert swarm_info['Spec']['Raft']['SnapshotInterval'] == 5000 assert swarm_info['Spec']['Raft']['LogEntriesForSlowFollowers'] == 1200 + @requires_api_version('1.30') + def test_init_swarm_with_ca_config(self): + spec = self.client.create_swarm_spec( + node_cert_expiry=7776000000000000, ca_force_rotate=6000000000000 + ) + + assert self.init_swarm(swarm_spec=spec) + swarm_info = self.client.inspect_swarm() + assert swarm_info['Spec']['CAConfig']['NodeCertExpiry'] == ( + spec['CAConfig']['NodeCertExpiry'] + ) + assert swarm_info['Spec']['CAConfig']['ForceRotate'] == ( + spec['CAConfig']['ForceRotate'] + ) + + @requires_api_version('1.25') + def test_init_swarm_with_autolock_managers(self): + spec = self.client.create_swarm_spec(autolock_managers=True) + assert self.init_swarm(swarm_spec=spec) + swarm_info = self.client.inspect_swarm() + + assert ( + swarm_info['Spec']['EncryptionConfig']['AutoLockManagers'] is True + ) + + @requires_api_version('1.25') + @pytest.mark.xfail( + reason="This doesn't seem to be taken into account by the engine" + ) + def test_init_swarm_with_log_driver(self): + spec = {'TaskDefaults': {'LogDriver': {'Name': 'syslog'}}} + assert self.init_swarm(swarm_spec=spec) + swarm_info = self.client.inspect_swarm() + + assert swarm_info['Spec']['TaskDefaults']['LogDriver']['Name'] == ( + 'syslog' + ) + @requires_api_version('1.24') def test_leave_swarm(self): assert self.init_swarm() From af0071403cb348c3dd5c253457078806303efec4 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 6 Nov 2017 16:04:00 -0800 Subject: [PATCH 30/34] Add support for insert_defaults in inspect_service Signed-off-by: Joffrey F --- docker/api/service.py | 16 +++++++++++++--- docker/models/services.py | 11 +++++++++-- tests/integration/api_service_test.py | 11 +++++++++++ 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/docker/api/service.py b/docker/api/service.py index e6b48768..4c10ef8e 100644 --- a/docker/api/service.py +++ b/docker/api/service.py @@ -136,12 +136,14 @@ class ServiceApiMixin(object): @utils.minimum_version('1.24') @utils.check_resource('service') - def inspect_service(self, service): + def inspect_service(self, service, insert_defaults=None): """ Return information about a service. Args: - service (str): Service name or ID + service (str): Service name or ID. + insert_defaults (boolean): If true, default values will be merged + into the service inspect output. Returns: ``True`` if successful. @@ -151,7 +153,15 @@ class ServiceApiMixin(object): If the server returns an error. """ url = self._url('/services/{0}', service) - return self._result(self._get(url), True) + params = {} + if insert_defaults is not None: + if utils.version_lt(self._version, '1.29'): + raise errors.InvalidVersion( + 'insert_defaults is not supported in API version < 1.29' + ) + params['insertDefaults'] = insert_defaults + + return self._result(self._get(url, params=params), True) @utils.minimum_version('1.24') @utils.check_resource('task') diff --git a/docker/models/services.py b/docker/models/services.py index f2a5d355..6fc5c2a5 100644 --- a/docker/models/services.py +++ b/docker/models/services.py @@ -177,12 +177,14 @@ class ServiceCollection(Collection): service_id = self.client.api.create_service(**create_kwargs) return self.get(service_id) - def get(self, service_id): + def get(self, service_id, insert_defaults=None): """ Get a service. Args: service_id (str): The ID of the service. + insert_defaults (boolean): If true, default values will be merged + into the output. Returns: (:py:class:`Service`): The service. @@ -192,8 +194,13 @@ class ServiceCollection(Collection): If the service does not exist. :py:class:`docker.errors.APIError` If the server returns an error. + :py:class:`docker.errors.InvalidVersion` + If one of the arguments is not supported with the current + API version. """ - return self.prepare_model(self.client.api.inspect_service(service_id)) + return self.prepare_model( + self.client.api.inspect_service(service_id, insert_defaults) + ) def list(self, **kwargs): """ diff --git a/tests/integration/api_service_test.py b/tests/integration/api_service_test.py index 8c6d4af5..b9311549 100644 --- a/tests/integration/api_service_test.py +++ b/tests/integration/api_service_test.py @@ -99,6 +99,17 @@ class ServiceTest(BaseAPIIntegrationTest): assert 'ID' in svc_info assert svc_info['ID'] == svc_id['ID'] + @requires_api_version('1.29') + def test_inspect_service_insert_defaults(self): + svc_name, svc_id = self.create_simple_service() + svc_info = self.client.inspect_service(svc_id) + svc_info_defaults = self.client.inspect_service( + svc_id, insert_defaults=True + ) + assert svc_info != svc_info_defaults + assert 'RollbackConfig' in svc_info_defaults['Spec'] + assert 'RollbackConfig' not in svc_info['Spec'] + def test_remove_service_by_id(self): svc_name, svc_id = self.create_simple_service() assert self.client.remove_service(svc_id) From 34709689372a090f6135d9d179eeff0e86d528e9 Mon Sep 17 00:00:00 2001 From: Martin Monperrus Date: Mon, 2 Oct 2017 09:40:21 +0200 Subject: [PATCH 31/34] explain the socket parameter of exec_run Signed-off-by: Martin Monperrus --- docker/models/containers.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docker/models/containers.py b/docker/models/containers.py index ea8c10b5..689d8ddc 100644 --- a/docker/models/containers.py +++ b/docker/models/containers.py @@ -142,14 +142,16 @@ class Container(Model): detach (bool): If true, detach from the exec command. Default: False stream (bool): Stream response data. Default: False + socket (bool): Whether to return a socket object or not. Default: False environment (dict or list): A dictionary or a list of strings in the following format ``["PASSWORD=xxx"]`` or ``{"PASSWORD": "xxx"}``. Returns: - (generator or str): If ``stream=True``, a generator yielding - response chunks. A string containing response data otherwise. - + (generator or str): + If ``stream=True``, a generator yielding response chunks. + If ``socket=True``, a socket object of the connection (an SSL wrapped socket for TLS-based docker, on which one must call ``sendall`` and ``recv`` -- and **not** os.read / os.write). + A string containing response data otherwise. Raises: :py:class:`docker.errors.APIError` If the server returns an error. From fe6c9a64b04f6ea4d440998debcf3d0739832be4 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 6 Nov 2017 17:04:27 -0800 Subject: [PATCH 32/34] Style fixes. Copied docs to APIClient as well Signed-off-by: Joffrey F --- docker/api/exec_api.py | 5 ++++- docker/models/containers.py | 7 ++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docker/api/exec_api.py b/docker/api/exec_api.py index 6f42524e..cff5cfa7 100644 --- a/docker/api/exec_api.py +++ b/docker/api/exec_api.py @@ -122,10 +122,13 @@ class ExecApiMixin(object): Default: False tty (bool): Allocate a pseudo-TTY. Default: False stream (bool): Stream response data. Default: False + socket (bool): Return the connection socket to allow custom + read/write operations. Returns: (generator or str): If ``stream=True``, a generator yielding - response chunks. A string containing response data otherwise. + response chunks. If ``socket=True``, a socket object for the + connection. A string containing response data otherwise. Raises: :py:class:`docker.errors.APIError` diff --git a/docker/models/containers.py b/docker/models/containers.py index 689d8ddc..97a08b9d 100644 --- a/docker/models/containers.py +++ b/docker/models/containers.py @@ -142,15 +142,16 @@ class Container(Model): detach (bool): If true, detach from the exec command. Default: False stream (bool): Stream response data. Default: False - socket (bool): Whether to return a socket object or not. Default: False + socket (bool): Return the connection socket to allow custom + read/write operations. Default: False environment (dict or list): A dictionary or a list of strings in the following format ``["PASSWORD=xxx"]`` or ``{"PASSWORD": "xxx"}``. Returns: - (generator or str): + (generator or str): If ``stream=True``, a generator yielding response chunks. - If ``socket=True``, a socket object of the connection (an SSL wrapped socket for TLS-based docker, on which one must call ``sendall`` and ``recv`` -- and **not** os.read / os.write). + If ``socket=True``, a socket object for the connection. A string containing response data otherwise. Raises: :py:class:`docker.errors.APIError` From 3bad05136a6366c4e4a80bc13a79250fd7ca2657 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 6 Nov 2017 19:13:19 -0800 Subject: [PATCH 33/34] Bump 2.6.0 Signed-off-by: Joffrey F --- docker/version.py | 2 +- docs/change-log.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/docker/version.py b/docker/version.py index 273270d5..bdf13464 100644 --- a/docker/version.py +++ b/docker/version.py @@ -1,2 +1,2 @@ -version = "2.5.1" +version = "2.6.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 9fe15e19..ca19981f 100644 --- a/docs/change-log.md +++ b/docs/change-log.md @@ -1,6 +1,50 @@ Change log ========== +2.6.0 +----- + +[List of PRs / issues for this release](https://github.com/docker/docker-py/milestone/34?closed=1) + +### Features + +* Added support for `mounts` in `APIClient.create_host_config` and + `DockerClient.containers.run` +* Added support for `consistency`, `tmpfs_size` and `tmpfs_mode` when + creating mount objects. +* `Mount` objects now support the `tmpfs` and `npipe` types. +* Added support for `extra_hosts` in the `build` methods. +* Added support for the configs API: + * In `APIClient`: `create_config`, `inspect_config`, `remove_config`, + `configs` + * In `DockerClient`: `configs.create`, `configs.get`, `configs.list` and + the `Config` model. + * Added `configs` parameter to `ContainerSpec`. Each item in the `configs` + list must be a `docker.types.ConfigReference` instance. +* Added support for the following parameters when creating a `ContainerSpec` + object: `groups`, `open_stdin`, `read_only`, `stop_signal`, `helathcheck`, + `hosts`, `ns_config`, `configs`, `privileges`. +* Added the following configuration classes to `docker.types`: + `ConfigReference`, `DNSConfig`, `Privileges`, `SwarmExternalCA`. +* Added support for `driver` in `APIClient.create_secret` and + `DockerClient.secrets.create`. +* Added support for `scope` in `APIClient.inspect_network` and + `APIClient.create_network`, and their `DockerClient` equivalent. +* Added support for the following parameters to `create_swarm_spec`: + `external_cas`, `labels`, `signing_ca_cert`, `signing_ca_key`, + `ca_force_rotate`, `autolock_managers`, `log_driver`. These additions + also apply to `DockerClient.swarm.init`. +* Added support for `insert_defaults` in `APIClient.inspect_service` and + `DockerClient.services.get`. + +### Bugfixes + +* Fixed a bug where reading a 0-length frame in log streams would incorrectly + interrupt streaming. +* Fixed a bug where the `id` member on `Swarm` objects wasn't being populated. +* Fixed a bug that would cause some data at the beginning of an upgraded + connection stream (`attach`, `exec_run`) to disappear. + 2.5.1 ----- From 65ba043d158792ea7a596f306293a6503cc12e9a Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 25 Oct 2017 15:14:19 -0700 Subject: [PATCH 34/34] Update test engine versions in Jenkinsfile Signed-off-by: Joffrey F Conflicts: Jenkinsfile --- Jenkinsfile | 4 ++-- Makefile | 4 ++-- tests/integration/api_build_test.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 178653a8..e3168cd7 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -5,7 +5,7 @@ def imageNamePy2 def imageNamePy3 def images = [:] -def dockerVersions = ["1.13.1", "17.04.0-ce", "17.05.0-ce", "17.06.0-ce", "17.07.0-ce-rc3"] +def dockerVersions = ["17.06.2-ce", "17.09.0-ce", "17.10.0-ce"] def buildImage = { name, buildargs, pyTag -> img = docker.image(name) @@ -33,7 +33,7 @@ def buildImages = { -> } def getAPIVersion = { engineVersion -> - def versionMap = ['1.13.': '1.26', '17.04': '1.27', '17.05': '1.29', '17.06': '1.30', '17.07': '1.31'] + def versionMap = ['17.06': '1.30', '17.09': '1.32', '17.10': '1.33'] return versionMap[engineVersion.substring(0, 5)] } diff --git a/Makefile b/Makefile index efa4232e..32ef5106 100644 --- a/Makefile +++ b/Makefile @@ -41,8 +41,8 @@ integration-test: build integration-test-py3: build-py3 docker run -t --rm -v /var/run/docker.sock:/var/run/docker.sock docker-sdk-python3 py.test tests/integration/${file} -TEST_API_VERSION ?= 1.30 -TEST_ENGINE_VERSION ?= 17.06.0-ce +TEST_API_VERSION ?= 1.33 +TEST_ENGINE_VERSION ?= 17.10.0-ce .PHONY: integration-dind integration-dind: build build-py3 diff --git a/tests/integration/api_build_test.py b/tests/integration/api_build_test.py index 7a0e6b16..8e98cc9f 100644 --- a/tests/integration/api_build_test.py +++ b/tests/integration/api_build_test.py @@ -210,7 +210,7 @@ class BuildTest(BaseAPIIntegrationTest): pass info = self.client.inspect_image('build1') - self.assertEqual(info['Config']['OnBuild'], []) + assert not info['Config']['OnBuild'] @requires_api_version('1.25') def test_build_with_network_mode(self):