From 5a85cad54785bae45787b2584476c286301329e2 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 18 Jun 2018 15:26:13 -0700 Subject: [PATCH 01/25] 3.5.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 c5043273..0866ca1c 100644 --- a/docker/version.py +++ b/docker/version.py @@ -1,2 +1,2 @@ -version = "3.4.0" +version = "3.5.0-dev" version_info = tuple([int(d) for d in version.split("-")[0].split(".")]) From 8c35eee0fba1ac403a7f498f76eb99d5ba71387d Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 26 Jun 2018 17:40:49 -0700 Subject: [PATCH 02/25] Fix support for legacy .dockercfg auth config format Signed-off-by: Joffrey F --- docker/api/container.py | 5 ++- docker/auth.py | 6 +-- tests/integration/api_client_test.py | 40 ----------------- tests/unit/auth_test.py | 66 +++++++++++++++++++++++----- 4 files changed, 60 insertions(+), 57 deletions(-) diff --git a/docker/api/container.py b/docker/api/container.py index 05676f11..d4f75f54 100644 --- a/docker/api/container.py +++ b/docker/api/container.py @@ -139,8 +139,9 @@ class ContainerApiMixin(object): 'changes': changes } u = self._url("/commit") - return self._result(self._post_json(u, data=conf, params=params), - json=True) + return self._result( + self._post_json(u, data=conf, params=params), json=True + ) def containers(self, quiet=False, all=False, trunc=False, latest=False, since=None, before=None, limit=-1, size=False, diff --git a/docker/auth.py b/docker/auth.py index 0c0cb204..9635f933 100644 --- a/docker/auth.py +++ b/docker/auth.py @@ -270,7 +270,7 @@ def load_config(config_path=None, config_dict=None): "Couldn't find auth-related section ; attempting to interpret" "as auth-only file" ) - return parse_auth(config_dict) + return {'auths': parse_auth(config_dict)} def _load_legacy_config(config_file): @@ -287,14 +287,14 @@ def _load_legacy_config(config_file): ) username, password = decode_auth(data[0]) - return { + return {'auths': { INDEX_NAME: { 'username': username, 'password': password, 'email': data[1], 'serveraddress': INDEX_URL, } - } + }} except Exception as e: log.debug(e) pass diff --git a/tests/integration/api_client_test.py b/tests/integration/api_client_test.py index 05281f88..905e0648 100644 --- a/tests/integration/api_client_test.py +++ b/tests/integration/api_client_test.py @@ -1,6 +1,3 @@ -import base64 -import os -import tempfile import time import unittest import warnings @@ -24,43 +21,6 @@ class InformationTest(BaseAPIIntegrationTest): assert 'Debug' in res -class LoadConfigTest(BaseAPIIntegrationTest): - def test_load_legacy_config(self): - folder = tempfile.mkdtemp() - self.tmp_folders.append(folder) - cfg_path = os.path.join(folder, '.dockercfg') - f = open(cfg_path, 'w') - auth_ = base64.b64encode(b'sakuya:izayoi').decode('ascii') - f.write('auth = {0}\n'.format(auth_)) - f.write('email = sakuya@scarlet.net') - f.close() - cfg = docker.auth.load_config(cfg_path) - assert cfg[docker.auth.INDEX_NAME] is not None - cfg = cfg[docker.auth.INDEX_NAME] - assert cfg['username'] == 'sakuya' - assert cfg['password'] == 'izayoi' - assert cfg['email'] == 'sakuya@scarlet.net' - assert cfg.get('Auth') is None - - def test_load_json_config(self): - folder = tempfile.mkdtemp() - self.tmp_folders.append(folder) - cfg_path = os.path.join(folder, '.dockercfg') - f = open(os.path.join(folder, '.dockercfg'), 'w') - auth_ = base64.b64encode(b'sakuya:izayoi').decode('ascii') - email_ = 'sakuya@scarlet.net' - f.write('{{"{0}": {{"auth": "{1}", "email": "{2}"}}}}\n'.format( - docker.auth.INDEX_URL, auth_, email_)) - f.close() - cfg = docker.auth.load_config(cfg_path) - assert cfg[docker.auth.INDEX_URL] is not None - cfg = cfg[docker.auth.INDEX_URL] - assert cfg['username'] == 'sakuya' - assert cfg['password'] == 'izayoi' - assert cfg['email'] == 'sakuya@scarlet.net' - assert cfg.get('Auth') is None - - class AutoDetectVersionTest(unittest.TestCase): def test_client_init(self): client = docker.APIClient(version='auto', **kwargs_from_env()) diff --git a/tests/unit/auth_test.py b/tests/unit/auth_test.py index ee32ca08..947d6800 100644 --- a/tests/unit/auth_test.py +++ b/tests/unit/auth_test.py @@ -282,22 +282,64 @@ class LoadConfigTest(unittest.TestCase): cfg = auth.load_config(folder) assert cfg is not None - def test_load_config(self): + def test_load_legacy_config(self): folder = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, folder) - dockercfg_path = os.path.join(folder, '.dockercfg') - with open(dockercfg_path, 'w') as f: - auth_ = base64.b64encode(b'sakuya:izayoi').decode('ascii') + cfg_path = os.path.join(folder, '.dockercfg') + auth_ = base64.b64encode(b'sakuya:izayoi').decode('ascii') + with open(cfg_path, 'w') as f: f.write('auth = {0}\n'.format(auth_)) f.write('email = sakuya@scarlet.net') - cfg = auth.load_config(dockercfg_path) - assert auth.INDEX_NAME in cfg - assert cfg[auth.INDEX_NAME] is not None - cfg = cfg[auth.INDEX_NAME] + + cfg = auth.load_config(cfg_path) + assert auth.resolve_authconfig(cfg) is not None + assert cfg['auths'][auth.INDEX_NAME] is not None + cfg = cfg['auths'][auth.INDEX_NAME] assert cfg['username'] == 'sakuya' assert cfg['password'] == 'izayoi' assert cfg['email'] == 'sakuya@scarlet.net' - assert cfg.get('auth') is None + assert cfg.get('Auth') is None + + def test_load_json_config(self): + folder = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, folder) + cfg_path = os.path.join(folder, '.dockercfg') + auth_ = base64.b64encode(b'sakuya:izayoi').decode('ascii') + email = 'sakuya@scarlet.net' + with open(cfg_path, 'w') as f: + json.dump( + {auth.INDEX_URL: {'auth': auth_, 'email': email}}, f + ) + cfg = auth.load_config(cfg_path) + assert auth.resolve_authconfig(cfg) is not None + assert cfg['auths'][auth.INDEX_URL] is not None + cfg = cfg['auths'][auth.INDEX_URL] + assert cfg['username'] == 'sakuya' + assert cfg['password'] == 'izayoi' + assert cfg['email'] == email + assert cfg.get('Auth') is None + + def test_load_modern_json_config(self): + folder = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, folder) + cfg_path = os.path.join(folder, 'config.json') + auth_ = base64.b64encode(b'sakuya:izayoi').decode('ascii') + email = 'sakuya@scarlet.net' + with open(cfg_path, 'w') as f: + json.dump({ + 'auths': { + auth.INDEX_URL: { + 'auth': auth_, 'email': email + } + } + }, f) + cfg = auth.load_config(cfg_path) + assert auth.resolve_authconfig(cfg) is not None + assert cfg['auths'][auth.INDEX_URL] is not None + cfg = cfg['auths'][auth.INDEX_URL] + assert cfg['username'] == 'sakuya' + assert cfg['password'] == 'izayoi' + assert cfg['email'] == email def test_load_config_with_random_name(self): folder = tempfile.mkdtemp() @@ -318,7 +360,7 @@ class LoadConfigTest(unittest.TestCase): with open(dockercfg_path, 'w') as f: json.dump(config, f) - cfg = auth.load_config(dockercfg_path) + cfg = auth.load_config(dockercfg_path)['auths'] assert registry in cfg assert cfg[registry] is not None cfg = cfg[registry] @@ -345,7 +387,7 @@ class LoadConfigTest(unittest.TestCase): json.dump(config, f) with mock.patch.dict(os.environ, {'DOCKER_CONFIG': folder}): - cfg = auth.load_config(None) + cfg = auth.load_config(None)['auths'] assert registry in cfg assert cfg[registry] is not None cfg = cfg[registry] @@ -422,7 +464,7 @@ class LoadConfigTest(unittest.TestCase): json.dump(config, f) cfg = auth.load_config(dockercfg_path) - assert cfg == {} + assert cfg == {'auths': {}} def test_load_config_invalid_auth_dict(self): folder = tempfile.mkdtemp() From e195e022cf854d1487fd87d796d8391221797388 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 28 Jun 2018 16:33:06 -0700 Subject: [PATCH 03/25] Fix detach assert function to account for new behavior in engine 18.06 Signed-off-by: Joffrey F --- tests/helpers.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/helpers.py b/tests/helpers.py index b6b493b3..b36d6d78 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -123,7 +123,12 @@ def assert_cat_socket_detached_with_keys(sock, inputs): sock.sendall(b'make sure the socket is closed\n') else: sock.sendall(b"make sure the socket is closed\n") - assert sock.recv(32) == b'' + data = sock.recv(128) + # New in 18.06: error message is broadcast over the socket when reading + # after detach + assert data == b'' or data.startswith( + b'exec attach failed: error on attach stdin: read escape sequence' + ) def ctrl_with(char): From 81b7d48ad6eb3e2275a0585421b3ed0af53e9f21 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 21 Jun 2018 00:15:55 -0700 Subject: [PATCH 04/25] Improved .dockerignore pattern processing to better match Docker CLI behavior Signed-off-by: Joffrey F --- docker/utils/build.py | 199 ++++++++++++++++++++++----------------- docker/utils/fnmatch.py | 1 + tests/unit/utils_test.py | 12 ++- 3 files changed, 123 insertions(+), 89 deletions(-) diff --git a/docker/utils/build.py b/docker/utils/build.py index b644c9fc..9ce0095c 100644 --- a/docker/utils/build.py +++ b/docker/utils/build.py @@ -6,8 +6,7 @@ import tarfile import tempfile from ..constants import IS_WINDOWS_PLATFORM -from fnmatch import fnmatch -from itertools import chain +from .fnmatch import fnmatch _SEP = re.compile('/|\\\\') if IS_WINDOWS_PLATFORM else re.compile('/') @@ -44,92 +43,9 @@ 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." - # ".." 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] - if i > 0: - del split[i - 1] - i -= 1 - else: - i += 1 - return split - - patterns = ( - (True, normalize(p[1:])) - if p.startswith('!') else - (False, normalize(p)) - for p in patterns) - patterns = list(reversed(list(chain( - # Exclude empty patterns such as "." or the empty string. - filter(lambda p: p[1], patterns), - # Always include the Dockerfile and .dockerignore - [(True, split_path(dockerfile)), (True, ['.dockerignore'])])))) - return set(walk(root, patterns)) - - -def walk(root, patterns, default=True): - """ - A collection of file lying below root that should be included according to - patterns. - """ - - def match(p): - if p[1][0] == '**': - rec = (p[0], p[1][1:]) - return [p] + (match(rec) if rec[1] else [rec]) - elif fnmatch(f, p[1][0]): - return [(p[0], p[1][1:])] - else: - return [] - - for f in os.listdir(root): - cur = os.path.join(root, f) - # The patterns if recursing in that directory. - sub = list(chain(*(match(p) for p in patterns))) - # Whether this file is explicitely included / excluded. - hit = next((p[0] for p in sub if not p[1]), None) - # 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) 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: - continue - # I think this would greatly speed up dockerignore handling by not - # recursing into directories we are sure would be entirely - # included, and only yielding the directory itself, which will be - # recursively archived anyway. However the current unit test expect - # the full list of subfiles and I'm not 100% sure it would make no - # difference yet. - # if all(p[0] for p in sub) and matched: - # yield f - # continue - children = False - for r in (os.path.join(f, p) for p in walk(cur, sub, matched)): - yield r - children = True - # The current unit tests expect directories only under those - # conditions. It might be simplifiable though. - if (not sub or not children) and hit or hit is None and default: - yield f - elif matched: - yield f + patterns.append('!' + dockerfile) + pm = PatternMatcher(patterns) + return set(pm.walk(root)) def build_file_list(root): @@ -217,3 +133,110 @@ def mkbuildcontext(dockerfile): t.close() f.seek(0) return f + + +def split_path(p): + return [pt for pt in re.split(_SEP, p) if pt and pt != '.'] + + +# Heavily based on +# https://github.com/moby/moby/blob/master/pkg/fileutils/fileutils.go +class PatternMatcher(object): + def __init__(self, patterns): + self.patterns = list(filter( + lambda p: p.dirs, [Pattern(p) for p in patterns] + )) + self.patterns.append(Pattern('!.dockerignore')) + + def matches(self, filepath): + matched = False + parent_path = os.path.dirname(filepath) + parent_path_dirs = split_path(parent_path) + + for pattern in self.patterns: + negative = pattern.exclusion + match = pattern.match(filepath) + if not match and parent_path != '': + if len(pattern.dirs) <= len(parent_path_dirs): + match = pattern.match( + os.path.sep.join(parent_path_dirs[:len(pattern.dirs)]) + ) + + if match: + matched = not negative + + return matched + + def walk(self, root): + def rec_walk(current_dir): + for f in os.listdir(current_dir): + fpath = os.path.join( + os.path.relpath(current_dir, root), f + ) + if fpath.startswith('.' + os.path.sep): + fpath = fpath[2:] + match = self.matches(fpath) + if not match: + yield fpath + + cur = os.path.join(root, fpath) + if not os.path.isdir(cur) or os.path.islink(cur): + continue + + if match: + # If we want to skip this file and its a directory + # then we should first check to see if there's an + # excludes pattern (e.g. !dir/file) that starts with this + # dir. If so then we can't skip this dir. + skip = True + + for pat in self.patterns: + if not pat.exclusion: + continue + if pat.cleaned_pattern.startswith(fpath): + skip = False + break + if skip: + continue + for sub in rec_walk(cur): + yield sub + + return rec_walk(root) + + +class Pattern(object): + def __init__(self, pattern_str): + self.exclusion = False + if pattern_str.startswith('!'): + self.exclusion = True + pattern_str = pattern_str[1:] + + self.dirs = self.normalize(pattern_str) + self.cleaned_pattern = '/'.join(self.dirs) + + @classmethod + def normalize(cls, 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." + # ".." 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] + if i > 0: + del split[i - 1] + i -= 1 + else: + i += 1 + return split + + def match(self, filepath): + return fnmatch(filepath, self.cleaned_pattern) diff --git a/docker/utils/fnmatch.py b/docker/utils/fnmatch.py index 42461dd7..cc940a2e 100644 --- a/docker/utils/fnmatch.py +++ b/docker/utils/fnmatch.py @@ -111,4 +111,5 @@ def translate(pat): res = '%s[%s]' % (res, stuff) else: res = res + re.escape(c) + return res + '$' diff --git a/tests/unit/utils_test.py b/tests/unit/utils_test.py index 00456e8c..467e835c 100644 --- a/tests/unit/utils_test.py +++ b/tests/unit/utils_test.py @@ -887,12 +887,22 @@ class ExcludePathsTest(unittest.TestCase): ) ) + def test_double_wildcard_with_exception(self): + assert self.exclude(['**', '!bar', '!foo/bar']) == convert_paths( + set([ + 'foo/bar', 'foo/bar/a.py', 'bar', 'bar/a.py', 'Dockerfile', + '.dockerignore', + ]) + ) + def test_include_wildcard(self): + # This may be surprising but it matches the CLI's behavior + # (tested with 18.05.0-ce on linux) base = make_tree(['a'], ['a/b.py']) assert exclude_paths( base, ['*', '!*/b.py'] - ) == convert_paths(['a/b.py']) + ) == set() def test_last_line_precedence(self): base = make_tree( From ced86ec81329e063550933abb90c940dceb24620 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 28 Jun 2018 14:07:38 -0700 Subject: [PATCH 05/25] On Windows, convert paths to use forward slashes before fnmatch call Signed-off-by: Joffrey F --- docker/utils/build.py | 18 +- tests/unit/utils_build_test.py | 493 +++++++++++++++++++++++++++++++++ tests/unit/utils_test.py | 490 +------------------------------- 3 files changed, 511 insertions(+), 490 deletions(-) create mode 100644 tests/unit/utils_build_test.py diff --git a/docker/utils/build.py b/docker/utils/build.py index 9ce0095c..6f6241e9 100644 --- a/docker/utils/build.py +++ b/docker/utils/build.py @@ -1,12 +1,13 @@ import io import os import re -import six import tarfile import tempfile -from ..constants import IS_WINDOWS_PLATFORM +import six + from .fnmatch import fnmatch +from ..constants import IS_WINDOWS_PLATFORM _SEP = re.compile('/|\\\\') if IS_WINDOWS_PLATFORM else re.compile('/') @@ -139,6 +140,12 @@ def split_path(p): return [pt for pt in re.split(_SEP, p) if pt and pt != '.'] +def normalize_slashes(p): + if IS_WINDOWS_PLATFORM: + return '/'.join(split_path(p)) + return p + + # Heavily based on # https://github.com/moby/moby/blob/master/pkg/fileutils/fileutils.go class PatternMatcher(object): @@ -184,7 +191,7 @@ class PatternMatcher(object): continue if match: - # If we want to skip this file and its a directory + # If we want to skip this file and it's a directory # then we should first check to see if there's an # excludes pattern (e.g. !dir/file) that starts with this # dir. If so then we can't skip this dir. @@ -193,7 +200,8 @@ class PatternMatcher(object): for pat in self.patterns: if not pat.exclusion: continue - if pat.cleaned_pattern.startswith(fpath): + if pat.cleaned_pattern.startswith( + normalize_slashes(fpath)): skip = False break if skip: @@ -239,4 +247,4 @@ class Pattern(object): return split def match(self, filepath): - return fnmatch(filepath, self.cleaned_pattern) + return fnmatch(normalize_slashes(filepath), self.cleaned_pattern) diff --git a/tests/unit/utils_build_test.py b/tests/unit/utils_build_test.py new file mode 100644 index 00000000..012f15b4 --- /dev/null +++ b/tests/unit/utils_build_test.py @@ -0,0 +1,493 @@ +# -*- coding: utf-8 -*- + +import os +import os.path +import shutil +import socket +import tarfile +import tempfile +import unittest + + +from docker.constants import IS_WINDOWS_PLATFORM +from docker.utils import exclude_paths, tar + +import pytest + +from ..helpers import make_tree + + +def convert_paths(collection): + return set(map(convert_path, collection)) + + +def convert_path(path): + return path.replace('/', os.path.sep) + + +class ExcludePathsTest(unittest.TestCase): + dirs = [ + 'foo', + 'foo/bar', + 'bar', + 'target', + 'target/subdir', + 'subdir', + 'subdir/target', + 'subdir/target/subdir', + 'subdir/subdir2', + 'subdir/subdir2/target', + 'subdir/subdir2/target/subdir' + ] + + files = [ + 'Dockerfile', + 'Dockerfile.alt', + '.dockerignore', + 'a.py', + 'a.go', + 'b.py', + 'cde.py', + 'foo/a.py', + 'foo/b.py', + 'foo/bar/a.py', + 'bar/a.py', + 'foo/Dockerfile3', + 'target/file.txt', + 'target/subdir/file.txt', + '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', + ] + + all_paths = set(dirs + files) + + def setUp(self): + self.base = make_tree(self.dirs, self.files) + + def tearDown(self): + shutil.rmtree(self.base) + + def exclude(self, patterns, dockerfile=None): + return set(exclude_paths(self.base, patterns, dockerfile=dockerfile)) + + def test_no_excludes(self): + assert self.exclude(['']) == convert_paths(self.all_paths) + + def test_no_dupes(self): + paths = exclude_paths(self.base, ['!a.py']) + assert sorted(paths) == sorted(set(paths)) + + def test_wildcard_exclude(self): + assert self.exclude(['*']) == set(['Dockerfile', '.dockerignore']) + + def test_exclude_dockerfile_dockerignore(self): + """ + Even if the .dockerignore file explicitly says to exclude + Dockerfile and/or .dockerignore, don't exclude them from + the actual tar file. + """ + assert self.exclude(['Dockerfile', '.dockerignore']) == convert_paths( + self.all_paths + ) + + def test_exclude_custom_dockerfile(self): + """ + If we're using a custom Dockerfile, make sure that's not + excluded. + """ + assert self.exclude(['*'], dockerfile='Dockerfile.alt') == set( + ['Dockerfile.alt', '.dockerignore'] + ) + + assert self.exclude( + ['*'], 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 + assert convert_path('foo/a.py') not in includes + + def test_single_filename(self): + assert self.exclude(['a.py']) == convert_paths( + self.all_paths - set(['a.py']) + ) + + def test_single_filename_leading_dot_slash(self): + assert self.exclude(['./a.py']) == convert_paths( + self.all_paths - set(['a.py']) + ) + + # As odd as it sounds, a filename pattern with a trailing slash on the + # end *will* result in that file being excluded. + def test_single_filename_trailing_slash(self): + assert self.exclude(['a.py/']) == convert_paths( + self.all_paths - set(['a.py']) + ) + + def test_wildcard_filename_start(self): + assert self.exclude(['*.py']) == convert_paths( + self.all_paths - set(['a.py', 'b.py', 'cde.py']) + ) + + def test_wildcard_with_exception(self): + assert self.exclude(['*.py', '!b.py']) == convert_paths( + self.all_paths - set(['a.py', 'cde.py']) + ) + + def test_wildcard_with_wildcard_exception(self): + assert self.exclude(['*.*', '!*.go']) == convert_paths( + self.all_paths - set([ + 'a.py', 'b.py', 'cde.py', 'Dockerfile.alt', + ]) + ) + + def test_wildcard_filename_end(self): + assert self.exclude(['a.*']) == convert_paths( + self.all_paths - set(['a.py', 'a.go']) + ) + + def test_question_mark(self): + assert self.exclude(['?.py']) == convert_paths( + self.all_paths - set(['a.py', 'b.py']) + ) + + def test_single_subdir_single_filename(self): + assert self.exclude(['foo/a.py']) == convert_paths( + self.all_paths - set(['foo/a.py']) + ) + + def test_single_subdir_single_filename_leading_slash(self): + assert self.exclude(['/foo/a.py']) == convert_paths( + self.all_paths - set(['foo/a.py']) + ) + + def test_exclude_include_absolute_path(self): + base = make_tree([], ['a.py', 'b.py']) + assert exclude_paths( + base, + ['/*', '!/*.py'] + ) == set(['a.py', 'b.py']) + + def test_single_subdir_with_path_traversal(self): + assert self.exclude(['foo/whoops/../a.py']) == convert_paths( + self.all_paths - set(['foo/a.py']) + ) + + def test_single_subdir_wildcard_filename(self): + assert self.exclude(['foo/*.py']) == convert_paths( + self.all_paths - set(['foo/a.py', 'foo/b.py']) + ) + + def test_wildcard_subdir_single_filename(self): + assert self.exclude(['*/a.py']) == convert_paths( + self.all_paths - set(['foo/a.py', 'bar/a.py']) + ) + + def test_wildcard_subdir_wildcard_filename(self): + assert self.exclude(['*/*.py']) == convert_paths( + self.all_paths - set(['foo/a.py', 'foo/b.py', 'bar/a.py']) + ) + + def test_directory(self): + assert self.exclude(['foo']) == convert_paths( + self.all_paths - set([ + 'foo', 'foo/a.py', 'foo/b.py', 'foo/bar', 'foo/bar/a.py', + 'foo/Dockerfile3' + ]) + ) + + def test_directory_with_trailing_slash(self): + assert self.exclude(['foo']) == convert_paths( + self.all_paths - set([ + 'foo', 'foo/a.py', 'foo/b.py', + 'foo/bar', 'foo/bar/a.py', 'foo/Dockerfile3' + ]) + ) + + def test_directory_with_single_exception(self): + assert self.exclude(['foo', '!foo/bar/a.py']) == convert_paths( + self.all_paths - set([ + 'foo/a.py', 'foo/b.py', 'foo', 'foo/bar', + 'foo/Dockerfile3' + ]) + ) + + def test_directory_with_subdir_exception(self): + assert self.exclude(['foo', '!foo/bar']) == convert_paths( + self.all_paths - set([ + 'foo/a.py', 'foo/b.py', 'foo', 'foo/Dockerfile3' + ]) + ) + + @pytest.mark.skipif( + not IS_WINDOWS_PLATFORM, reason='Backslash patterns only on Windows' + ) + def test_directory_with_subdir_exception_win32_pathsep(self): + assert self.exclude(['foo', '!foo\\bar']) == convert_paths( + self.all_paths - set([ + 'foo/a.py', 'foo/b.py', 'foo', 'foo/Dockerfile3' + ]) + ) + + def test_directory_with_wildcard_exception(self): + assert self.exclude(['foo', '!foo/*.py']) == convert_paths( + self.all_paths - set([ + 'foo/bar', 'foo/bar/a.py', 'foo', 'foo/Dockerfile3' + ]) + ) + + def test_subdirectory(self): + assert self.exclude(['foo/bar']) == convert_paths( + self.all_paths - set(['foo/bar', 'foo/bar/a.py']) + ) + + @pytest.mark.skipif( + not IS_WINDOWS_PLATFORM, reason='Backslash patterns only on Windows' + ) + def test_subdirectory_win32_pathsep(self): + assert self.exclude(['foo\\bar']) == convert_paths( + self.all_paths - set(['foo/bar', 'foo/bar/a.py']) + ) + + def test_double_wildcard(self): + assert self.exclude(['**/a.py']) == convert_paths( + self.all_paths - set( + ['a.py', 'foo/a.py', 'foo/bar/a.py', 'bar/a.py'] + ) + ) + + assert self.exclude(['foo/**/bar']) == convert_paths( + self.all_paths - set(['foo/bar', 'foo/bar/a.py']) + ) + + def test_single_and_double_wildcard(self): + assert self.exclude(['**/target/*/*']) == convert_paths( + self.all_paths - set( + ['target/subdir/file.txt', + 'subdir/target/subdir/file.txt', + 'subdir/subdir2/target/subdir/file.txt'] + ) + ) + + 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'] + ) + ) + + def test_double_wildcard_with_exception(self): + assert self.exclude(['**', '!bar', '!foo/bar']) == convert_paths( + set([ + 'foo/bar', 'foo/bar/a.py', 'bar', 'bar/a.py', 'Dockerfile', + '.dockerignore', + ]) + ) + + def test_include_wildcard(self): + # This may be surprising but it matches the CLI's behavior + # (tested with 18.05.0-ce on linux) + base = make_tree(['a'], ['a/b.py']) + assert exclude_paths( + base, + ['*', '!*/b.py'] + ) == set() + + def test_last_line_precedence(self): + base = make_tree( + [], + ['garbage.md', + 'trash.md', + 'README.md', + 'README-bis.md', + 'README-secret.md']) + assert exclude_paths( + base, + ['*.md', '!README*.md', 'README-secret.md'] + ) == set(['README.md', 'README-bis.md']) + + def test_parent_directory(self): + base = make_tree( + [], + ['a.py', + 'b.py', + 'c.py']) + # Dockerignore reference stipulates that absolute paths are + # equivalent to relative paths, hence /../foo should be + # equivalent to ../foo. It also stipulates that paths are run + # through Go's filepath.Clean, which explicitely "replace + # "/.." by "/" at the beginning of a path". + assert exclude_paths( + base, + ['../a.py', '/../b.py'] + ) == set(['c.py']) + + +class TarTest(unittest.TestCase): + def test_tar_with_excludes(self): + dirs = [ + 'foo', + 'foo/bar', + 'bar', + ] + + files = [ + 'Dockerfile', + 'Dockerfile.alt', + '.dockerignore', + 'a.py', + 'a.go', + 'b.py', + 'cde.py', + 'foo/a.py', + 'foo/b.py', + 'foo/bar/a.py', + 'bar/a.py', + ] + + exclude = [ + '*.py', + '!b.py', + '!a.go', + 'foo', + 'Dockerfile*', + '.dockerignore', + ] + + expected_names = set([ + 'Dockerfile', + '.dockerignore', + 'a.go', + 'b.py', + 'bar', + 'bar/a.py', + ]) + + base = make_tree(dirs, files) + self.addCleanup(shutil.rmtree, base) + + with tar(base, exclude=exclude) as archive: + tar_data = tarfile.open(fileobj=archive) + assert sorted(tar_data.getnames()) == sorted(expected_names) + + def test_tar_with_empty_directory(self): + base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base) + for d in ['foo', 'bar']: + os.makedirs(os.path.join(base, d)) + with tar(base) as archive: + tar_data = tarfile.open(fileobj=archive) + assert sorted(tar_data.getnames()) == ['bar', 'foo'] + + @pytest.mark.skipif( + IS_WINDOWS_PLATFORM or os.geteuid() == 0, + reason='root user always has access ; no chmod on Windows' + ) + def test_tar_with_inaccessible_file(self): + base = tempfile.mkdtemp() + full_path = os.path.join(base, 'foo') + self.addCleanup(shutil.rmtree, base) + with open(full_path, 'w') as f: + f.write('content') + os.chmod(full_path, 0o222) + with pytest.raises(IOError) as ei: + tar(base) + + assert 'Can not read file in context: {}'.format(full_path) in ( + ei.exconly() + ) + + @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='No symlinks on Windows') + def test_tar_with_file_symlinks(self): + base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base) + with open(os.path.join(base, 'foo'), 'w') as f: + f.write("content") + os.makedirs(os.path.join(base, 'bar')) + os.symlink('../foo', os.path.join(base, 'bar/foo')) + with tar(base) as archive: + tar_data = tarfile.open(fileobj=archive) + assert sorted(tar_data.getnames()) == ['bar', 'bar/foo', 'foo'] + + @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='No symlinks on Windows') + def test_tar_with_directory_symlinks(self): + base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base) + for d in ['foo', 'bar']: + os.makedirs(os.path.join(base, d)) + os.symlink('../foo', os.path.join(base, 'bar/foo')) + with tar(base) as archive: + tar_data = tarfile.open(fileobj=archive) + assert sorted(tar_data.getnames()) == ['bar', 'bar/foo', 'foo'] + + @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='No symlinks on Windows') + def test_tar_with_broken_symlinks(self): + base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base) + for d in ['foo', 'bar']: + os.makedirs(os.path.join(base, d)) + + os.symlink('../baz', os.path.join(base, 'bar/foo')) + with tar(base) as archive: + tar_data = tarfile.open(fileobj=archive) + assert sorted(tar_data.getnames()) == ['bar', 'bar/foo', 'foo'] + + @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='No UNIX sockets on Win32') + def test_tar_socket_file(self): + base = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, base) + for d in ['foo', 'bar']: + os.makedirs(os.path.join(base, d)) + sock = socket.socket(socket.AF_UNIX) + self.addCleanup(sock.close) + sock.bind(os.path.join(base, 'test.sock')) + with tar(base) as archive: + tar_data = tarfile.open(fileobj=archive) + assert sorted(tar_data.getnames()) == ['bar', 'foo'] + + def tar_test_negative_mtime_bug(self): + base = tempfile.mkdtemp() + filename = os.path.join(base, 'th.txt') + self.addCleanup(shutil.rmtree, base) + with open(filename, 'w') as f: + f.write('Invisible Full Moon') + os.utime(filename, (12345, -3600.0)) + with tar(base) as archive: + tar_data = tarfile.open(fileobj=archive) + 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 diff --git a/tests/unit/utils_test.py b/tests/unit/utils_test.py index 467e835c..8880cfef 100644 --- a/tests/unit/utils_test.py +++ b/tests/unit/utils_test.py @@ -5,29 +5,25 @@ import json import os import os.path import shutil -import socket import sys -import tarfile import tempfile import unittest -import pytest -import six from docker.api.client import APIClient -from docker.constants import IS_WINDOWS_PLATFORM from docker.errors import DockerException from docker.utils import ( - parse_repository_tag, parse_host, convert_filters, kwargs_from_env, - parse_bytes, parse_env_file, exclude_paths, convert_volume_binds, - decode_json_header, tar, split_command, parse_devices, update_headers, + convert_filters, convert_volume_binds, decode_json_header, kwargs_from_env, + parse_bytes, parse_devices, parse_env_file, parse_host, + parse_repository_tag, split_command, update_headers, ) from docker.utils.ports import build_port_bindings, split_port from docker.utils.utils import format_environment -from ..helpers import make_tree +import pytest +import six TEST_CERT_DIR = os.path.join( os.path.dirname(__file__), @@ -608,482 +604,6 @@ class PortsTest(unittest.TestCase): assert port_bindings["2000"] == [("127.0.0.1", "2000")] -def convert_paths(collection): - return set(map(convert_path, collection)) - - -def convert_path(path): - return path.replace('/', os.path.sep) - - -class ExcludePathsTest(unittest.TestCase): - dirs = [ - 'foo', - 'foo/bar', - 'bar', - 'target', - 'target/subdir', - 'subdir', - 'subdir/target', - 'subdir/target/subdir', - 'subdir/subdir2', - 'subdir/subdir2/target', - 'subdir/subdir2/target/subdir' - ] - - files = [ - 'Dockerfile', - 'Dockerfile.alt', - '.dockerignore', - 'a.py', - 'a.go', - 'b.py', - 'cde.py', - 'foo/a.py', - 'foo/b.py', - 'foo/bar/a.py', - 'bar/a.py', - 'foo/Dockerfile3', - 'target/file.txt', - 'target/subdir/file.txt', - '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', - ] - - all_paths = set(dirs + files) - - def setUp(self): - self.base = make_tree(self.dirs, self.files) - - def tearDown(self): - shutil.rmtree(self.base) - - def exclude(self, patterns, dockerfile=None): - return set(exclude_paths(self.base, patterns, dockerfile=dockerfile)) - - def test_no_excludes(self): - assert self.exclude(['']) == convert_paths(self.all_paths) - - def test_no_dupes(self): - paths = exclude_paths(self.base, ['!a.py']) - assert sorted(paths) == sorted(set(paths)) - - def test_wildcard_exclude(self): - assert self.exclude(['*']) == set(['Dockerfile', '.dockerignore']) - - def test_exclude_dockerfile_dockerignore(self): - """ - Even if the .dockerignore file explicitly says to exclude - Dockerfile and/or .dockerignore, don't exclude them from - the actual tar file. - """ - assert self.exclude(['Dockerfile', '.dockerignore']) == convert_paths( - self.all_paths - ) - - def test_exclude_custom_dockerfile(self): - """ - If we're using a custom Dockerfile, make sure that's not - excluded. - """ - assert self.exclude(['*'], dockerfile='Dockerfile.alt') == set( - ['Dockerfile.alt', '.dockerignore'] - ) - - assert self.exclude( - ['*'], 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 - assert convert_path('foo/a.py') not in includes - - def test_single_filename(self): - assert self.exclude(['a.py']) == convert_paths( - self.all_paths - set(['a.py']) - ) - - def test_single_filename_leading_dot_slash(self): - assert self.exclude(['./a.py']) == convert_paths( - self.all_paths - set(['a.py']) - ) - - # As odd as it sounds, a filename pattern with a trailing slash on the - # end *will* result in that file being excluded. - def test_single_filename_trailing_slash(self): - assert self.exclude(['a.py/']) == convert_paths( - self.all_paths - set(['a.py']) - ) - - def test_wildcard_filename_start(self): - assert self.exclude(['*.py']) == convert_paths( - self.all_paths - set(['a.py', 'b.py', 'cde.py']) - ) - - def test_wildcard_with_exception(self): - assert self.exclude(['*.py', '!b.py']) == convert_paths( - self.all_paths - set(['a.py', 'cde.py']) - ) - - def test_wildcard_with_wildcard_exception(self): - assert self.exclude(['*.*', '!*.go']) == convert_paths( - self.all_paths - set([ - 'a.py', 'b.py', 'cde.py', 'Dockerfile.alt', - ]) - ) - - def test_wildcard_filename_end(self): - assert self.exclude(['a.*']) == convert_paths( - self.all_paths - set(['a.py', 'a.go']) - ) - - def test_question_mark(self): - assert self.exclude(['?.py']) == convert_paths( - self.all_paths - set(['a.py', 'b.py']) - ) - - def test_single_subdir_single_filename(self): - assert self.exclude(['foo/a.py']) == convert_paths( - self.all_paths - set(['foo/a.py']) - ) - - def test_single_subdir_single_filename_leading_slash(self): - assert self.exclude(['/foo/a.py']) == convert_paths( - self.all_paths - set(['foo/a.py']) - ) - - def test_exclude_include_absolute_path(self): - base = make_tree([], ['a.py', 'b.py']) - assert exclude_paths( - base, - ['/*', '!/*.py'] - ) == set(['a.py', 'b.py']) - - def test_single_subdir_with_path_traversal(self): - assert self.exclude(['foo/whoops/../a.py']) == convert_paths( - self.all_paths - set(['foo/a.py']) - ) - - def test_single_subdir_wildcard_filename(self): - assert self.exclude(['foo/*.py']) == convert_paths( - self.all_paths - set(['foo/a.py', 'foo/b.py']) - ) - - def test_wildcard_subdir_single_filename(self): - assert self.exclude(['*/a.py']) == convert_paths( - self.all_paths - set(['foo/a.py', 'bar/a.py']) - ) - - def test_wildcard_subdir_wildcard_filename(self): - assert self.exclude(['*/*.py']) == convert_paths( - self.all_paths - set(['foo/a.py', 'foo/b.py', 'bar/a.py']) - ) - - def test_directory(self): - assert self.exclude(['foo']) == convert_paths( - self.all_paths - set([ - 'foo', 'foo/a.py', 'foo/b.py', 'foo/bar', 'foo/bar/a.py', - 'foo/Dockerfile3' - ]) - ) - - def test_directory_with_trailing_slash(self): - assert self.exclude(['foo']) == convert_paths( - self.all_paths - set([ - 'foo', 'foo/a.py', 'foo/b.py', - 'foo/bar', 'foo/bar/a.py', 'foo/Dockerfile3' - ]) - ) - - def test_directory_with_single_exception(self): - assert self.exclude(['foo', '!foo/bar/a.py']) == convert_paths( - self.all_paths - set([ - 'foo/a.py', 'foo/b.py', 'foo', 'foo/bar', - 'foo/Dockerfile3' - ]) - ) - - def test_directory_with_subdir_exception(self): - assert self.exclude(['foo', '!foo/bar']) == convert_paths( - self.all_paths - set([ - 'foo/a.py', 'foo/b.py', 'foo', 'foo/Dockerfile3' - ]) - ) - - @pytest.mark.skipif( - not IS_WINDOWS_PLATFORM, reason='Backslash patterns only on Windows' - ) - def test_directory_with_subdir_exception_win32_pathsep(self): - assert self.exclude(['foo', '!foo\\bar']) == convert_paths( - self.all_paths - set([ - 'foo/a.py', 'foo/b.py', 'foo', 'foo/Dockerfile3' - ]) - ) - - def test_directory_with_wildcard_exception(self): - assert self.exclude(['foo', '!foo/*.py']) == convert_paths( - self.all_paths - set([ - 'foo/bar', 'foo/bar/a.py', 'foo', 'foo/Dockerfile3' - ]) - ) - - def test_subdirectory(self): - assert self.exclude(['foo/bar']) == convert_paths( - self.all_paths - set(['foo/bar', 'foo/bar/a.py']) - ) - - @pytest.mark.skipif( - not IS_WINDOWS_PLATFORM, reason='Backslash patterns only on Windows' - ) - def test_subdirectory_win32_pathsep(self): - assert self.exclude(['foo\\bar']) == convert_paths( - self.all_paths - set(['foo/bar', 'foo/bar/a.py']) - ) - - def test_double_wildcard(self): - assert self.exclude(['**/a.py']) == convert_paths( - self.all_paths - set( - ['a.py', 'foo/a.py', 'foo/bar/a.py', 'bar/a.py'] - ) - ) - - assert self.exclude(['foo/**/bar']) == convert_paths( - self.all_paths - set(['foo/bar', 'foo/bar/a.py']) - ) - - def test_single_and_double_wildcard(self): - assert self.exclude(['**/target/*/*']) == convert_paths( - self.all_paths - set( - ['target/subdir/file.txt', - 'subdir/target/subdir/file.txt', - 'subdir/subdir2/target/subdir/file.txt'] - ) - ) - - 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'] - ) - ) - - def test_double_wildcard_with_exception(self): - assert self.exclude(['**', '!bar', '!foo/bar']) == convert_paths( - set([ - 'foo/bar', 'foo/bar/a.py', 'bar', 'bar/a.py', 'Dockerfile', - '.dockerignore', - ]) - ) - - def test_include_wildcard(self): - # This may be surprising but it matches the CLI's behavior - # (tested with 18.05.0-ce on linux) - base = make_tree(['a'], ['a/b.py']) - assert exclude_paths( - base, - ['*', '!*/b.py'] - ) == set() - - def test_last_line_precedence(self): - base = make_tree( - [], - ['garbage.md', - 'thrash.md', - 'README.md', - 'README-bis.md', - 'README-secret.md']) - assert exclude_paths( - base, - ['*.md', '!README*.md', 'README-secret.md'] - ) == set(['README.md', 'README-bis.md']) - - def test_parent_directory(self): - base = make_tree( - [], - ['a.py', - 'b.py', - 'c.py']) - # Dockerignore reference stipulates that absolute paths are - # equivalent to relative paths, hence /../foo should be - # equivalent to ../foo. It also stipulates that paths are run - # through Go's filepath.Clean, which explicitely "replace - # "/.." by "/" at the beginning of a path". - assert exclude_paths( - base, - ['../a.py', '/../b.py'] - ) == set(['c.py']) - - -class TarTest(unittest.TestCase): - def test_tar_with_excludes(self): - dirs = [ - 'foo', - 'foo/bar', - 'bar', - ] - - files = [ - 'Dockerfile', - 'Dockerfile.alt', - '.dockerignore', - 'a.py', - 'a.go', - 'b.py', - 'cde.py', - 'foo/a.py', - 'foo/b.py', - 'foo/bar/a.py', - 'bar/a.py', - ] - - exclude = [ - '*.py', - '!b.py', - '!a.go', - 'foo', - 'Dockerfile*', - '.dockerignore', - ] - - expected_names = set([ - 'Dockerfile', - '.dockerignore', - 'a.go', - 'b.py', - 'bar', - 'bar/a.py', - ]) - - base = make_tree(dirs, files) - self.addCleanup(shutil.rmtree, base) - - with tar(base, exclude=exclude) as archive: - tar_data = tarfile.open(fileobj=archive) - assert sorted(tar_data.getnames()) == sorted(expected_names) - - def test_tar_with_empty_directory(self): - base = tempfile.mkdtemp() - self.addCleanup(shutil.rmtree, base) - for d in ['foo', 'bar']: - os.makedirs(os.path.join(base, d)) - with tar(base) as archive: - tar_data = tarfile.open(fileobj=archive) - assert sorted(tar_data.getnames()) == ['bar', 'foo'] - - @pytest.mark.skipif( - IS_WINDOWS_PLATFORM or os.geteuid() == 0, - reason='root user always has access ; no chmod on Windows' - ) - def test_tar_with_inaccessible_file(self): - base = tempfile.mkdtemp() - full_path = os.path.join(base, 'foo') - self.addCleanup(shutil.rmtree, base) - with open(full_path, 'w') as f: - f.write('content') - os.chmod(full_path, 0o222) - with pytest.raises(IOError) as ei: - tar(base) - - assert 'Can not read file in context: {}'.format(full_path) in ( - ei.exconly() - ) - - @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='No symlinks on Windows') - def test_tar_with_file_symlinks(self): - base = tempfile.mkdtemp() - self.addCleanup(shutil.rmtree, base) - with open(os.path.join(base, 'foo'), 'w') as f: - f.write("content") - os.makedirs(os.path.join(base, 'bar')) - os.symlink('../foo', os.path.join(base, 'bar/foo')) - with tar(base) as archive: - tar_data = tarfile.open(fileobj=archive) - assert sorted(tar_data.getnames()) == ['bar', 'bar/foo', 'foo'] - - @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='No symlinks on Windows') - def test_tar_with_directory_symlinks(self): - base = tempfile.mkdtemp() - self.addCleanup(shutil.rmtree, base) - for d in ['foo', 'bar']: - os.makedirs(os.path.join(base, d)) - os.symlink('../foo', os.path.join(base, 'bar/foo')) - with tar(base) as archive: - tar_data = tarfile.open(fileobj=archive) - assert sorted(tar_data.getnames()) == ['bar', 'bar/foo', 'foo'] - - @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='No symlinks on Windows') - def test_tar_with_broken_symlinks(self): - base = tempfile.mkdtemp() - self.addCleanup(shutil.rmtree, base) - for d in ['foo', 'bar']: - os.makedirs(os.path.join(base, d)) - - os.symlink('../baz', os.path.join(base, 'bar/foo')) - with tar(base) as archive: - tar_data = tarfile.open(fileobj=archive) - assert sorted(tar_data.getnames()) == ['bar', 'bar/foo', 'foo'] - - @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='No UNIX sockets on Win32') - def test_tar_socket_file(self): - base = tempfile.mkdtemp() - self.addCleanup(shutil.rmtree, base) - for d in ['foo', 'bar']: - os.makedirs(os.path.join(base, d)) - sock = socket.socket(socket.AF_UNIX) - self.addCleanup(sock.close) - sock.bind(os.path.join(base, 'test.sock')) - with tar(base) as archive: - tar_data = tarfile.open(fileobj=archive) - assert sorted(tar_data.getnames()) == ['bar', 'foo'] - - def tar_test_negative_mtime_bug(self): - base = tempfile.mkdtemp() - filename = os.path.join(base, 'th.txt') - self.addCleanup(shutil.rmtree, base) - with open(filename, 'w') as f: - f.write('Invisible Full Moon') - os.utime(filename, (12345, -3600.0)) - with tar(base) as archive: - tar_data = tarfile.open(fileobj=archive) - 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): env_dict = { From 37ba1c1eac97b5b3fcddb98f4ff6e1698985bc79 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 28 Jun 2018 14:30:52 -0700 Subject: [PATCH 06/25] Re-add walk method to utils.build Signed-off-by: Joffrey F --- docker/utils/build.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docker/utils/build.py b/docker/utils/build.py index 6f6241e9..4fa57518 100644 --- a/docker/utils/build.py +++ b/docker/utils/build.py @@ -146,6 +146,11 @@ def normalize_slashes(p): return p +def walk(root, patterns, default=True): + pm = PatternMatcher(patterns) + return pm.walk(root) + + # Heavily based on # https://github.com/moby/moby/blob/master/pkg/fileutils/fileutils.go class PatternMatcher(object): From 098318ad95bd3c1330d63af378397b0990ab03a5 Mon Sep 17 00:00:00 2001 From: Marco Trillo Date: Fri, 29 Jun 2018 14:54:48 +0200 Subject: [PATCH 07/25] Add support for `uts_mode` parameter in `Client.create_host_config`. This parameter allows to set the UTS namespace of the container, as in the `--uts=X` Docker CLI parameter: The only allowed value, if set, is "host". Signed-off-by: Marco Trillo Signed-off-by: Diego Alvarez --- docker/api/container.py | 2 ++ docker/models/containers.py | 1 + docker/types/containers.py | 15 ++++++++++----- tests/integration/api_container_test.py | 10 ++++++++++ tests/unit/dockertypes_test.py | 6 ++++++ tests/unit/models_containers_test.py | 2 ++ 6 files changed, 31 insertions(+), 5 deletions(-) diff --git a/docker/api/container.py b/docker/api/container.py index d4f75f54..d8416066 100644 --- a/docker/api/container.py +++ b/docker/api/container.py @@ -547,6 +547,8 @@ class ContainerApiMixin(object): userns_mode (str): Sets the user namespace mode for the container when user namespace remapping option is enabled. Supported values are: ``host`` + uts_mode (str): Sets the UTS namespace mode for the container. + Supported values are: ``host`` volumes_from (:py:class:`list`): List of container names or IDs to get volumes from. runtime (str): Runtime to use with this container. diff --git a/docker/models/containers.py b/docker/models/containers.py index b33a718f..de6222ec 100644 --- a/docker/models/containers.py +++ b/docker/models/containers.py @@ -995,6 +995,7 @@ RUN_HOST_CONFIG_KWARGS = [ 'tmpfs', 'ulimits', 'userns_mode', + 'uts_mode', 'version', 'volumes_from', 'runtime' diff --git a/docker/types/containers.py b/docker/types/containers.py index 25214207..e7841bcb 100644 --- a/docker/types/containers.py +++ b/docker/types/containers.py @@ -115,11 +115,11 @@ class HostConfig(dict): device_read_iops=None, device_write_iops=None, oom_kill_disable=False, shm_size=None, sysctls=None, tmpfs=None, oom_score_adj=None, dns_opt=None, cpu_shares=None, - cpuset_cpus=None, userns_mode=None, pids_limit=None, - isolation=None, auto_remove=False, storage_opt=None, - init=None, init_path=None, volume_driver=None, - cpu_count=None, cpu_percent=None, nano_cpus=None, - cpuset_mems=None, runtime=None, mounts=None, + cpuset_cpus=None, userns_mode=None, uts_mode=None, + pids_limit=None, isolation=None, auto_remove=False, + storage_opt=None, init=None, init_path=None, + volume_driver=None, cpu_count=None, cpu_percent=None, + nano_cpus=None, cpuset_mems=None, runtime=None, mounts=None, cpu_rt_period=None, cpu_rt_runtime=None, device_cgroup_rules=None): @@ -392,6 +392,11 @@ class HostConfig(dict): raise host_config_value_error("userns_mode", userns_mode) self['UsernsMode'] = userns_mode + if uts_mode: + if uts_mode != "host": + raise host_config_value_error("uts_mode", uts_mode) + self['UTSMode'] = uts_mode + if pids_limit: if not isinstance(pids_limit, int): raise host_config_type_error('pids_limit', pids_limit, 'int') diff --git a/tests/integration/api_container_test.py b/tests/integration/api_container_test.py index ff701487..6ce846bb 100644 --- a/tests/integration/api_container_test.py +++ b/tests/integration/api_container_test.py @@ -490,6 +490,16 @@ class CreateContainerTest(BaseAPIIntegrationTest): self.client.start(ctnr) assert rule in self.client.logs(ctnr).decode('utf-8') + def test_create_with_uts_mode(self): + container = self.client.create_container( + BUSYBOX, ['echo'], host_config=self.client.create_host_config( + uts_mode='host' + ) + ) + self.tmp_containers.append(container) + config = self.client.inspect_container(container) + assert config['HostConfig']['UTSMode'] == 'host' + @pytest.mark.xfail( IS_WINDOWS_PLATFORM, reason='Test not designed for Windows platform' diff --git a/tests/unit/dockertypes_test.py b/tests/unit/dockertypes_test.py index 2be05784..cdacf8cd 100644 --- a/tests/unit/dockertypes_test.py +++ b/tests/unit/dockertypes_test.py @@ -85,6 +85,12 @@ class HostConfigTest(unittest.TestCase): with pytest.raises(ValueError): create_host_config(version='1.23', userns_mode='host12') + def test_create_host_config_with_uts(self): + config = create_host_config(version='1.15', uts_mode='host') + assert config.get('UTSMode') == 'host' + with pytest.raises(ValueError): + create_host_config(version='1.15', uts_mode='host12') + def test_create_host_config_with_oom_score_adj(self): config = create_host_config(version='1.22', oom_score_adj=100) assert config.get('OomScoreAdj') == 100 diff --git a/tests/unit/models_containers_test.py b/tests/unit/models_containers_test.py index 48a52888..22dd2410 100644 --- a/tests/unit/models_containers_test.py +++ b/tests/unit/models_containers_test.py @@ -95,6 +95,7 @@ class ContainerCollectionTest(unittest.TestCase): ulimits=[{"Name": "nofile", "Soft": 1024, "Hard": 2048}], user='bob', userns_mode='host', + uts_mode='host', version='1.23', volume_driver='some_driver', volumes=[ @@ -174,6 +175,7 @@ class ContainerCollectionTest(unittest.TestCase): 'Tmpfs': {'/blah': ''}, 'Ulimits': [{"Name": "nofile", "Soft": 1024, "Hard": 2048}], 'UsernsMode': 'host', + 'UTSMode': 'host', 'VolumesFrom': ['container'], }, healthcheck={'test': 'true'}, From 6152dc8dad000f24386f99db16494e69455332fa Mon Sep 17 00:00:00 2001 From: Aron Parsons Date: Wed, 18 Jul 2018 19:02:44 -0400 Subject: [PATCH 08/25] honor placement preferences via services.create() this allows creating a service with placement preferences when calling services.create(). only constraints were being honored before. related to https://github.com/docker/docker-py/pull/1615 Signed-off-by: Aron Parsons --- docker/models/services.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docker/models/services.py b/docker/models/services.py index 458d2c87..612f3454 100644 --- a/docker/models/services.py +++ b/docker/models/services.py @@ -321,10 +321,15 @@ def _get_create_service_kwargs(func_name, kwargs): if 'container_labels' in kwargs: container_spec_kwargs['labels'] = kwargs.pop('container_labels') + placement = {} + if 'constraints' in kwargs: - task_template_kwargs['placement'] = { - 'Constraints': kwargs.pop('constraints') - } + placement['Constraints'] = kwargs.pop('constraints') + + if 'preferences' in kwargs: + placement['Preferences'] = kwargs.pop('preferences') + + task_template_kwargs['placement'] = placement if 'log_driver' in kwargs: task_template_kwargs['log_driver'] = { From d7bb808ca63b4ef8175e404f722a77be57de0f0e Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 18 Jul 2018 16:59:09 -0700 Subject: [PATCH 09/25] Update deps for 3.3 & 3.7 support Signed-off-by: Joffrey F --- .travis.yml | 4 ++++ requirements.txt | 6 ++++-- setup.py | 11 ++++++++--- test-requirements.txt | 3 ++- tox.ini | 2 +- 5 files changed, 19 insertions(+), 7 deletions(-) diff --git a/.travis.yml b/.travis.yml index 842e3528..1c837a26 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,6 +10,10 @@ matrix: env: TOXENV=py35 - python: 3.6 env: TOXENV=py36 + - python: 3.7 + env: TOXENV=py37 + dist: xenial + sudo: true - env: TOXENV=flake8 install: diff --git a/requirements.txt b/requirements.txt index 6c5e7d03..289dea91 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,8 @@ appdirs==1.4.3 asn1crypto==0.22.0 backports.ssl-match-hostname==3.5.0.1 cffi==1.10.0 -cryptography==1.9 +cryptography==1.9; python_version == '3.3' +cryptography==2.3; python_version > '3.3' docker-pycreds==0.3.0 enum34==1.1.6 idna==2.5 @@ -12,7 +13,8 @@ pycparser==2.17 pyOpenSSL==17.0.0 pyparsing==2.2.0 pypiwin32==219; sys_platform == 'win32' and python_version < '3.6' -pypiwin32==220; sys_platform == 'win32' and python_version >= '3.6' +pypiwin32==223; sys_platform == 'win32' and python_version >= '3.6' requests==2.14.2 six==1.10.0 websocket-client==0.40.0 +urllib3==1.21.1; python_version == '3.3' \ No newline at end of file diff --git a/setup.py b/setup.py index 57b2b5a8..1b208e5c 100644 --- a/setup.py +++ b/setup.py @@ -10,10 +10,10 @@ ROOT_DIR = os.path.dirname(__file__) SOURCE_DIR = os.path.join(ROOT_DIR) requirements = [ - 'requests >= 2.14.2, != 2.18.0', 'six >= 1.4.0', 'websocket-client >= 0.32.0', - 'docker-pycreds >= 0.3.0' + 'docker-pycreds >= 0.3.0', + 'requests >= 2.14.2, != 2.18.0', ] extras_require = { @@ -27,7 +27,10 @@ extras_require = { # Python 3.6 is only compatible with v220 ; Python < 3.5 is not supported # on v220 ; ALL versions are broken for v222 (as of 2018-01-26) ':sys_platform == "win32" and python_version < "3.6"': 'pypiwin32==219', - ':sys_platform == "win32" and python_version >= "3.6"': 'pypiwin32==220', + ':sys_platform == "win32" and python_version >= "3.6"': 'pypiwin32==223', + + # urllib3 drops support for Python 3.3 in 1.23 + ':python_version == "3.3"': 'urllib3 < 1.23', # If using docker-py over TLS, highly recommend this option is # pip-installed or pinned. @@ -38,6 +41,7 @@ extras_require = { # installing the extra dependencies, install the following instead: # 'requests[security] >= 2.5.2, != 2.11.0, != 2.12.2' 'tls': ['pyOpenSSL>=0.14', 'cryptography>=1.3.4', 'idna>=2.0.0'], + } version = None @@ -81,6 +85,7 @@ setup( 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', 'Topic :: Utilities', 'License :: OSI Approved :: Apache Software License', ], diff --git a/test-requirements.txt b/test-requirements.txt index 09680b68..9ad59cc6 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,6 +1,7 @@ coverage==3.7.1 flake8==3.4.1 mock==1.0.1 -pytest==2.9.1 +pytest==2.9.1; python_version == '3.3' +pytest==3.6.3; python_version > '3.3' pytest-cov==2.1.0 pytest-timeout==1.2.1 diff --git a/tox.ini b/tox.ini index 41d88605..5396147e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27, py33, py34, py35, py36, flake8 +envlist = py27, py34, py35, py36, py37, flake8 skipsdist=True [testenv] From 87f8956a3206b8b5c16dfb5c1df68e216131f024 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 10 Jul 2018 15:51:17 -0400 Subject: [PATCH 10/25] Add credHelpers support to set_auth_headers in build Signed-off-by: Joffrey F --- docker/api/build.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/docker/api/build.py b/docker/api/build.py index 419255fc..0486dce6 100644 --- a/docker/api/build.py +++ b/docker/api/build.py @@ -293,20 +293,28 @@ class BuildApiMixin(object): # Send the full auth configuration (if any exists), since the build # could use any (or all) of the registries. if self._auth_configs: + auth_cfgs = self._auth_configs auth_data = {} - if self._auth_configs.get('credsStore'): + if auth_cfgs.get('credsStore'): # Using a credentials store, we need to retrieve the # credentials for each registry listed in the config.json file # Matches CLI behavior: https://github.com/docker/docker/blob/ # 67b85f9d26f1b0b2b240f2d794748fac0f45243c/cliconfig/ # credentials/native_store.go#L68-L83 - for registry in self._auth_configs.get('auths', {}).keys(): + for registry in auth_cfgs.get('auths', {}).keys(): auth_data[registry] = auth.resolve_authconfig( - self._auth_configs, registry, + auth_cfgs, registry, credstore_env=self.credstore_env, ) else: - auth_data = self._auth_configs.get('auths', {}).copy() + for registry in auth_cfgs.get('credHelpers', {}).keys(): + auth_data[registry] = auth.resolve_authconfig( + auth_cfgs, registry, + credstore_env=self.credstore_env + ) + for registry, creds in auth_cfgs.get('auths', {}).items(): + if registry not in auth_data: + auth_data[registry] = creds # See https://github.com/docker/docker-py/issues/1683 if auth.INDEX_NAME in auth_data: auth_data[auth.INDEX_URL] = auth_data[auth.INDEX_NAME] From 3112d3920988fde5afa35d2dadbb5298db3a77da Mon Sep 17 00:00:00 2001 From: Nikolay Murga Date: Fri, 20 Jul 2018 12:53:44 +0300 Subject: [PATCH 11/25] Add 'rollback' command as allowed for failure_action Signed-off-by: Nikolay Murga --- docker/types/services.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/types/services.py b/docker/types/services.py index 31f4750f..a914cef6 100644 --- a/docker/types/services.py +++ b/docker/types/services.py @@ -371,7 +371,7 @@ class UpdateConfig(dict): delay (int): Amount of time between updates. failure_action (string): Action to take if an updated task fails to run, or stops running during the update. Acceptable values are - ``continue`` and ``pause``. Default: ``continue`` + ``continue``, ``rollback`` and ``pause``. Default: ``continue`` monitor (int): Amount of time to monitor each updated task for failures, in nanoseconds. max_failure_ratio (float): The fraction of tasks that may fail during @@ -385,7 +385,7 @@ class UpdateConfig(dict): self['Parallelism'] = parallelism if delay is not None: self['Delay'] = delay - if failure_action not in ('pause', 'continue'): + if failure_action not in ('pause', 'continue', 'rollback'): raise errors.InvalidArgument( 'failure_action must be either `pause` or `continue`.' ) From 24fff59bd95d077a523496a942225a074707bc08 Mon Sep 17 00:00:00 2001 From: Nikolay Murga Date: Fri, 20 Jul 2018 13:20:19 +0300 Subject: [PATCH 12/25] Add documentation for delay property Signed-off-by: Nikolay Murga --- docker/types/services.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/types/services.py b/docker/types/services.py index a914cef6..29407638 100644 --- a/docker/types/services.py +++ b/docker/types/services.py @@ -368,7 +368,7 @@ class UpdateConfig(dict): parallelism (int): Maximum number of tasks to be updated in one iteration (0 means unlimited parallelism). Default: 0. - delay (int): Amount of time between updates. + delay (int): Amount of time between updates, in nanoseconds. failure_action (string): Action to take if an updated task fails to run, or stops running during the update. Acceptable values are ``continue``, ``rollback`` and ``pause``. Default: ``continue`` From 3c9738a584ad2ff549bf95372758d41fe71adc2e Mon Sep 17 00:00:00 2001 From: Anthony Sottile Date: Thu, 2 Aug 2018 12:00:11 -0700 Subject: [PATCH 13/25] Allow user=0 to be passed in create_container Signed-off-by: Anthony Sottile --- docker/types/containers.py | 2 +- tests/unit/types_containers_test.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 tests/unit/types_containers_test.py diff --git a/docker/types/containers.py b/docker/types/containers.py index e7841bcb..9dfea8ce 100644 --- a/docker/types/containers.py +++ b/docker/types/containers.py @@ -578,7 +578,7 @@ class ContainerConfig(dict): 'Hostname': hostname, 'Domainname': domainname, 'ExposedPorts': ports, - 'User': six.text_type(user) if user else None, + 'User': six.text_type(user) if user is not None else None, 'Tty': tty, 'OpenStdin': stdin_open, 'StdinOnce': stdin_once, diff --git a/tests/unit/types_containers_test.py b/tests/unit/types_containers_test.py new file mode 100644 index 00000000..b0ad0a71 --- /dev/null +++ b/tests/unit/types_containers_test.py @@ -0,0 +1,6 @@ +from docker.types.containers import ContainerConfig + + +def test_uid_0_is_not_elided(): + x = ContainerConfig(image='i', version='v', command='true', user=0) + assert x['User'] == '0' From c28ff855429ca50804945d1c3c274fb89aca2ef3 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 3 Aug 2018 14:04:04 -0700 Subject: [PATCH 14/25] Improve placement handling in DockerClient.services.create Signed-off-by: Joffrey F --- docker/models/services.py | 22 ++++++++++++++-------- tests/unit/models_services_test.py | 8 +++++++- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/docker/models/services.py b/docker/models/services.py index 612f3454..7fbd1651 100644 --- a/docker/models/services.py +++ b/docker/models/services.py @@ -1,6 +1,6 @@ import copy from docker.errors import create_unexpected_kwargs_error, InvalidArgument -from docker.types import TaskTemplate, ContainerSpec, ServiceMode +from docker.types import TaskTemplate, ContainerSpec, Placement, ServiceMode from .resource import Model, Collection @@ -153,6 +153,9 @@ class ServiceCollection(Collection): command (list of str or str): Command to run. args (list of str): Arguments to the command. constraints (list of str): Placement constraints. + preferences (list of str): Placement preferences. + platforms (list of tuple): A list of platforms constraints + expressed as ``(arch, os)`` tuples container_labels (dict): Labels to apply to the container. endpoint_spec (EndpointSpec): Properties that can be configured to access and load balance a service. Default: ``None``. @@ -302,6 +305,12 @@ CREATE_SERVICE_KWARGS = [ 'endpoint_spec', ] +PLACEMENT_KWARGS = [ + 'constraints', + 'preferences', + 'platforms', +] + def _get_create_service_kwargs(func_name, kwargs): # Copy over things which can be copied directly @@ -322,13 +331,10 @@ def _get_create_service_kwargs(func_name, kwargs): container_spec_kwargs['labels'] = kwargs.pop('container_labels') placement = {} - - if 'constraints' in kwargs: - placement['Constraints'] = kwargs.pop('constraints') - - if 'preferences' in kwargs: - placement['Preferences'] = kwargs.pop('preferences') - + for key in copy.copy(kwargs): + if key in PLACEMENT_KWARGS: + placement[key] = kwargs.pop(key) + placement = Placement(**placement) task_template_kwargs['placement'] = placement if 'log_driver' in kwargs: diff --git a/tests/unit/models_services_test.py b/tests/unit/models_services_test.py index 247bb4a4..a4ac50c3 100644 --- a/tests/unit/models_services_test.py +++ b/tests/unit/models_services_test.py @@ -26,6 +26,8 @@ class CreateServiceKwargsTest(unittest.TestCase): 'mounts': [{'some': 'mounts'}], 'stop_grace_period': 5, 'constraints': ['foo=bar'], + 'preferences': ['bar=baz'], + 'platforms': [('x86_64', 'linux')], }) task_template = kwargs.pop('task_template') @@ -41,7 +43,11 @@ class CreateServiceKwargsTest(unittest.TestCase): 'ContainerSpec', 'Resources', 'RestartPolicy', 'Placement', 'LogDriver', 'Networks' ]) - assert task_template['Placement'] == {'Constraints': ['foo=bar']} + assert task_template['Placement'] == { + 'Constraints': ['foo=bar'], + 'Preferences': ['bar=baz'], + 'Platforms': [{'Architecture': 'x86_64', 'OS': 'linux'}], + } assert task_template['LogDriver'] == { 'Name': 'logdriver', 'Options': {'foo': 'bar'} From 14524f19e2fd2d1c570453d530ae71b1d39dc9fb Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 9 Aug 2018 15:56:11 -0700 Subject: [PATCH 15/25] Add version checks and test Signed-off-by: Joffrey F --- docker/api/service.py | 6 ++++++ docker/types/services.py | 5 +++-- tests/integration/api_service_test.py | 14 ++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/docker/api/service.py b/docker/api/service.py index 03b0ca6e..1dbe2697 100644 --- a/docker/api/service.py +++ b/docker/api/service.py @@ -18,6 +18,12 @@ def _check_api_features(version, task_template, update_config, endpoint_spec): if 'Monitor' in update_config: raise_version_error('UpdateConfig.monitor', '1.25') + if utils.version_lt(version, '1.28'): + if update_config.get('FailureAction') == 'rollback': + raise_version_error( + 'UpdateConfig.failure_action rollback', '1.28' + ) + if utils.version_lt(version, '1.29'): if 'Order' in update_config: raise_version_error('UpdateConfig.order', '1.29') diff --git a/docker/types/services.py b/docker/types/services.py index 29407638..a883f3ff 100644 --- a/docker/types/services.py +++ b/docker/types/services.py @@ -371,7 +371,8 @@ class UpdateConfig(dict): delay (int): Amount of time between updates, in nanoseconds. failure_action (string): Action to take if an updated task fails to run, or stops running during the update. Acceptable values are - ``continue``, ``rollback`` and ``pause``. Default: ``continue`` + ``continue``, ``pause``, as well as ``rollback`` since API v1.28. + Default: ``continue`` monitor (int): Amount of time to monitor each updated task for failures, in nanoseconds. max_failure_ratio (float): The fraction of tasks that may fail during @@ -387,7 +388,7 @@ class UpdateConfig(dict): self['Delay'] = delay if failure_action not in ('pause', 'continue', 'rollback'): raise errors.InvalidArgument( - 'failure_action must be either `pause` or `continue`.' + 'failure_action must be one of `pause`, `continue`, `rollback`' ) self['FailureAction'] = failure_action diff --git a/tests/integration/api_service_test.py b/tests/integration/api_service_test.py index 85f9dccf..ba2ed91f 100644 --- a/tests/integration/api_service_test.py +++ b/tests/integration/api_service_test.py @@ -281,6 +281,20 @@ class ServiceTest(BaseAPIIntegrationTest): assert update_config['Delay'] == uc['Delay'] assert update_config['FailureAction'] == uc['FailureAction'] + @requires_api_version('1.28') + def test_create_service_with_failure_action_rollback(self): + container_spec = docker.types.ContainerSpec(BUSYBOX, ['true']) + task_tmpl = docker.types.TaskTemplate(container_spec) + update_config = docker.types.UpdateConfig(failure_action='rollback') + name = self.get_service_name() + svc_id = self.client.create_service( + task_tmpl, update_config=update_config, name=name + ) + svc_info = self.client.inspect_service(svc_id) + assert 'UpdateConfig' in svc_info['Spec'] + uc = svc_info['Spec']['UpdateConfig'] + assert update_config['FailureAction'] == uc['FailureAction'] + @requires_api_version('1.25') def test_create_service_with_update_config_monitor(self): container_spec = docker.types.ContainerSpec('busybox', ['true']) From 82445764e0499f605ec6222ce3341511436e96b3 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 9 Aug 2018 16:41:25 -0700 Subject: [PATCH 16/25] Add support for RollbackConfig Signed-off-by: Joffrey F --- docker/api/service.py | 34 +++++++++++++++++++++++---- docker/models/services.py | 2 ++ docker/types/__init__.py | 4 ++-- docker/types/services.py | 24 +++++++++++++++++++ tests/integration/api_service_test.py | 21 +++++++++++++++++ 5 files changed, 78 insertions(+), 7 deletions(-) diff --git a/docker/api/service.py b/docker/api/service.py index 1dbe2697..8b956b63 100644 --- a/docker/api/service.py +++ b/docker/api/service.py @@ -2,7 +2,8 @@ from .. import auth, errors, utils from ..types import ServiceMode -def _check_api_features(version, task_template, update_config, endpoint_spec): +def _check_api_features(version, task_template, update_config, endpoint_spec, + rollback_config): def raise_version_error(param, min_version): raise errors.InvalidVersion( @@ -28,6 +29,14 @@ def _check_api_features(version, task_template, update_config, endpoint_spec): if 'Order' in update_config: raise_version_error('UpdateConfig.order', '1.29') + if rollback_config is not None: + if utils.version_lt(version, '1.28'): + raise_version_error('rollback_config', '1.28') + + if utils.version_lt(version, '1.29'): + if 'Order' in update_config: + raise_version_error('RollbackConfig.order', '1.29') + if endpoint_spec is not None: if utils.version_lt(version, '1.32') and 'Ports' in endpoint_spec: if any(p.get('PublishMode') for p in endpoint_spec['Ports']): @@ -105,7 +114,7 @@ class ServiceApiMixin(object): def create_service( self, task_template, name=None, labels=None, mode=None, update_config=None, networks=None, endpoint_config=None, - endpoint_spec=None + endpoint_spec=None, rollback_config=None ): """ Create a service. @@ -120,6 +129,8 @@ class ServiceApiMixin(object): or global). Defaults to replicated. update_config (UpdateConfig): Specification for the update strategy of the service. Default: ``None`` + rollback_config (RollbackConfig): Specification for the rollback + strategy of the service. Default: ``None`` networks (:py:class:`list`): List of network names or IDs to attach the service to. Default: ``None``. endpoint_spec (EndpointSpec): Properties that can be configured to @@ -135,7 +146,8 @@ class ServiceApiMixin(object): """ _check_api_features( - self._version, task_template, update_config, endpoint_spec + self._version, task_template, update_config, endpoint_spec, + rollback_config ) url = self._url('/services/create') @@ -166,6 +178,9 @@ class ServiceApiMixin(object): if update_config is not None: data['UpdateConfig'] = update_config + if rollback_config is not None: + data['RollbackConfig'] = rollback_config + return self._result( self._post_json(url, data=data, headers=headers), True ) @@ -342,7 +357,8 @@ class ServiceApiMixin(object): def update_service(self, service, version, task_template=None, name=None, labels=None, mode=None, update_config=None, networks=None, endpoint_config=None, - endpoint_spec=None, fetch_current_spec=False): + endpoint_spec=None, fetch_current_spec=False, + rollback_config=None): """ Update a service. @@ -360,6 +376,8 @@ class ServiceApiMixin(object): or global). Defaults to replicated. update_config (UpdateConfig): Specification for the update strategy of the service. Default: ``None``. + rollback_config (RollbackConfig): Specification for the rollback + strategy of the service. Default: ``None`` networks (:py:class:`list`): List of network names or IDs to attach the service to. Default: ``None``. endpoint_spec (EndpointSpec): Properties that can be configured to @@ -376,7 +394,8 @@ class ServiceApiMixin(object): """ _check_api_features( - self._version, task_template, update_config, endpoint_spec + self._version, task_template, update_config, endpoint_spec, + rollback_config ) if fetch_current_spec: @@ -422,6 +441,11 @@ class ServiceApiMixin(object): else: data['UpdateConfig'] = current.get('UpdateConfig') + if rollback_config is not None: + data['RollbackConfig'] = rollback_config + else: + data['RollbackConfig'] = current.get('RollbackConfig') + if networks is not None: converted_networks = utils.convert_service_networks(networks) if utils.version_lt(self._version, '1.25'): diff --git a/docker/models/services.py b/docker/models/services.py index 7fbd1651..fa029f36 100644 --- a/docker/models/services.py +++ b/docker/models/services.py @@ -183,6 +183,8 @@ class ServiceCollection(Collection): containers to terminate before forcefully killing them. update_config (UpdateConfig): Specification for the update strategy of the service. Default: ``None`` + rollback_config (RollbackConfig): Specification for the rollback + strategy of the service. Default: ``None`` user (str): User to run commands as. workdir (str): Working directory for commands to run. tty (boolean): Whether a pseudo-TTY should be allocated. diff --git a/docker/types/__init__.py b/docker/types/__init__.py index 0b0d847f..64512333 100644 --- a/docker/types/__init__.py +++ b/docker/types/__init__.py @@ -5,7 +5,7 @@ from .healthcheck import Healthcheck from .networks import EndpointConfig, IPAMConfig, IPAMPool, NetworkingConfig from .services import ( ConfigReference, ContainerSpec, DNSConfig, DriverConfig, EndpointSpec, - Mount, Placement, Privileges, Resources, RestartPolicy, SecretReference, - ServiceMode, TaskTemplate, UpdateConfig + Mount, Placement, Privileges, Resources, RestartPolicy, RollbackConfig, + SecretReference, ServiceMode, TaskTemplate, UpdateConfig ) from .swarm import SwarmSpec, SwarmExternalCA diff --git a/docker/types/services.py b/docker/types/services.py index a883f3ff..c66d41a1 100644 --- a/docker/types/services.py +++ b/docker/types/services.py @@ -414,6 +414,30 @@ class UpdateConfig(dict): self['Order'] = order +class RollbackConfig(UpdateConfig): + """ + Used to specify the way containe rollbacks should be performed by a service + + Args: + parallelism (int): Maximum number of tasks to be rolled back in one + iteration (0 means unlimited parallelism). Default: 0 + delay (int): Amount of time between rollbacks, in nanoseconds. + failure_action (string): Action to take if a rolled back task fails to + run, or stops running during the rollback. Acceptable values are + ``continue``, ``pause`` or ``rollback``. + Default: ``continue`` + monitor (int): Amount of time to monitor each rolled back task for + failures, in nanoseconds. + max_failure_ratio (float): The fraction of tasks that may fail during + a rollback before the failure action is invoked, specified as a + floating point number between 0 and 1. Default: 0 + order (string): Specifies the order of operations when rolling out a + rolled back task. Either ``start_first`` or ``stop_first`` are + accepted. + """ + pass + + class RestartConditionTypesEnum(object): _values = ( 'none', diff --git a/tests/integration/api_service_test.py b/tests/integration/api_service_test.py index ba2ed91f..a53ca1c8 100644 --- a/tests/integration/api_service_test.py +++ b/tests/integration/api_service_test.py @@ -312,6 +312,27 @@ class ServiceTest(BaseAPIIntegrationTest): assert update_config['Monitor'] == uc['Monitor'] assert update_config['MaxFailureRatio'] == uc['MaxFailureRatio'] + @requires_api_version('1.28') + def test_create_service_with_rollback_config(self): + container_spec = docker.types.ContainerSpec(BUSYBOX, ['true']) + task_tmpl = docker.types.TaskTemplate(container_spec) + rollback_cfg = docker.types.RollbackConfig( + parallelism=10, delay=5, failure_action='pause', + monitor=300000000, max_failure_ratio=0.4 + ) + name = self.get_service_name() + svc_id = self.client.create_service( + task_tmpl, rollback_config=rollback_cfg, name=name + ) + svc_info = self.client.inspect_service(svc_id) + assert 'RollbackConfig' in svc_info['Spec'] + rc = svc_info['Spec']['RollbackConfig'] + assert rollback_cfg['Parallelism'] == rc['Parallelism'] + assert rollback_cfg['Delay'] == rc['Delay'] + assert rollback_cfg['FailureAction'] == rc['FailureAction'] + assert rollback_cfg['Monitor'] == rc['Monitor'] + assert rollback_cfg['MaxFailureRatio'] == rc['MaxFailureRatio'] + def test_create_service_with_restart_policy(self): container_spec = docker.types.ContainerSpec(BUSYBOX, ['true']) policy = docker.types.RestartPolicy( From 205a2f76bd7791dcef52e8b1f3acd9b95bc29ad3 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 9 Aug 2018 17:28:06 -0700 Subject: [PATCH 17/25] Bump dev version 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 022daffd..a274307c 100644 --- a/docker/version.py +++ b/docker/version.py @@ -1,2 +1,2 @@ -version = "3.5.0" +version = "3.6.0-dev" version_info = tuple([int(d) for d in version.split("-")[0].split(".")]) From 87d72c0f6cf726764a1cf8aebc527c64d46cecfb Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 9 Aug 2018 17:28:35 -0700 Subject: [PATCH 18/25] Misc release script improvements Signed-off-by: Joffrey F --- scripts/release.sh | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/scripts/release.sh b/scripts/release.sh index f36efff9..5b37b6d0 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -22,7 +22,15 @@ echo "##> Removing stale build files" rm -rf ./build || exit 1 echo "##> Tagging the release as $VERSION" -git tag $VERSION || exit 1 +git tag $VERSION +if [[ $? != 0 ]]; then + head_commit=$(git show --pretty=format:%H HEAD) + tag_commit=$(git show --pretty=format:%H $VERSION) + if [[ $head_commit != $tag_commit ]]; then + echo "ERROR: tag already exists, but isn't the current HEAD" + exit 1 + fi +fi if [[ $2 == 'upload' ]]; then echo "##> Pushing tag to github" git push $GITHUB_REPO $VERSION || exit 1 @@ -30,10 +38,10 @@ fi pandoc -f markdown -t rst README.md -o README.rst || exit 1 +echo "##> sdist & wheel" +python setup.py sdist bdist_wheel + 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 + echo '##> Uploading sdist to pypi' + twine upload dist/docker-$VERSION* +fi \ No newline at end of file From e78e4e7491da7055151bfe454282770786a8c270 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 9 Aug 2018 17:33:22 -0700 Subject: [PATCH 19/25] Add RollbackConfig to API docs Signed-off-by: Joffrey F --- docs/api.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/api.rst b/docs/api.rst index ff466a17..69312457 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -145,6 +145,7 @@ Configuration types .. autoclass:: Privileges .. autoclass:: Resources .. autoclass:: RestartPolicy +.. autoclass:: RollbackConfig .. autoclass:: SecretReference .. autoclass:: ServiceMode .. autoclass:: SwarmExternalCA From 67308c1e55b8cc93276a52a2306d174e52b04cd4 Mon Sep 17 00:00:00 2001 From: Ben Firshman Date: Sun, 12 Aug 2018 13:00:16 +0400 Subject: [PATCH 20/25] Document defaults of logs() This is not obvious because some are True by default. Signed-off-by: Ben Firshman --- docker/api/container.py | 10 +++++----- docker/models/containers.py | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docker/api/container.py b/docker/api/container.py index d8416066..d02ad78d 100644 --- a/docker/api/container.py +++ b/docker/api/container.py @@ -765,16 +765,16 @@ class ContainerApiMixin(object): Args: container (str): The container to get logs from - stdout (bool): Get ``STDOUT`` - stderr (bool): Get ``STDERR`` - stream (bool): Stream the response - timestamps (bool): Show timestamps + stdout (bool): Get ``STDOUT``. Default ``True`` + stderr (bool): Get ``STDERR``. Default ``True`` + stream (bool): Stream the response. Default ``False`` + timestamps (bool): Show timestamps. Default ``False`` tail (str or int): Output specified number of lines at the end of logs. Either an integer of number of lines or the string ``all``. Default ``all`` since (datetime or int): Show logs since a given datetime or integer epoch (in seconds) - follow (bool): Follow log output + follow (bool): Follow log output. Default ``False`` until (datetime or int): Show logs that occurred before the given datetime or integer epoch (in seconds) diff --git a/docker/models/containers.py b/docker/models/containers.py index de6222ec..14545a76 100644 --- a/docker/models/containers.py +++ b/docker/models/containers.py @@ -253,16 +253,16 @@ class Container(Model): generator you can iterate over to retrieve log output as it happens. Args: - stdout (bool): Get ``STDOUT`` - stderr (bool): Get ``STDERR`` - stream (bool): Stream the response - timestamps (bool): Show timestamps + stdout (bool): Get ``STDOUT``. Default ``True`` + stderr (bool): Get ``STDERR``. Default ``True`` + stream (bool): Stream the response. Default ``False`` + timestamps (bool): Show timestamps. Default ``False`` tail (str or int): Output specified number of lines at the end of logs. Either an integer of number of lines or the string ``all``. Default ``all`` since (datetime or int): Show logs since a given datetime or integer epoch (in seconds) - follow (bool): Follow log output + follow (bool): Follow log output. Default ``False`` until (datetime or int): Show logs that occurred before the given datetime or integer epoch (in seconds) From 74a293a9c92e30ff8e7927694a147ca293fbf686 Mon Sep 17 00:00:00 2001 From: adw1n Date: Mon, 3 Sep 2018 02:49:16 +0200 Subject: [PATCH 21/25] Fix docs for `chunk_size` parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #2122 Signed-off-by: Przemysław Adamek --- docker/models/images.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docker/models/images.py b/docker/models/images.py index 41632c6a..7d9ab70b 100644 --- a/docker/models/images.py +++ b/docker/models/images.py @@ -64,9 +64,9 @@ class Image(Model): Get a tarball of an image. Similar to the ``docker save`` command. Args: - chunk_size (int): The number of bytes returned by each iteration - of the generator. If ``None``, data will be streamed as it is - received. Default: 2 MB + chunk_size (int): The generator will return up to that much data + per iteration, but may return less. If ``None``, data will be + streamed as it is received. Default: 2 MB Returns: (generator): A stream of raw archive data. From 2b10c3773c1f48d16d395f1f08a7a93419ab0790 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Fri, 14 Sep 2018 16:58:11 -0700 Subject: [PATCH 22/25] Fix docs for Service objects Signed-off-by: Joffrey F --- docker/models/services.py | 49 ++++++++++++++++++++------------------- docs/services.rst | 3 +++ 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/docker/models/services.py b/docker/models/services.py index fa029f36..a2a3ed01 100644 --- a/docker/models/services.py +++ b/docker/models/services.py @@ -42,7 +42,7 @@ class Service(Model): ``label``, and ``desired-state``. Returns: - (:py:class:`list`): List of task dictionaries. + :py:class:`list`: List of task dictionaries. Raises: :py:class:`docker.errors.APIError` @@ -84,26 +84,27 @@ class Service(Model): def logs(self, **kwargs): """ - Get log stream for the service. - Note: This method works only for services with the ``json-file`` - or ``journald`` logging drivers. + Get log stream for the service. + Note: This method works only for services with the ``json-file`` + or ``journald`` logging drivers. - Args: - details (bool): Show extra details provided to logs. - Default: ``False`` - follow (bool): Keep connection open to read logs as they are - sent by the Engine. Default: ``False`` - stdout (bool): Return logs from ``stdout``. Default: ``False`` - stderr (bool): Return logs from ``stderr``. Default: ``False`` - since (int): UNIX timestamp for the logs staring point. - Default: 0 - timestamps (bool): Add timestamps to every log line. - tail (string or int): Number of log lines to be returned, - counting from the current end of the logs. Specify an - integer or ``'all'`` to output all log lines. - Default: ``all`` + Args: + details (bool): Show extra details provided to logs. + Default: ``False`` + follow (bool): Keep connection open to read logs as they are + sent by the Engine. Default: ``False`` + stdout (bool): Return logs from ``stdout``. Default: ``False`` + stderr (bool): Return logs from ``stderr``. Default: ``False`` + since (int): UNIX timestamp for the logs staring point. + Default: 0 + timestamps (bool): Add timestamps to every log line. + tail (string or int): Number of log lines to be returned, + counting from the current end of the logs. Specify an + integer or ``'all'`` to output all log lines. + Default: ``all`` - Returns (generator): Logs for the service. + Returns: + generator: Logs for the service. """ is_tty = self.attrs['Spec']['TaskTemplate']['ContainerSpec'].get( 'TTY', False @@ -118,7 +119,7 @@ class Service(Model): replicas (int): The number of containers that should be running. Returns: - ``True``if successful. + bool: ``True`` if successful. """ if 'Global' in self.attrs['Spec']['Mode'].keys(): @@ -134,7 +135,7 @@ class Service(Model): Force update the service even if no changes require it. Returns: - ``True``if successful. + bool: ``True`` if successful. """ return self.update(force_update=True, fetch_current_spec=True) @@ -206,7 +207,7 @@ class ServiceCollection(Collection): containers. Returns: - (:py:class:`Service`) The created service. + :py:class:`Service`: The created service. Raises: :py:class:`docker.errors.APIError` @@ -228,7 +229,7 @@ class ServiceCollection(Collection): into the output. Returns: - (:py:class:`Service`): The service. + :py:class:`Service`: The service. Raises: :py:class:`docker.errors.NotFound` @@ -253,7 +254,7 @@ class ServiceCollection(Collection): Default: ``None``. Returns: - (list of :py:class:`Service`): The services. + list of :py:class:`Service`: The services. Raises: :py:class:`docker.errors.APIError` diff --git a/docs/services.rst b/docs/services.rst index d8e52854..8f444288 100644 --- a/docs/services.rst +++ b/docs/services.rst @@ -30,7 +30,10 @@ Service objects The raw representation of this object from the server. + .. automethod:: force_update + .. automethod:: logs .. automethod:: reload .. automethod:: remove + .. automethod:: scale .. automethod:: tasks .. automethod:: update From 46a9b10b634f70d56662c9e5f834ba35b238321a Mon Sep 17 00:00:00 2001 From: Rui Cao Date: Thu, 27 Sep 2018 21:10:36 +0800 Subject: [PATCH 23/25] Fix typo: Addtional -> Additional Signed-off-by: Rui Cao --- docker/api/container.py | 2 +- docker/models/containers.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/api/container.py b/docker/api/container.py index d02ad78d..c59a6d01 100644 --- a/docker/api/container.py +++ b/docker/api/container.py @@ -465,7 +465,7 @@ class ContainerApiMixin(object): dns_opt (:py:class:`list`): Additional options to be added to the container's ``resolv.conf`` file dns_search (:py:class:`list`): DNS search domains. - extra_hosts (dict): Addtional hostnames to resolve inside the + extra_hosts (dict): Additional hostnames to resolve inside the container, as a mapping of hostname to IP address. group_add (:py:class:`list`): List of additional group names and/or IDs that the container process will run as. diff --git a/docker/models/containers.py b/docker/models/containers.py index 14545a76..f60ba6e2 100644 --- a/docker/models/containers.py +++ b/docker/models/containers.py @@ -558,7 +558,7 @@ class ContainerCollection(Collection): environment (dict or list): Environment variables to set inside the container, as a dictionary or a list of strings in the format ``["SOMEVARIABLE=xxx"]``. - extra_hosts (dict): Addtional hostnames to resolve inside the + extra_hosts (dict): Additional hostnames to resolve inside the container, as a mapping of hostname to IP address. group_add (:py:class:`list`): List of additional group names and/or IDs that the container process will run as. From 609045f343ac628f953bb3a8fe5b201700929b5c Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 17 Oct 2018 13:52:39 -0700 Subject: [PATCH 24/25] Bump pyopenssl to prevent installation of vulnerable version CVE refs: CVE-2018-1000807 CVE-2018-1000808 Signed-off-by: Joffrey F --- requirements.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 289dea91..c46a021e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ idna==2.5 ipaddress==1.0.18 packaging==16.8 pycparser==2.17 -pyOpenSSL==17.0.0 +pyOpenSSL==18.0.0 pyparsing==2.2.0 pypiwin32==219; sys_platform == 'win32' and python_version < '3.6' pypiwin32==223; sys_platform == 'win32' and python_version >= '3.6' diff --git a/setup.py b/setup.py index 1b208e5c..390783d5 100644 --- a/setup.py +++ b/setup.py @@ -40,7 +40,7 @@ extras_require = { # https://github.com/pypa/pip/issues/4391). Once that's fixed, instead of # installing the extra dependencies, install the following instead: # 'requests[security] >= 2.5.2, != 2.11.0, != 2.12.2' - 'tls': ['pyOpenSSL>=0.14', 'cryptography>=1.3.4', 'idna>=2.0.0'], + 'tls': ['pyOpenSSL>=17.5.0', 'cryptography>=1.3.4', 'idna>=2.0.0'], } From f097ea5b9846471437ed06750dfc0bc52e82e055 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 17 Oct 2018 14:38:02 -0700 Subject: [PATCH 25/25] Bump 3.5.1 Signed-off-by: Joffrey F --- docker/version.py | 2 +- docs/change-log.md | 14 +++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/docker/version.py b/docker/version.py index a274307c..ef6b491c 100644 --- a/docker/version.py +++ b/docker/version.py @@ -1,2 +1,2 @@ -version = "3.6.0-dev" +version = "3.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 1b2d620f..750afb9b 100644 --- a/docs/change-log.md +++ b/docs/change-log.md @@ -1,10 +1,22 @@ Change log ========== +3.5.1 +----- + +[List of PRs / issues for this release](https://github.com/docker/docker-py/milestone/54?closed=1) + +### Miscellaneous + +* Bumped version of `pyOpenSSL` in `requirements.txt` and `setup.py` to prevent + installation of a vulnerable version + +* Docs fixes + 3.5.0 ----- -[List of PRs / issues for this release](https://github.com/docker/docker-py/milestone=53?closed=1) +[List of PRs / issues for this release](https://github.com/docker/docker-py/milestone/53?closed=1) ### Deprecation warning