From 52c3d528f64dbc9bd155ae9bc74b454f842761c5 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 5 Mar 2018 15:15:37 -0800 Subject: [PATCH 01/23] Bump 3.1.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 3429f284..fedd9ed7 100644 --- a/docker/version.py +++ b/docker/version.py @@ -1,2 +1,2 @@ -version = "3.2.0-dev" +version = "3.1.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 ceab083e..94c325d6 100644 --- a/docs/change-log.md +++ b/docs/change-log.md @@ -1,6 +1,18 @@ Change log ========== +3.1.1 +----- + +[List of PRs / issues for this release](https://github.com/docker/docker-py/milestone/46?closed=1) + +### Bugfixes + +* Fixed a bug that caused costly DNS lookups on Mac OSX when connecting to the + engine through UNIX socket +* Fixed a bug that caused `.dockerignore` comments to be read as exclusion + patterns + 3.1.0 ----- From 9c2b4e12f87bdec349caf85d97625bd93de1e027 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 14 Mar 2018 14:11:42 -0700 Subject: [PATCH 02/23] Use same split rules for Dockerfile as other include/exclude patterns Signed-off-by: Joffrey F --- docker/utils/build.py | 7 +++++-- tests/unit/utils_test.py | 5 +++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/docker/utils/build.py b/docker/utils/build.py index 1da56fbc..1622ec35 100644 --- a/docker/utils/build.py +++ b/docker/utils/build.py @@ -31,18 +31,21 @@ def exclude_paths(root, patterns, dockerfile=None): if dockerfile is None: dockerfile = 'Dockerfile' + def split_path(p): + return [pt for pt in re.split(_SEP, p) if pt and pt != '.'] + def normalize(p): # Leading and trailing slashes are not relevant. Yes, # "foo.py/" must exclude the "foo.py" regular file. "." # components are not relevant either, even if the whole # pattern is only ".", as the Docker reference states: "For # historical reasons, the pattern . is ignored." - split = [pt for pt in re.split(_SEP, p) if pt and pt != '.'] # ".." component must be cleared with the potential previous # component, regardless of whether it exists: "A preprocessing # step [...] eliminates . and .. elements using Go's # filepath.". i = 0 + split = split_path(p) while i < len(split): if split[i] == '..': del split[i] @@ -62,7 +65,7 @@ def exclude_paths(root, patterns, dockerfile=None): # Exclude empty patterns such as "." or the empty string. filter(lambda p: p[1], patterns), # Always include the Dockerfile and .dockerignore - [(True, dockerfile.split('/')), (True, ['.dockerignore'])])))) + [(True, split_path(dockerfile)), (True, ['.dockerignore'])])))) return set(walk(root, patterns)) diff --git a/tests/unit/utils_test.py b/tests/unit/utils_test.py index c2dd502b..56800f9e 100644 --- a/tests/unit/utils_test.py +++ b/tests/unit/utils_test.py @@ -698,6 +698,11 @@ class ExcludePathsTest(unittest.TestCase): ['*'], dockerfile='foo/Dockerfile3' ) == convert_paths(set(['foo/Dockerfile3', '.dockerignore'])) + # https://github.com/docker/docker-py/issues/1956 + assert self.exclude( + ['*'], dockerfile='./foo/Dockerfile3' + ) == convert_paths(set(['foo/Dockerfile3', '.dockerignore'])) + def test_exclude_dockerfile_child(self): includes = self.exclude(['foo/'], dockerfile='foo/Dockerfile3') assert convert_path('foo/Dockerfile3') in includes From cf1d869105641095406db7bf2e5e0e2c1a9bb014 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 15 Mar 2018 15:01:13 +0100 Subject: [PATCH 03/23] Updates docs for rename of `name` to `repository` Signed-off-by: James Meakin --- 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 58d5d93c..d4c28132 100644 --- a/docker/models/images.py +++ b/docker/models/images.py @@ -284,7 +284,7 @@ class ImageCollection(Collection): low-level API. Args: - name (str): The repository to pull + repository (str): The repository to pull tag (str): The tag to pull auth_config (dict): Override the credentials that :py:meth:`~docker.client.DockerClient.login` has set for From ffdc0487f5d2f20066c18e29edf2931ca66385fb Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 15 Mar 2018 14:36:39 -0700 Subject: [PATCH 04/23] Fix socket tests for TLS-enabled tests Signed-off-by: Joffrey F --- tests/helpers.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/helpers.py b/tests/helpers.py index c4ea3647..b6b493b3 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -108,21 +108,21 @@ def swarm_listen_addr(): def assert_cat_socket_detached_with_keys(sock, inputs): - if six.PY3: + if six.PY3 and hasattr(sock, '_sock'): sock = sock._sock for i in inputs: - sock.send(i) + sock.sendall(i) time.sleep(0.5) # If we're using a Unix socket, the sock.send call will fail with a # BrokenPipeError ; INET sockets will just stop receiving / sending data # but will not raise an error - if sock.family == getattr(socket, 'AF_UNIX', -1): + if getattr(sock, 'family', -9) == getattr(socket, 'AF_UNIX', -1): with pytest.raises(socket.error): - sock.send(b'make sure the socket is closed\n') + sock.sendall(b'make sure the socket is closed\n') else: - sock.send(b"make sure the socket is closed\n") + sock.sendall(b"make sure the socket is closed\n") assert sock.recv(32) == b'' From 3f3ca7ed431b18332967cf9d3f0ffce098016011 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 15 Mar 2018 14:37:02 -0700 Subject: [PATCH 05/23] Use networks instead of legacy links for test setup Signed-off-by: Joffrey F --- Jenkinsfile | 19 ++++++++++++++----- Makefile | 36 +++++++++++++++++++++--------------- 2 files changed, 35 insertions(+), 20 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 6d9d3436..fe684197 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -5,7 +5,12 @@ def imageNamePy2 def imageNamePy3 def images = [:] -def dockerVersions = ["17.06.2-ce", "17.12.0-ce", "18.01.0-ce"] +def dockerVersions = [ + "17.06.2-ce", // Latest EE + "17.12.1-ce", // Latest CE stable + "18.02.0-ce", // Latest CE edge + "18.03.0-ce-rc4" // Latest CE RC +] def buildImage = { name, buildargs, pyTag -> img = docker.image(name) @@ -59,15 +64,18 @@ def runTests = { Map settings -> checkout(scm) def dindContainerName = "dpy-dind-\$BUILD_NUMBER-\$EXECUTOR_NUMBER-${pythonVersion}-${dockerVersion}" def testContainerName = "dpy-tests-\$BUILD_NUMBER-\$EXECUTOR_NUMBER-${pythonVersion}-${dockerVersion}" + def testNetwork = "dpy-testnet-\$BUILD_NUMBER-\$EXECUTOR_NUMBER-${pythonVersion}-${dockerVersion}" try { - sh """docker run -d --name ${dindContainerName} -v /tmp --privileged \\ + sh """docker network create ${testNetwork}""" + sh """docker run -d --name ${dindContainerName} -v /tmp --privileged --network ${testNetwork} \\ dockerswarm/dind:${dockerVersion} dockerd -H tcp://0.0.0.0:2375 """ sh """docker run \\ - --name ${testContainerName} --volumes-from ${dindContainerName} \\ - -e 'DOCKER_HOST=tcp://docker:2375' \\ + --name ${testContainerName} \\ + -e "DOCKER_HOST=tcp://${dindContainerName}:2375" \\ -e 'DOCKER_TEST_API_VERSION=${apiVersion}' \\ - --link=${dindContainerName}:docker \\ + --network ${testNetwork} \\ + --volumes-from ${dindContainerName} \\ ${testImage} \\ py.test -v -rxs tests/integration """ @@ -75,6 +83,7 @@ def runTests = { Map settings -> sh """ docker stop ${dindContainerName} ${testContainerName} docker rm -vf ${dindContainerName} ${testContainerName} + docker network rm ${testNetwork} """ } } diff --git a/Makefile b/Makefile index f4919939..434d40e1 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ all: test .PHONY: clean clean: - -docker rm -f dpy-dind-py2 dpy-dind-py3 + -docker rm -f dpy-dind-py2 dpy-dind-py3 dpy-dind-certs dpy-dind-ssl find -name "__pycache__" | xargs rm -rf .PHONY: build @@ -44,41 +44,47 @@ integration-test-py3: build-py3 TEST_API_VERSION ?= 1.35 TEST_ENGINE_VERSION ?= 17.12.0-ce +.PHONY: setup-network +setup-network: + docker network inspect dpy-tests || docker network create dpy-tests + .PHONY: integration-dind integration-dind: integration-dind-py2 integration-dind-py3 .PHONY: integration-dind-py2 -integration-dind-py2: build +integration-dind-py2: build setup-network docker rm -vf dpy-dind-py2 || : - docker run -d --name dpy-dind-py2 --privileged dockerswarm/dind:${TEST_ENGINE_VERSION} dockerd\ - -H tcp://0.0.0.0:2375 --experimental - docker run -t --rm --env="DOCKER_HOST=tcp://docker:2375" --env="DOCKER_TEST_API_VERSION=${TEST_API_VERSION}"\ - --link=dpy-dind-py2:docker docker-sdk-python py.test tests/integration + docker run -d --network dpy-tests --name dpy-dind-py2 --privileged\ + dockerswarm/dind:${TEST_ENGINE_VERSION} dockerd -H tcp://0.0.0.0:2375 --experimental + docker run -t --rm --env="DOCKER_HOST=tcp://dpy-dind-py2:2375" --env="DOCKER_TEST_API_VERSION=${TEST_API_VERSION}"\ + --network dpy-tests docker-sdk-python py.test tests/integration docker rm -vf dpy-dind-py2 .PHONY: integration-dind-py3 -integration-dind-py3: build-py3 +integration-dind-py3: build-py3 setup-network docker rm -vf dpy-dind-py3 || : - docker run -d --name dpy-dind-py3 --privileged dockerswarm/dind:${TEST_ENGINE_VERSION} dockerd\ - -H tcp://0.0.0.0:2375 --experimental - docker run -t --rm --env="DOCKER_HOST=tcp://docker:2375" --env="DOCKER_TEST_API_VERSION=${TEST_API_VERSION}"\ - --link=dpy-dind-py3:docker docker-sdk-python3 py.test tests/integration + docker run -d --network dpy-tests --name dpy-dind-py3 --privileged\ + dockerswarm/dind:${TEST_ENGINE_VERSION} dockerd -H tcp://0.0.0.0:2375 --experimental + docker run -t --rm --env="DOCKER_HOST=tcp://dpy-dind-py3:2375" --env="DOCKER_TEST_API_VERSION=${TEST_API_VERSION}"\ + --network dpy-tests docker-sdk-python3 py.test tests/integration docker rm -vf dpy-dind-py3 .PHONY: integration-dind-ssl integration-dind-ssl: build-dind-certs build build-py3 + docker rm -vf dpy-dind-certs dpy-dind-ssl || : 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} dockerd --tlsverify\ - --tlscacert=/certs/ca.pem --tlscert=/certs/server-cert.pem\ + --network dpy-tests --network-alias docker -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 -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 + --network dpy-tests docker-sdk-python py.test tests/integration 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 + --network dpy-tests docker-sdk-python3 py.test tests/integration docker rm -vf dpy-dind-ssl dpy-dind-certs .PHONY: flake8 From 791de789ecb9219fa9a8fa9f241213866ee7b7e1 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 20 Mar 2018 09:55:05 +0100 Subject: [PATCH 06/23] Bump 3.1.2 Signed-off-by: Joffrey F --- docker/version.py | 2 +- docs/change-log.md | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/docker/version.py b/docker/version.py index fedd9ed7..0ce64351 100644 --- a/docker/version.py +++ b/docker/version.py @@ -1,2 +1,2 @@ -version = "3.1.1" +version = "3.1.2" 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 94c325d6..e92632b5 100644 --- a/docs/change-log.md +++ b/docs/change-log.md @@ -1,6 +1,16 @@ Change log ========== +3.1.2 +----- + +[List of PRs / issues for this release](https://github.com/docker/docker-py/milestone/47?closed=1) + +### Bugfixes + +* Fixed a bug that led to a Dockerfile not being included in the build context + in some situations when the Dockerfile's path was prefixed with `./` + 3.1.1 ----- From 88b0d697aa5386c2ef90a5b480cd400ce5a32646 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 5 Mar 2018 15:26:59 -0800 Subject: [PATCH 07/23] Bump test engine versions Signed-off-by: Joffrey F --- Jenkinsfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Jenkinsfile b/Jenkinsfile index fe684197..1323f4b8 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -38,7 +38,7 @@ def buildImages = { -> } def getAPIVersion = { engineVersion -> - def versionMap = ['17.06': '1.30', '17.12': '1.35', '18.01': '1.35'] + def versionMap = ['17.06': '1.30', '17.12': '1.35', '18.02': '1.36', '18.03': '1.37'] return versionMap[engineVersion.substring(0, 5)] } From af674155b78eaf1d014853d9dfcf728b22f1302b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 20 Mar 2018 11:57:10 +0100 Subject: [PATCH 08/23] Bump 3.1.3 Signed-off-by: Joffrey F --- docker/version.py | 2 +- docs/change-log.md | 7 +++++++ scripts/release.sh | 6 ++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/docker/version.py b/docker/version.py index 0ce64351..060797bf 100644 --- a/docker/version.py +++ b/docker/version.py @@ -1,2 +1,2 @@ -version = "3.1.2" +version = "3.1.3" 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 e92632b5..27885b2a 100644 --- a/docs/change-log.md +++ b/docs/change-log.md @@ -1,6 +1,13 @@ Change log ========== +3.1.3 +----- + +### Bugfixes + +* Regenerated invalid wheel package + 3.1.2 ----- diff --git a/scripts/release.sh b/scripts/release.sh index 814185bd..f36efff9 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -18,6 +18,9 @@ if [ -z $VERSION ]; then exit 1 fi +echo "##> Removing stale build files" +rm -rf ./build || exit 1 + echo "##> Tagging the release as $VERSION" git tag $VERSION || exit 1 if [[ $2 == 'upload' ]]; then @@ -30,4 +33,7 @@ pandoc -f markdown -t rst README.md -o README.rst || exit 1 if [[ $2 == 'upload' ]]; then echo "##> Uploading sdist to pypi" python setup.py sdist bdist_wheel upload +else + echo "##> sdist & wheel" + python setup.py sdist bdist_wheel fi From a9ecb7234f476989bf28db4f15a5d1d4e47734e6 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 21 Mar 2018 13:54:43 +0100 Subject: [PATCH 09/23] Don't descend into symlinks when building context tar Signed-off-by: Joffrey F --- docker/utils/build.py | 2 +- tests/unit/utils_test.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/docker/utils/build.py b/docker/utils/build.py index 1622ec35..894b2993 100644 --- a/docker/utils/build.py +++ b/docker/utils/build.py @@ -93,7 +93,7 @@ def walk(root, patterns, default=True): # Whether this file is implicitely included / excluded. matched = default if hit is None else hit sub = list(filter(lambda p: p[1], sub)) - if os.path.isdir(cur): + if os.path.isdir(cur) and not os.path.islink(cur): # Entirely skip directories if there are no chance any subfile will # be included. if all(not p[0] for p in sub) and not matched: diff --git a/tests/unit/utils_test.py b/tests/unit/utils_test.py index 56800f9e..00456e8c 100644 --- a/tests/unit/utils_test.py +++ b/tests/unit/utils_test.py @@ -1058,6 +1058,21 @@ class TarTest(unittest.TestCase): assert tar_data.getnames() == ['th.txt'] assert tar_data.getmember('th.txt').mtime == -3600 + @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='No symlinks on Windows') + def test_tar_directory_link(self): + dirs = ['a', 'b', 'a/c'] + files = ['a/hello.py', 'b/utils.py', 'a/c/descend.py'] + base = make_tree(dirs, files) + self.addCleanup(shutil.rmtree, base) + os.symlink(os.path.join(base, 'b'), os.path.join(base, 'a/c/b')) + with tar(base) as archive: + tar_data = tarfile.open(fileobj=archive) + names = tar_data.getnames() + for member in dirs + files: + assert member in names + assert 'a/c/b' in names + assert 'a/c/b/utils.py' not in names + class FormatEnvironmentTest(unittest.TestCase): def test_format_env_binary_unicode_value(self): From ea682a69d6c71721f441018fe429e4f1b83ceabf Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 21 Mar 2018 14:23:23 +0100 Subject: [PATCH 10/23] Bump 3.1.4 Signed-off-by: Joffrey F --- docker/version.py | 2 +- docs/change-log.md | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docker/version.py b/docker/version.py index 060797bf..0233d237 100644 --- a/docker/version.py +++ b/docker/version.py @@ -1,2 +1,2 @@ -version = "3.1.3" +version = "3.1.4" 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 27885b2a..908519fb 100644 --- a/docs/change-log.md +++ b/docs/change-log.md @@ -1,6 +1,15 @@ Change log ========== +3.1.4 +----- + +[List of PRs / issues for this release](https://github.com/docker/docker-py/milestone/48?closed=1) + + +* Fixed a bug where build contexts containing directory symlinks would produce + invalid tar archives + 3.1.3 ----- From 35520ab01fc49052f1ccf145933e89a8c368a364 Mon Sep 17 00:00:00 2001 From: Matthieu Nottale Date: Wed, 14 Mar 2018 14:14:20 +0100 Subject: [PATCH 11/23] Add close() method to DockerClient. Signed-off-by: Matthieu Nottale --- docker/client.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docker/client.py b/docker/client.py index 467583e6..b4364c3c 100644 --- a/docker/client.py +++ b/docker/client.py @@ -186,6 +186,10 @@ class DockerClient(object): return self.api.version(*args, **kwargs) version.__doc__ = APIClient.version.__doc__ + def close(self): + return self.api.close() + close.__doc__ = APIClient.close.__doc__ + def __getattr__(self, name): s = ["'DockerClient' object has no attribute '{}'".format(name)] # If a user calls a method on APIClient, they From 726d7f31cabebbd545337733a38b79bf36d2846b Mon Sep 17 00:00:00 2001 From: Matthieu Nottale Date: Wed, 14 Mar 2018 15:30:30 +0100 Subject: [PATCH 12/23] Add sparse argument to DockerClient.containers.list(). Signed-off-by: Matthieu Nottale --- docker/models/containers.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docker/models/containers.py b/docker/models/containers.py index 895080ca..d4ed1aa3 100644 --- a/docker/models/containers.py +++ b/docker/models/containers.py @@ -833,7 +833,8 @@ class ContainerCollection(Collection): resp = self.client.api.inspect_container(container_id) return self.prepare_model(resp) - def list(self, all=False, before=None, filters=None, limit=-1, since=None): + def list(self, all=False, before=None, filters=None, limit=-1, since=None, + sparse=False): """ List containers. Similar to the ``docker ps`` command. @@ -862,6 +863,9 @@ class ContainerCollection(Collection): container. Give the container name or id. - `since` (str): Only containers created after a particular container. Give container name or id. + sparse (bool): Do not inspect containers. Returns partial + informations, but guaranteed not to block. Use reload() on + each container to get the full list of attributes. A comprehensive list can be found in the documentation for `docker ps @@ -877,7 +881,10 @@ class ContainerCollection(Collection): resp = self.client.api.containers(all=all, before=before, filters=filters, limit=limit, since=since) - return [self.get(r['Id']) for r in resp] + if sparse: + return [self.prepare_model(r) for r in resp] + else: + return [self.get(r['Id']) for r in resp] def prune(self, filters=None): return self.client.api.prune_containers(filters=filters) From d310d95fbcdd6ae20d37ca676cb8ece23c3805cc Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 14 Mar 2018 16:53:56 -0700 Subject: [PATCH 13/23] Add test for container list with sparse=True Signed-off-by: Joffrey F --- docker/models/containers.py | 30 ++++++++++++++------- tests/integration/models_containers_test.py | 22 +++++++++++++++ 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/docker/models/containers.py b/docker/models/containers.py index d4ed1aa3..1e06ed60 100644 --- a/docker/models/containers.py +++ b/docker/models/containers.py @@ -4,8 +4,10 @@ from collections import namedtuple from ..api import APIClient from ..constants import DEFAULT_DATA_CHUNK_SIZE -from ..errors import (ContainerError, ImageNotFound, - create_unexpected_kwargs_error) +from ..errors import ( + ContainerError, DockerException, ImageNotFound, + create_unexpected_kwargs_error +) from ..types import HostConfig from ..utils import version_gte from .images import Image @@ -27,7 +29,7 @@ class Container(Model): """ The image of the container. """ - image_id = self.attrs['Image'] + image_id = self.attrs.get('ImageID', self.attrs['Image']) if image_id is None: return None return self.client.images.get(image_id.split(':')[1]) @@ -37,15 +39,23 @@ class Container(Model): """ The labels of a container as dictionary. """ - result = self.attrs['Config'].get('Labels') - return result or {} + try: + result = self.attrs['Config'].get('Labels') + return result or {} + except KeyError: + raise DockerException( + 'Label data is not available for sparse objects. Call reload()' + ' to retrieve all information' + ) @property def status(self): """ The status of the container. For example, ``running``, or ``exited``. """ - return self.attrs['State']['Status'] + if isinstance(self.attrs['State'], dict): + return self.attrs['State']['Status'] + return self.attrs['State'] def attach(self, **kwargs): """ @@ -863,14 +873,16 @@ class ContainerCollection(Collection): container. Give the container name or id. - `since` (str): Only containers created after a particular container. Give container name or id. - sparse (bool): Do not inspect containers. Returns partial - informations, but guaranteed not to block. Use reload() on - each container to get the full list of attributes. A comprehensive list can be found in the documentation for `docker ps `_. + sparse (bool): Do not inspect containers. Returns partial + information, but guaranteed not to block. Use + :py:meth:`Container.reload` on resulting objects to retrieve + all attributes. Default: ``False`` + Returns: (list of :py:class:`Container`) diff --git a/tests/integration/models_containers_test.py b/tests/integration/models_containers_test.py index fac4de2b..38aae4d2 100644 --- a/tests/integration/models_containers_test.py +++ b/tests/integration/models_containers_test.py @@ -159,6 +159,28 @@ class ContainerCollectionTest(BaseIntegrationTest): container = containers[0] assert container.attrs['Config']['Image'] == 'alpine' + assert container.status == 'running' + assert container.image == client.images.get('alpine') + + container.kill() + container.remove() + assert container_id not in [c.id for c in client.containers.list()] + + def test_list_sparse(self): + client = docker.from_env(version=TEST_API_VERSION) + container_id = client.containers.run( + "alpine", "sleep 300", detach=True).id + self.tmp_containers.append(container_id) + containers = [c for c in client.containers.list(sparse=True) if c.id == + container_id] + assert len(containers) == 1 + + container = containers[0] + assert container.attrs['Image'] == 'alpine' + assert container.status == 'running' + assert container.image == client.images.get('alpine') + with pytest.raises(docker.errors.DockerException): + container.labels container.kill() container.remove() From dd743db4b390b71ef86e4d23c618db2e7204e135 Mon Sep 17 00:00:00 2001 From: Viktor Adam Date: Wed, 21 Feb 2018 22:16:21 +0000 Subject: [PATCH 14/23] Allow cancelling the streams from other threads Signed-off-by: Viktor Adam --- docker/api/container.py | 17 +++++- docker/api/daemon.py | 21 ++++--- docker/types/__init__.py | 1 + docker/types/daemon.py | 63 +++++++++++++++++++++ tests/integration/api_container_test.py | 48 ++++++++++++++++ tests/integration/client_test.py | 20 +++++++ tests/integration/models_containers_test.py | 21 +++++++ 7 files changed, 181 insertions(+), 10 deletions(-) create mode 100644 docker/types/daemon.py diff --git a/docker/api/container.py b/docker/api/container.py index f8d52de4..cb97b794 100644 --- a/docker/api/container.py +++ b/docker/api/container.py @@ -5,7 +5,8 @@ from .. import errors from .. import utils from ..constants import DEFAULT_DATA_CHUNK_SIZE from ..types import ( - ContainerConfig, EndpointConfig, HostConfig, NetworkingConfig + CancellableStream, ContainerConfig, EndpointConfig, HostConfig, + NetworkingConfig ) @@ -52,10 +53,15 @@ class ContainerApiMixin(object): u = self._url("/containers/{0}/attach", container) response = self._post(u, headers=headers, params=params, stream=True) - return self._read_from_socket( + output = self._read_from_socket( response, stream, self._check_is_tty(container) ) + if stream: + return CancellableStream(output, response) + else: + return output + @utils.check_resource('container') def attach_socket(self, container, params=None, ws=False): """ @@ -815,7 +821,12 @@ class ContainerApiMixin(object): url = self._url("/containers/{0}/logs", container) res = self._get(url, params=params, stream=stream) - return self._get_result(container, stream, res) + output = self._get_result(container, stream, res) + + if stream: + return CancellableStream(output, res) + else: + return output @utils.check_resource('container') def pause(self, container): diff --git a/docker/api/daemon.py b/docker/api/daemon.py index 0e1c7538..fc3692c2 100644 --- a/docker/api/daemon.py +++ b/docker/api/daemon.py @@ -1,7 +1,7 @@ import os from datetime import datetime -from .. import auth, utils +from .. import auth, types, utils class DaemonApiMixin(object): @@ -34,8 +34,7 @@ class DaemonApiMixin(object): the fly. False by default. Returns: - (generator): A blocking generator you can iterate over to retrieve - events as they happen. + A :py:class:`docker.types.daemon.CancellableStream` generator Raises: :py:class:`docker.errors.APIError` @@ -50,6 +49,14 @@ class DaemonApiMixin(object): u'status': u'start', u'time': 1423339459} ... + + or + + >>> events = client.events() + >>> for event in events: + ... print event + >>> # and cancel from another thread + >>> events.close() """ if isinstance(since, datetime): @@ -68,10 +75,10 @@ class DaemonApiMixin(object): } url = self._url('/events') - return self._stream_helper( - self._get(url, params=params, stream=True, timeout=None), - decode=decode - ) + response = self._get(url, params=params, stream=True, timeout=None) + stream = self._stream_helper(response, decode=decode) + + return types.CancellableStream(stream, response) def info(self): """ diff --git a/docker/types/__init__.py b/docker/types/__init__.py index 39c93e34..0b0d847f 100644 --- a/docker/types/__init__.py +++ b/docker/types/__init__.py @@ -1,5 +1,6 @@ # flake8: noqa from .containers import ContainerConfig, HostConfig, LogConfig, Ulimit +from .daemon import CancellableStream from .healthcheck import Healthcheck from .networks import EndpointConfig, IPAMConfig, IPAMPool, NetworkingConfig from .services import ( diff --git a/docker/types/daemon.py b/docker/types/daemon.py new file mode 100644 index 00000000..ba0334d0 --- /dev/null +++ b/docker/types/daemon.py @@ -0,0 +1,63 @@ +import socket + +try: + import requests.packages.urllib3 as urllib3 +except ImportError: + import urllib3 + + +class CancellableStream(object): + """ + Stream wrapper for real-time events, logs, etc. from the server. + + Example: + >>> events = client.events() + >>> for event in events: + ... print event + >>> # and cancel from another thread + >>> events.close() + """ + + def __init__(self, stream, response): + self._stream = stream + self._response = response + + def __iter__(self): + return self + + def __next__(self): + try: + return next(self._stream) + except urllib3.exceptions.ProtocolError: + raise StopIteration + except socket.error: + raise StopIteration + + next = __next__ + + def close(self): + """ + Closes the event streaming. + """ + + if not self._response.raw.closed: + # find the underlying socket object + # based on api.client._get_raw_response_socket + + sock_fp = self._response.raw._fp.fp + + if hasattr(sock_fp, 'raw'): + sock_raw = sock_fp.raw + + if hasattr(sock_raw, 'sock'): + sock = sock_raw.sock + + elif hasattr(sock_raw, '_sock'): + sock = sock_raw._sock + + else: + sock = sock_fp._sock + + sock.shutdown(socket.SHUT_RDWR) + sock.makefile().close() + sock.close() diff --git a/tests/integration/api_container_test.py b/tests/integration/api_container_test.py index 8447aa5f..cc2c0719 100644 --- a/tests/integration/api_container_test.py +++ b/tests/integration/api_container_test.py @@ -2,6 +2,7 @@ import os import re import signal import tempfile +import threading from datetime import datetime import docker @@ -880,6 +881,30 @@ Line2''' assert logs == (snippet + '\n').encode(encoding='ascii') + def test_logs_streaming_and_follow_and_cancel(self): + snippet = 'Flowering Nights (Sakuya Iyazoi)' + container = self.client.create_container( + BUSYBOX, 'sh -c "echo \\"{0}\\" && sleep 3"'.format(snippet) + ) + id = container['Id'] + self.tmp_containers.append(id) + self.client.start(id) + logs = six.binary_type() + + generator = self.client.logs(id, stream=True, follow=True) + + exit_timer = threading.Timer(3, os._exit, args=[1]) + exit_timer.start() + + threading.Timer(1, generator.close).start() + + for chunk in generator: + logs += chunk + + exit_timer.cancel() + + assert logs == (snippet + '\n').encode(encoding='ascii') + def test_logs_with_dict_instead_of_id(self): snippet = 'Flowering Nights (Sakuya Iyazoi)' container = self.client.create_container( @@ -1226,6 +1251,29 @@ class AttachContainerTest(BaseAPIIntegrationTest): output = self.client.attach(container, stream=False, logs=True) assert output == 'hello\n'.encode(encoding='ascii') + def test_attach_stream_and_cancel(self): + container = self.client.create_container( + BUSYBOX, 'sh -c "echo hello && sleep 60"', + tty=True + ) + self.tmp_containers.append(container) + self.client.start(container) + output = self.client.attach(container, stream=True, logs=True) + + exit_timer = threading.Timer(3, os._exit, args=[1]) + exit_timer.start() + + threading.Timer(1, output.close).start() + + lines = [] + for line in output: + lines.append(line) + + exit_timer.cancel() + + assert len(lines) == 1 + assert lines[0] == 'hello\r\n'.encode(encoding='ascii') + def test_detach_with_default(self): container = self.client.create_container( BUSYBOX, 'cat', diff --git a/tests/integration/client_test.py b/tests/integration/client_test.py index 8f6bd86b..7df172c8 100644 --- a/tests/integration/client_test.py +++ b/tests/integration/client_test.py @@ -1,7 +1,10 @@ +import threading import unittest import docker +from datetime import datetime, timedelta + from ..helpers import requires_api_version from .base import TEST_API_VERSION @@ -27,3 +30,20 @@ class ClientTest(unittest.TestCase): assert 'Containers' in data assert 'Volumes' in data assert 'Images' in data + + +class CancellableEventsTest(unittest.TestCase): + client = docker.from_env(version=TEST_API_VERSION) + + def test_cancel_events(self): + start = datetime.now() + + events = self.client.events(until=start + timedelta(seconds=5)) + + cancel_thread = threading.Timer(2, events.close) + cancel_thread.start() + + for _ in events: + pass + + self.assertLess(datetime.now() - start, timedelta(seconds=3)) diff --git a/tests/integration/models_containers_test.py b/tests/integration/models_containers_test.py index 38aae4d2..41faff35 100644 --- a/tests/integration/models_containers_test.py +++ b/tests/integration/models_containers_test.py @@ -1,4 +1,6 @@ +import os import tempfile +import threading import docker import pytest @@ -141,6 +143,25 @@ class ContainerCollectionTest(BaseIntegrationTest): assert logs[0] == b'hello\n' assert logs[1] == b'world\n' + def test_run_with_streamed_logs_and_cancel(self): + client = docker.from_env(version=TEST_API_VERSION) + out = client.containers.run( + 'alpine', 'sh -c "echo hello && echo world"', stream=True + ) + + exit_timer = threading.Timer(3, os._exit, args=[1]) + exit_timer.start() + + threading.Timer(1, out.close).start() + + logs = [line for line in out] + + exit_timer.cancel() + + assert len(logs) == 2 + assert logs[0] == b'hello\n' + assert logs[1] == b'world\n' + def test_get(self): client = docker.from_env(version=TEST_API_VERSION) container = client.containers.run("alpine", "sleep 300", detach=True) From e9f31e1e277a39eb275cdd3cc29a42adf0bc5094 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 19 Mar 2018 14:40:49 +0100 Subject: [PATCH 15/23] Remove redundant single-socket select call Clean up + use pytest-timeout Signed-off-by: Joffrey F --- docker/types/daemon.py | 1 - docker/utils/socket.py | 3 +-- test-requirements.txt | 5 +++-- tests/integration/api_container_test.py | 13 ++----------- tests/integration/models_containers_test.py | 7 +------ 5 files changed, 7 insertions(+), 22 deletions(-) diff --git a/docker/types/daemon.py b/docker/types/daemon.py index ba0334d0..852f3d82 100644 --- a/docker/types/daemon.py +++ b/docker/types/daemon.py @@ -59,5 +59,4 @@ class CancellableStream(object): sock = sock_fp._sock sock.shutdown(socket.SHUT_RDWR) - sock.makefile().close() sock.close() diff --git a/docker/utils/socket.py b/docker/utils/socket.py index c3a5f90f..0945f0a6 100644 --- a/docker/utils/socket.py +++ b/docker/utils/socket.py @@ -22,8 +22,7 @@ def read(socket, n=4096): recoverable_errors = (errno.EINTR, errno.EDEADLK, errno.EWOULDBLOCK) - # wait for data to become available - if not isinstance(socket, NpipeSocket): + if six.PY3 and not isinstance(socket, NpipeSocket): select.select([socket], [], []) try: diff --git a/test-requirements.txt b/test-requirements.txt index f79e8159..09680b68 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,5 +1,6 @@ +coverage==3.7.1 +flake8==3.4.1 mock==1.0.1 pytest==2.9.1 -coverage==3.7.1 pytest-cov==2.1.0 -flake8==3.4.1 +pytest-timeout==1.2.1 diff --git a/tests/integration/api_container_test.py b/tests/integration/api_container_test.py index cc2c0719..e2125186 100644 --- a/tests/integration/api_container_test.py +++ b/tests/integration/api_container_test.py @@ -881,6 +881,7 @@ Line2''' assert logs == (snippet + '\n').encode(encoding='ascii') + @pytest.mark.timeout(5) def test_logs_streaming_and_follow_and_cancel(self): snippet = 'Flowering Nights (Sakuya Iyazoi)' container = self.client.create_container( @@ -892,17 +893,11 @@ Line2''' logs = six.binary_type() generator = self.client.logs(id, stream=True, follow=True) - - exit_timer = threading.Timer(3, os._exit, args=[1]) - exit_timer.start() - threading.Timer(1, generator.close).start() for chunk in generator: logs += chunk - exit_timer.cancel() - assert logs == (snippet + '\n').encode(encoding='ascii') def test_logs_with_dict_instead_of_id(self): @@ -1251,6 +1246,7 @@ class AttachContainerTest(BaseAPIIntegrationTest): output = self.client.attach(container, stream=False, logs=True) assert output == 'hello\n'.encode(encoding='ascii') + @pytest.mark.timeout(5) def test_attach_stream_and_cancel(self): container = self.client.create_container( BUSYBOX, 'sh -c "echo hello && sleep 60"', @@ -1260,17 +1256,12 @@ class AttachContainerTest(BaseAPIIntegrationTest): self.client.start(container) output = self.client.attach(container, stream=True, logs=True) - exit_timer = threading.Timer(3, os._exit, args=[1]) - exit_timer.start() - threading.Timer(1, output.close).start() lines = [] for line in output: lines.append(line) - exit_timer.cancel() - assert len(lines) == 1 assert lines[0] == 'hello\r\n'.encode(encoding='ascii') diff --git a/tests/integration/models_containers_test.py b/tests/integration/models_containers_test.py index 41faff35..6ddb034b 100644 --- a/tests/integration/models_containers_test.py +++ b/tests/integration/models_containers_test.py @@ -1,4 +1,3 @@ -import os import tempfile import threading @@ -143,21 +142,17 @@ class ContainerCollectionTest(BaseIntegrationTest): assert logs[0] == b'hello\n' assert logs[1] == b'world\n' + @pytest.mark.timeout(5) def test_run_with_streamed_logs_and_cancel(self): client = docker.from_env(version=TEST_API_VERSION) out = client.containers.run( 'alpine', 'sh -c "echo hello && echo world"', stream=True ) - exit_timer = threading.Timer(3, os._exit, args=[1]) - exit_timer.start() - threading.Timer(1, out.close).start() logs = [line for line in out] - exit_timer.cancel() - assert len(logs) == 2 assert logs[0] == b'hello\n' assert logs[1] == b'world\n' From 73a9003758d60226879f06c630c01389f3dd0fe7 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 22 Mar 2018 13:10:35 +0100 Subject: [PATCH 16/23] Generate test engines list dynamically Signed-off-by: Joffrey F --- Jenkinsfile | 29 +++++++++++++----- scripts/versions.py | 71 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 8 deletions(-) create mode 100644 scripts/versions.py diff --git a/Jenkinsfile b/Jenkinsfile index 1323f4b8..211159bc 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -5,13 +5,6 @@ def imageNamePy2 def imageNamePy3 def images = [:] -def dockerVersions = [ - "17.06.2-ce", // Latest EE - "17.12.1-ce", // Latest CE stable - "18.02.0-ce", // Latest CE edge - "18.03.0-ce-rc4" // Latest CE RC -] - def buildImage = { name, buildargs, pyTag -> img = docker.image(name) try { @@ -37,9 +30,27 @@ def buildImages = { -> } } +def getDockerVersions = { -> + def dockerVersions = ["17.06.2-ce"] + wrappedNode(label: "ubuntu && !zfs") { + def result = sh(script: """docker run --rm \\ + --entrypoint=python \\ + ${imageNamePy3} \\ + /src/scripts/versions.py + """, returnStdout: true + ) + dockerVersions = dockerVersions + result.trim().tokenize(' ') + } + return dockerVersions +} + def getAPIVersion = { engineVersion -> def versionMap = ['17.06': '1.30', '17.12': '1.35', '18.02': '1.36', '18.03': '1.37'] - return versionMap[engineVersion.substring(0, 5)] + def result = versionMap[engineVersion.substring(0, 5)] + if (!result) { + return '1.37' + } + return result } def runTests = { Map settings -> @@ -94,6 +105,8 @@ def runTests = { Map settings -> buildImages() +def dockerVersions = getDockerVersions() + def testMatrix = [failFast: false] for (imgKey in new ArrayList(images.keySet())) { diff --git a/scripts/versions.py b/scripts/versions.py new file mode 100644 index 00000000..77aaf4f1 --- /dev/null +++ b/scripts/versions.py @@ -0,0 +1,71 @@ +import operator +import re +from collections import namedtuple + +import requests + +base_url = 'https://download.docker.com/linux/static/{0}/x86_64/' +categories = [ + 'edge', + 'stable', + 'test' +] + + +class Version(namedtuple('_Version', 'major minor patch rc edition')): + + @classmethod + def parse(cls, version): + edition = None + version = version.lstrip('v') + version, _, rc = version.partition('-') + if rc: + if 'rc' not in rc: + edition = rc + rc = None + elif '-' in rc: + edition, rc = rc.split('-') + + major, minor, patch = version.split('.', 3) + return cls(major, minor, patch, rc, edition) + + @property + def major_minor(self): + return self.major, self.minor + + @property + def order(self): + """Return a representation that allows this object to be sorted + correctly with the default comparator. + """ + # rc releases should appear before official releases + rc = (0, self.rc) if self.rc else (1, ) + return (int(self.major), int(self.minor), int(self.patch)) + rc + + def __str__(self): + rc = '-{}'.format(self.rc) if self.rc else '' + edition = '-{}'.format(self.edition) if self.edition else '' + return '.'.join(map(str, self[:3])) + edition + rc + + +def main(): + results = set() + for url in [base_url.format(cat) for cat in categories]: + res = requests.get(url) + content = res.text + versions = [ + Version.parse( + v.strip('"').lstrip('docker-').rstrip('.tgz').rstrip('-x86_64') + ) for v in re.findall( + r'"docker-[0-9]+\.[0-9]+\.[0-9]+-.*tgz"', content + ) + ] + sorted_versions = sorted( + versions, reverse=True, key=operator.attrgetter('order') + ) + latest = sorted_versions[0] + results.add(str(latest)) + print(' '.join(results)) + +if __name__ == '__main__': + main() From 27322fede7fcba1e81e447722d8708de9dfc7406 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 22 Mar 2018 09:51:10 +0100 Subject: [PATCH 17/23] Add isolation param to build Signed-off-by: Joffrey F --- docker/api/build.py | 11 ++++++++++- docker/models/images.py | 2 ++ tests/integration/api_build_test.py | 15 +++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/docker/api/build.py b/docker/api/build.py index e136a6ee..3067c105 100644 --- a/docker/api/build.py +++ b/docker/api/build.py @@ -18,7 +18,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, extra_hosts=None, platform=None): + squash=None, extra_hosts=None, platform=None, isolation=None): """ Similar to the ``docker build`` command. Either ``path`` or ``fileobj`` needs to be set. ``path`` can be a local path (to a directory @@ -100,6 +100,8 @@ class BuildApiMixin(object): extra_hosts (dict): Extra hosts to add to /etc/hosts in building containers, as a mapping of hostname to IP address. platform (str): Platform in the format ``os[/arch[/variant]]`` + isolation (str): Isolation technology used during build. + Default: `None`. Returns: A generator for the build output. @@ -232,6 +234,13 @@ class BuildApiMixin(object): ) params['platform'] = platform + if isolation is not None: + if utils.version_lt(self._version, '1.24'): + raise errors.InvalidVersion( + 'isolation was only introduced in API version 1.24' + ) + params['isolation'] = isolation + 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 d4c28132..bb24eb5c 100644 --- a/docker/models/images.py +++ b/docker/models/images.py @@ -164,6 +164,8 @@ class ImageCollection(Collection): extra_hosts (dict): Extra hosts to add to /etc/hosts in building containers, as a mapping of hostname to IP address. platform (str): Platform in the format ``os[/arch[/variant]]``. + isolation (str): Isolation technology used during build. + Default: `None`. Returns: (tuple): The first item is the :py:class:`Image` object for the diff --git a/tests/integration/api_build_test.py b/tests/integration/api_build_test.py index ce587d54..13bd8ac5 100644 --- a/tests/integration/api_build_test.py +++ b/tests/integration/api_build_test.py @@ -138,6 +138,21 @@ class BuildTest(BaseAPIIntegrationTest): # There is currently no way to get the shmsize # that was used to build the image + @requires_api_version('1.24') + def test_build_isolation(self): + script = io.BytesIO('\n'.join([ + 'FROM scratch', + 'CMD sh -c "echo \'Deaf To All But The Song\'' + ]).encode('ascii')) + + stream = self.client.build( + fileobj=script, tag='isolation', + isolation='default' + ) + + for chunk in stream: + pass + @requires_api_version('1.23') def test_build_labels(self): script = io.BytesIO('\n'.join([ From 20939d06819447ad8a745e5d5c0b4bfc41f9792b Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 26 Mar 2018 14:12:42 -0700 Subject: [PATCH 18/23] Update MAINTAINERS file Signed-off-by: Joffrey F --- MAINTAINERS | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/MAINTAINERS b/MAINTAINERS index 76aafd88..b857d13d 100644 --- a/MAINTAINERS +++ b/MAINTAINERS @@ -10,13 +10,16 @@ # [Org] [Org."Core maintainers"] + people = [ + "shin-", + ] + [Org.Alumni] people = [ "aanand", "bfirsh", "dnephin", "mnowster", "mpetazzoni", - "shin-", ] [people] From 77c3e57dcfa5d5e8cf05038861b2037dafe2d0e6 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 26 Mar 2018 13:36:45 -0700 Subject: [PATCH 19/23] Support building with Dockerfile outside of context Signed-off-by: Joffrey F --- docker/api/build.py | 10 +++++++++ docker/utils/build.py | 22 +++++++++++++------ docker/utils/utils.py | 12 ++++++++++- tests/integration/api_build_test.py | 33 +++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 7 deletions(-) diff --git a/docker/api/build.py b/docker/api/build.py index 3067c105..2a227591 100644 --- a/docker/api/build.py +++ b/docker/api/build.py @@ -1,6 +1,7 @@ import json import logging import os +import random from .. import auth from .. import constants @@ -148,6 +149,15 @@ class BuildApiMixin(object): lambda x: x != '' and x[0] != '#', [l.strip() for l in f.read().splitlines()] )) + if dockerfile and os.path.relpath(dockerfile, path).startswith( + '..'): + with open(dockerfile, 'r') as df: + dockerfile = ( + '.dockerfile.{0:x}'.format(random.getrandbits(160)), + df.read() + ) + else: + dockerfile = (dockerfile, None) context = utils.tar( path, exclude=exclude, dockerfile=dockerfile, gzip=gzip ) diff --git a/docker/utils/build.py b/docker/utils/build.py index 894b2993..0f173476 100644 --- a/docker/utils/build.py +++ b/docker/utils/build.py @@ -2,23 +2,33 @@ import os import re from ..constants import IS_WINDOWS_PLATFORM +from .utils import create_archive from fnmatch import fnmatch from itertools import chain -from .utils import create_archive + + +_SEP = re.compile('/|\\\\') if IS_WINDOWS_PLATFORM else re.compile('/') def tar(path, exclude=None, dockerfile=None, fileobj=None, gzip=False): root = os.path.abspath(path) exclude = exclude or [] + dockerfile = dockerfile or (None, None) + extra_files = [] + if dockerfile[1] is not None: + dockerignore_contents = '\n'.join( + (exclude or ['.dockerignore']) + [dockerfile[0]] + ) + extra_files = [ + ('.dockerignore', dockerignore_contents), + dockerfile, + ] return create_archive( - files=sorted(exclude_paths(root, exclude, dockerfile=dockerfile)), - root=root, fileobj=fileobj, gzip=gzip + files=sorted(exclude_paths(root, exclude, dockerfile=dockerfile[0])), + root=root, fileobj=fileobj, gzip=gzip, extra_files=extra_files ) -_SEP = re.compile('/|\\\\') if IS_WINDOWS_PLATFORM else re.compile('/') - - def exclude_paths(root, patterns, dockerfile=None): """ Given a root directory path and a list of .dockerignore patterns, return diff --git a/docker/utils/utils.py b/docker/utils/utils.py index 3cd2be81..5024e471 100644 --- a/docker/utils/utils.py +++ b/docker/utils/utils.py @@ -88,13 +88,17 @@ def build_file_list(root): return files -def create_archive(root, files=None, fileobj=None, gzip=False): +def create_archive(root, files=None, fileobj=None, gzip=False, + extra_files=None): if not fileobj: fileobj = tempfile.NamedTemporaryFile() t = tarfile.open(mode='w:gz' if gzip else 'w', fileobj=fileobj) if files is None: files = build_file_list(root) for path in files: + if path in [e[0] for e in extra_files]: + # Extra files override context files with the same name + continue full_path = os.path.join(root, path) i = t.gettarinfo(full_path, arcname=path) @@ -123,6 +127,12 @@ def create_archive(root, files=None, fileobj=None, gzip=False): else: # Directories, FIFOs, symlinks... don't need to be read. t.addfile(i, None) + + for name, contents in extra_files: + info = tarfile.TarInfo(name) + info.size = len(contents) + t.addfile(info, io.BytesIO(contents.encode('utf-8'))) + t.close() fileobj.seek(0) return fileobj diff --git a/tests/integration/api_build_test.py b/tests/integration/api_build_test.py index 13bd8ac5..f411efc4 100644 --- a/tests/integration/api_build_test.py +++ b/tests/integration/api_build_test.py @@ -407,3 +407,36 @@ class BuildTest(BaseAPIIntegrationTest): assert excinfo.value.status_code == 400 assert 'invalid platform' in excinfo.exconly() + + def test_build_out_of_context_dockerfile(self): + base_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base_dir) + with open(os.path.join(base_dir, 'file.txt'), 'w') as f: + f.write('hello world') + with open(os.path.join(base_dir, '.dockerignore'), 'w') as f: + f.write('.dockerignore\n') + df = tempfile.NamedTemporaryFile() + self.addCleanup(df.close) + df.write(('\n'.join([ + 'FROM busybox', + 'COPY . /src', + 'WORKDIR /src', + ])).encode('utf-8')) + df.flush() + img_name = random_name() + self.tmp_imgs.append(img_name) + stream = self.client.build( + path=base_dir, dockerfile=df.name, tag=img_name, + decode=True + ) + lines = [] + for chunk in stream: + lines.append(chunk) + assert 'Successfully tagged' in lines[-1]['stream'] + + ctnr = self.client.create_container(img_name, 'ls -a') + self.tmp_containers.append(ctnr) + self.client.start(ctnr) + lsdata = self.client.logs(ctnr).strip().split(b'\n') + assert len(lsdata) == 3 + assert sorted([b'.', b'..', b'file.txt']) == sorted(lsdata) From fce99c329fe4157bda209b5dfb44b0c2f8fe037e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 26 Mar 2018 13:38:13 -0700 Subject: [PATCH 20/23] Move build utils to appropriate file Signed-off-by: Joffrey F --- docker/utils/__init__.py | 6 +-- docker/utils/build.py | 91 +++++++++++++++++++++++++++++++++++++++- docker/utils/utils.py | 89 --------------------------------------- 3 files changed, 93 insertions(+), 93 deletions(-) diff --git a/docker/utils/__init__.py b/docker/utils/__init__.py index e70a5e61..81c8186c 100644 --- a/docker/utils/__init__.py +++ b/docker/utils/__init__.py @@ -1,13 +1,13 @@ # flake8: noqa -from .build import tar, exclude_paths +from .build import create_archive, exclude_paths, mkbuildcontext, tar from .decorators import check_resource, minimum_version, update_headers from .utils import ( compare_version, convert_port_bindings, convert_volume_binds, - mkbuildcontext, parse_repository_tag, parse_host, + parse_repository_tag, parse_host, kwargs_from_env, convert_filters, datetime_to_timestamp, create_host_config, parse_bytes, 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_extra_hosts + format_environment, format_extra_hosts ) diff --git a/docker/utils/build.py b/docker/utils/build.py index 0f173476..783273ee 100644 --- a/docker/utils/build.py +++ b/docker/utils/build.py @@ -1,8 +1,11 @@ +import io import os import re +import six +import tarfile +import tempfile from ..constants import IS_WINDOWS_PLATFORM -from .utils import create_archive from fnmatch import fnmatch from itertools import chain @@ -127,3 +130,89 @@ def walk(root, patterns, default=True): yield f elif matched: yield f + + +def build_file_list(root): + files = [] + for dirname, dirnames, fnames in os.walk(root): + for filename in fnames + dirnames: + longpath = os.path.join(dirname, filename) + files.append( + longpath.replace(root, '', 1).lstrip('/') + ) + + return files + + +def create_archive(root, files=None, fileobj=None, gzip=False, + extra_files=None): + extra_files = extra_files or [] + if not fileobj: + fileobj = tempfile.NamedTemporaryFile() + t = tarfile.open(mode='w:gz' if gzip else 'w', fileobj=fileobj) + if files is None: + files = build_file_list(root) + for path in files: + if path in [e[0] for e in extra_files]: + # Extra files override context files with the same name + continue + full_path = os.path.join(root, path) + + i = t.gettarinfo(full_path, arcname=path) + if i is None: + # This happens when we encounter a socket file. We can safely + # ignore it and proceed. + continue + + # Workaround https://bugs.python.org/issue32713 + if i.mtime < 0 or i.mtime > 8**11 - 1: + i.mtime = int(i.mtime) + + if IS_WINDOWS_PLATFORM: + # Windows doesn't keep track of the execute bit, so we make files + # and directories executable by default. + i.mode = i.mode & 0o755 | 0o111 + + if i.isfile(): + try: + with open(full_path, 'rb') as f: + t.addfile(i, f) + except IOError: + raise IOError( + 'Can not read file in context: {}'.format(full_path) + ) + else: + # Directories, FIFOs, symlinks... don't need to be read. + t.addfile(i, None) + + for name, contents in extra_files: + info = tarfile.TarInfo(name) + info.size = len(contents) + t.addfile(info, io.BytesIO(contents.encode('utf-8'))) + + t.close() + fileobj.seek(0) + return fileobj + + +def mkbuildcontext(dockerfile): + f = tempfile.NamedTemporaryFile() + t = tarfile.open(mode='w', fileobj=f) + if isinstance(dockerfile, io.StringIO): + dfinfo = tarfile.TarInfo('Dockerfile') + if six.PY3: + raise TypeError('Please use io.BytesIO to create in-memory ' + 'Dockerfiles with Python 3') + else: + dfinfo.size = len(dockerfile.getvalue()) + dockerfile.seek(0) + elif isinstance(dockerfile, io.BytesIO): + dfinfo = tarfile.TarInfo('Dockerfile') + dfinfo.size = len(dockerfile.getvalue()) + dockerfile.seek(0) + else: + dfinfo = t.gettarinfo(fileobj=dockerfile, arcname='Dockerfile') + t.addfile(dfinfo, dockerfile) + t.close() + f.seek(0) + return f diff --git a/docker/utils/utils.py b/docker/utils/utils.py index 5024e471..fe3b9a57 100644 --- a/docker/utils/utils.py +++ b/docker/utils/utils.py @@ -1,17 +1,13 @@ import base64 -import io import os import os.path import json import shlex -import tarfile -import tempfile from distutils.version import StrictVersion from datetime import datetime import six -from .. import constants from .. import errors from .. import tls @@ -46,29 +42,6 @@ def create_ipam_config(*args, **kwargs): ) -def mkbuildcontext(dockerfile): - f = tempfile.NamedTemporaryFile() - t = tarfile.open(mode='w', fileobj=f) - if isinstance(dockerfile, io.StringIO): - dfinfo = tarfile.TarInfo('Dockerfile') - if six.PY3: - raise TypeError('Please use io.BytesIO to create in-memory ' - 'Dockerfiles with Python 3') - else: - dfinfo.size = len(dockerfile.getvalue()) - dockerfile.seek(0) - elif isinstance(dockerfile, io.BytesIO): - dfinfo = tarfile.TarInfo('Dockerfile') - dfinfo.size = len(dockerfile.getvalue()) - dockerfile.seek(0) - else: - dfinfo = t.gettarinfo(fileobj=dockerfile, arcname='Dockerfile') - t.addfile(dfinfo, dockerfile) - t.close() - f.seek(0) - return f - - def decode_json_header(header): data = base64.b64decode(header) if six.PY3: @@ -76,68 +49,6 @@ def decode_json_header(header): return json.loads(data) -def build_file_list(root): - files = [] - for dirname, dirnames, fnames in os.walk(root): - for filename in fnames + dirnames: - longpath = os.path.join(dirname, filename) - files.append( - longpath.replace(root, '', 1).lstrip('/') - ) - - return files - - -def create_archive(root, files=None, fileobj=None, gzip=False, - extra_files=None): - if not fileobj: - fileobj = tempfile.NamedTemporaryFile() - t = tarfile.open(mode='w:gz' if gzip else 'w', fileobj=fileobj) - if files is None: - files = build_file_list(root) - for path in files: - if path in [e[0] for e in extra_files]: - # Extra files override context files with the same name - continue - full_path = os.path.join(root, path) - - i = t.gettarinfo(full_path, arcname=path) - if i is None: - # This happens when we encounter a socket file. We can safely - # ignore it and proceed. - continue - - # Workaround https://bugs.python.org/issue32713 - if i.mtime < 0 or i.mtime > 8**11 - 1: - i.mtime = int(i.mtime) - - if constants.IS_WINDOWS_PLATFORM: - # Windows doesn't keep track of the execute bit, so we make files - # and directories executable by default. - i.mode = i.mode & 0o755 | 0o111 - - if i.isfile(): - try: - with open(full_path, 'rb') as f: - t.addfile(i, f) - except IOError: - raise IOError( - 'Can not read file in context: {}'.format(full_path) - ) - else: - # Directories, FIFOs, symlinks... don't need to be read. - t.addfile(i, None) - - for name, contents in extra_files: - info = tarfile.TarInfo(name) - info.size = len(contents) - t.addfile(info, io.BytesIO(contents.encode('utf-8'))) - - t.close() - fileobj.seek(0) - return fileobj - - def compare_version(v1, v2): """Compare docker versions From f39c0dc18d820392e5e1b32e30bc0764bf8e0714 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 27 Mar 2018 10:22:17 -0700 Subject: [PATCH 21/23] Improve extra_files override check Signed-off-by: Joffrey F --- docker/utils/build.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker/utils/build.py b/docker/utils/build.py index 783273ee..b644c9fc 100644 --- a/docker/utils/build.py +++ b/docker/utils/build.py @@ -152,8 +152,9 @@ def create_archive(root, files=None, fileobj=None, gzip=False, t = tarfile.open(mode='w:gz' if gzip else 'w', fileobj=fileobj) if files is None: files = build_file_list(root) + extra_names = set(e[0] for e in extra_files) for path in files: - if path in [e[0] for e in extra_files]: + if path in extra_names: # Extra files override context files with the same name continue full_path = os.path.join(root, path) From bedabbfa35c36c5a29c8d4a8b888f398caa8e0e2 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 26 Mar 2018 19:01:50 -0700 Subject: [PATCH 22/23] Add methods for /distribution//json endpoint Signed-off-by: Joffrey F --- docker/api/image.py | 21 ++++++ docker/models/images.py | 107 +++++++++++++++++++++++++++- docs/images.rst | 19 +++++ tests/integration/api_image_test.py | 9 +++ 4 files changed, 155 insertions(+), 1 deletion(-) diff --git a/docker/api/image.py b/docker/api/image.py index 3ebca32e..5f05d887 100644 --- a/docker/api/image.py +++ b/docker/api/image.py @@ -245,6 +245,27 @@ class ImageApiMixin(object): self._get(self._url("/images/{0}/json", image)), True ) + @utils.minimum_version('1.30') + @utils.check_resource('image') + def inspect_distribution(self, image): + """ + Get image digest and platform information by contacting the registry. + + Args: + image (str): The image name to inspect + + Returns: + (dict): A dict containing distribution data + + Raises: + :py:class:`docker.errors.APIError` + If the server returns an error. + """ + + return self._result( + self._get(self._url("/distribution/{0}/json", image)), True + ) + def load_image(self, data, quiet=None): """ Load an image that was previously saved using diff --git a/docker/models/images.py b/docker/models/images.py index bb24eb5c..d4893bb6 100644 --- a/docker/models/images.py +++ b/docker/models/images.py @@ -5,7 +5,7 @@ import six from ..api import APIClient from ..constants import DEFAULT_DATA_CHUNK_SIZE -from ..errors import BuildError, ImageLoadError +from ..errors import BuildError, ImageLoadError, InvalidArgument from ..utils import parse_repository_tag from ..utils.json_stream import json_stream from .resource import Collection, Model @@ -105,6 +105,81 @@ class Image(Model): return self.client.api.tag(self.id, repository, tag=tag, **kwargs) +class RegistryData(Model): + """ + Image metadata stored on the registry, including available platforms. + """ + def __init__(self, image_name, *args, **kwargs): + super(RegistryData, self).__init__(*args, **kwargs) + self.image_name = image_name + + @property + def id(self): + """ + The ID of the object. + """ + return self.attrs['Descriptor']['digest'] + + @property + def short_id(self): + """ + The ID of the image truncated to 10 characters, plus the ``sha256:`` + prefix. + """ + return self.id[:17] + + def pull(self, platform=None): + """ + Pull the image digest. + + Args: + platform (str): The platform to pull the image for. + Default: ``None`` + + Returns: + (:py:class:`Image`): A reference to the pulled image. + """ + repository, _ = parse_repository_tag(self.image_name) + return self.collection.pull(repository, tag=self.id, platform=platform) + + def has_platform(self, platform): + """ + Check whether the given platform identifier is available for this + digest. + + Args: + platform (str or dict): A string using the ``os[/arch[/variant]]`` + format, or a platform dictionary. + + Returns: + (bool): ``True`` if the platform is recognized as available, + ``False`` otherwise. + + Raises: + :py:class:`docker.errors.InvalidArgument` + If the platform argument is not a valid descriptor. + """ + if platform and not isinstance(platform, dict): + parts = platform.split('/') + if len(parts) > 3 or len(parts) < 1: + raise InvalidArgument( + '"{0}" is not a valid platform descriptor'.format(platform) + ) + platform = {'os': parts[0]} + if len(parts) > 2: + platform['variant'] = parts[2] + if len(parts) > 1: + platform['architecture'] = parts[1] + return normalize_platform( + platform, self.client.version() + ) in self.attrs['Platforms'] + + def reload(self): + self.attrs = self.client.api.inspect_distribution(self.image_name) + + reload.__doc__ = Model.reload.__doc__ + + class ImageCollection(Collection): model = Image @@ -219,6 +294,26 @@ class ImageCollection(Collection): """ return self.prepare_model(self.client.api.inspect_image(name)) + def get_registry_data(self, name): + """ + Gets the registry data for an image. + + Args: + name (str): The name of the image. + + Returns: + (:py:class:`RegistryData`): The data object. + Raises: + :py:class:`docker.errors.APIError` + If the server returns an error. + """ + return RegistryData( + image_name=name, + attrs=self.client.api.inspect_distribution(name), + client=self.client, + collection=self, + ) + def list(self, name=None, all=False, filters=None): """ List images on the server. @@ -336,3 +431,13 @@ class ImageCollection(Collection): def prune(self, filters=None): return self.client.api.prune_images(filters=filters) prune.__doc__ = APIClient.prune_images.__doc__ + + +def normalize_platform(platform, engine_info): + if platform is None: + platform = {} + if 'os' not in platform: + platform['os'] = engine_info['Os'] + if 'architecture' not in platform: + platform['architecture'] = engine_info['Arch'] + return platform diff --git a/docs/images.rst b/docs/images.rst index 12b0fd18..4d425e95 100644 --- a/docs/images.rst +++ b/docs/images.rst @@ -12,6 +12,7 @@ Methods available on ``client.images``: .. automethod:: build .. automethod:: get + .. automethod:: get_registry_data .. automethod:: list(**kwargs) .. automethod:: load .. automethod:: prune @@ -41,3 +42,21 @@ Image objects .. automethod:: reload .. automethod:: save .. automethod:: tag + +RegistryData objects +-------------------- + +.. autoclass:: RegistryData() + + .. py:attribute:: attrs + + The raw representation of this object from the server. + + .. autoattribute:: id + .. autoattribute:: short_id + + + + .. automethod:: has_platform + .. automethod:: pull + .. automethod:: reload diff --git a/tests/integration/api_image_test.py b/tests/integration/api_image_test.py index ab638c9e..050e7f33 100644 --- a/tests/integration/api_image_test.py +++ b/tests/integration/api_image_test.py @@ -357,3 +357,12 @@ class SaveLoadImagesTest(BaseAPIIntegrationTest): success = True break assert success is True + + +@requires_api_version('1.30') +class InspectDistributionTest(BaseAPIIntegrationTest): + def test_inspect_distribution(self): + data = self.client.inspect_distribution('busybox:latest') + assert data is not None + assert 'Platforms' in data + assert {'os': 'linux', 'architecture': 'amd64'} in data['Platforms'] From 2ecc3adcd4b7edcaa1f8eb4aa288089feebba42e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 29 Mar 2018 15:58:42 -0700 Subject: [PATCH 23/23] Bump 3.2.0 Signed-off-by: Joffrey F --- docker/version.py | 2 +- docs/change-log.md | 19 +++++++++++++++++++ docs/client.rst | 1 + 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/docker/version.py b/docker/version.py index 0233d237..5460e165 100644 --- a/docker/version.py +++ b/docker/version.py @@ -1,2 +1,2 @@ -version = "3.1.4" +version = "3.2.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 908519fb..4715c524 100644 --- a/docs/change-log.md +++ b/docs/change-log.md @@ -1,11 +1,30 @@ Change log ========== +3.2.0 +----- + +[List of PRs/ issues for this release](https://github.com/docker/docker-py/milestone/45?closed=1) + +### Features + +* Generators returned by `attach()`, `logs()` and `events()` now have a + `cancel()` method to let consumers stop the iteration client-side. +* `build()` methods can now handle Dockerfiles supplied outside of the + build context. +* Added `sparse` argument to `DockerClient.containers.list()` +* Added `isolation` parameter to `build()` methods. +* Added `close()` method to `DockerClient` +* Added `APIClient.inspect_distribution()` method and + `DockerClient.images.get_registry_data()` + * The latter returns an instance of the new `RegistryData` class + 3.1.4 ----- [List of PRs / issues for this release](https://github.com/docker/docker-py/milestone/48?closed=1) +### Bugfixes * Fixed a bug where build contexts containing directory symlinks would produce invalid tar archives diff --git a/docs/client.rst b/docs/client.rst index 43d7c63b..85a1396f 100644 --- a/docs/client.rst +++ b/docs/client.rst @@ -26,6 +26,7 @@ Client reference .. autoattribute:: swarm .. autoattribute:: volumes + .. automethod:: close() .. automethod:: df() .. automethod:: events() .. automethod:: info()