From d74f1bc3805176af229ae3d2e9e5dbe1f0b40a15 Mon Sep 17 00:00:00 2001 From: Tzu-Chiao Yeh Date: Sun, 13 Aug 2017 09:01:50 +0000 Subject: [PATCH 01/30] Fix #1575 - Add cpu_rt_period and cpu_rt_runtime args Add cpu_rt_period and cpu_rt_runtime in hostconfig with version(1.25), types(int) checks. Also add version and type checks in dockertype unit test. Signed-off-by: Tzu-Chiao Yeh --- docker/models/containers.py | 2 ++ docker/types/containers.py | 23 ++++++++++++++++++++++- tests/unit/dockertypes_test.py | 22 ++++++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/docker/models/containers.py b/docker/models/containers.py index a3598f28f..204369643 100644 --- a/docker/models/containers.py +++ b/docker/models/containers.py @@ -861,6 +861,8 @@ def prune(self, filters=None): 'cpu_shares', 'cpuset_cpus', 'cpuset_mems', + 'cpu_rt_period', + 'cpu_rt_runtime', 'device_read_bps', 'device_read_iops', 'device_write_bps', diff --git a/docker/types/containers.py b/docker/types/containers.py index 030e292bc..fee93c075 100644 --- a/docker/types/containers.py +++ b/docker/types/containers.py @@ -120,7 +120,8 @@ def __init__(self, version, binds=None, port_bindings=None, isolation=None, auto_remove=False, storage_opt=None, init=None, init_path=None, volume_driver=None, cpu_count=None, cpu_percent=None, nano_cpus=None, - cpuset_mems=None, runtime=None): + cpuset_mems=None, runtime=None, cpu_rt_period=None, + cpu_rt_runtime=None): if mem_limit is not None: self['Memory'] = parse_bytes(mem_limit) @@ -339,6 +340,26 @@ def __init__(self, version, binds=None, port_bindings=None, ) self['CpusetMems'] = cpuset_mems + if cpu_rt_period: + if version_lt(version, '1.25'): + raise host_config_version_error('cpu_rt_period', '1.25') + + if not isinstance(cpu_rt_period, int): + raise host_config_type_error( + 'cpu_rt_period', cpu_rt_period, 'int' + ) + self['CPURealtimePeriod'] = cpu_rt_period + + if cpu_rt_runtime: + if version_lt(version, '1.25'): + raise host_config_version_error('cpu_rt_runtime', '1.25') + + if not isinstance(cpu_rt_runtime, int): + raise host_config_type_error( + 'cpu_rt_runtime', cpu_rt_runtime, 'int' + ) + self['CPURealtimeRuntime'] = cpu_rt_runtime + if blkio_weight: if not isinstance(blkio_weight, int): raise host_config_type_error( diff --git a/tests/unit/dockertypes_test.py b/tests/unit/dockertypes_test.py index 8dbb35ecc..40adbb782 100644 --- a/tests/unit/dockertypes_test.py +++ b/tests/unit/dockertypes_test.py @@ -206,6 +206,28 @@ def test_create_host_config_with_nano_cpus(self): InvalidVersion, lambda: create_host_config( version='1.24', nano_cpus=1)) + def test_create_host_config_with_cpu_rt_period_types(self): + with pytest.raises(TypeError): + create_host_config(version='1.25', cpu_rt_period='1000') + + def test_create_host_config_with_cpu_rt_period(self): + config = create_host_config(version='1.25', cpu_rt_period=1000) + self.assertEqual(config.get('CPURealtimePeriod'), 1000) + self.assertRaises( + InvalidVersion, lambda: create_host_config( + version='1.24', cpu_rt_period=1000)) + + def test_ctrate_host_config_with_cpu_rt_runtime_types(self): + with pytest.raises(TypeError): + create_host_config(version='1.25', cpu_rt_runtime='1000') + + def test_create_host_config_with_cpu_rt_runtime(self): + config = create_host_config(version='1.25', cpu_rt_runtime=1000) + self.assertEqual(config.get('CPURealtimeRuntime'), 1000) + self.assertRaises( + InvalidVersion, lambda: create_host_config( + version='1.24', cpu_rt_runtime=1000)) + class ContainerConfigTest(unittest.TestCase): def test_create_container_config_volume_driver_warning(self): From 7fa2cb7be339e575ade092442f94fcf40a19e6a0 Mon Sep 17 00:00:00 2001 From: Maxime Belanger Date: Thu, 24 Aug 2017 16:15:30 -0400 Subject: [PATCH 02/30] Add join_swarm default listen address Since the docker CLI adds a default listen address (0.0.0.0:2377) when joining a node to the swarm, the docker-py api will support the same behavior to easy configuration. Signed-off-by: Maxime Belanger --- docker/api/swarm.py | 2 +- tests/unit/fake_api.py | 6 ++++++ tests/unit/swarm_test.py | 43 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/docker/api/swarm.py b/docker/api/swarm.py index 4fa0c4a12..cab7a45e5 100644 --- a/docker/api/swarm.py +++ b/docker/api/swarm.py @@ -137,7 +137,7 @@ def inspect_node(self, node_id): return self._result(self._get(url), True) @utils.minimum_version('1.24') - def join_swarm(self, remote_addrs, join_token, listen_addr=None, + def join_swarm(self, remote_addrs, join_token, listen_addr='0.0.0.0:2377', advertise_addr=None): """ Make this Engine join a swarm that has already been created. diff --git a/tests/unit/fake_api.py b/tests/unit/fake_api.py index 2ba85bbf5..b3051886b 100644 --- a/tests/unit/fake_api.py +++ b/tests/unit/fake_api.py @@ -435,6 +435,10 @@ def post_fake_update_node(): return 200, None +def post_fake_join_swarm(): + return 200, None + + def get_fake_network_list(): return 200, [{ "Name": "bridge", @@ -599,6 +603,8 @@ def post_fake_network_disconnect(): CURRENT_VERSION, prefix, FAKE_NODE_ID ), 'POST'): post_fake_update_node, + ('{1}/{0}/swarm/join'.format(CURRENT_VERSION, prefix), 'POST'): + post_fake_join_swarm, ('{1}/{0}/networks'.format(CURRENT_VERSION, prefix), 'GET'): get_fake_network_list, ('{1}/{0}/networks/create'.format(CURRENT_VERSION, prefix), 'POST'): diff --git a/tests/unit/swarm_test.py b/tests/unit/swarm_test.py index 374f8b247..9a66c0c04 100644 --- a/tests/unit/swarm_test.py +++ b/tests/unit/swarm_test.py @@ -30,3 +30,46 @@ def test_node_update(self): self.assertEqual( args[1]['headers']['Content-Type'], 'application/json' ) + + @requires_api_version('1.24') + def test_join_swarm(self): + remote_addr = ['1.2.3.4:2377'] + listen_addr = '2.3.4.5:2377' + join_token = 'A_BEAUTIFUL_JOIN_TOKEN' + + data = { + 'RemoteAddrs': remote_addr, + 'ListenAddr': listen_addr, + 'JoinToken': join_token + } + + self.client.join_swarm( + remote_addrs=remote_addr, + listen_addr=listen_addr, + join_token=join_token + ) + + args = fake_request.call_args + + assert (args[0][1] == url_prefix + 'swarm/join') + assert (json.loads(args[1]['data']) == data) + assert (args[1]['headers']['Content-Type'] == 'application/json') + + @requires_api_version('1.24') + def test_join_swarm_no_listen_address_takes_default(self): + remote_addr = ['1.2.3.4:2377'] + join_token = 'A_BEAUTIFUL_JOIN_TOKEN' + + data = { + 'RemoteAddrs': remote_addr, + 'ListenAddr': '0.0.0.0:2377', + 'JoinToken': join_token + } + + self.client.join_swarm(remote_addrs=remote_addr, join_token=join_token) + + args = fake_request.call_args + + assert (args[0][1] == url_prefix + 'swarm/join') + assert (json.loads(args[1]['data']) == data) + assert (args[1]['headers']['Content-Type'] == 'application/json') From ff86324c4f51495b0f0395e4cb28c83a37d4d3e0 Mon Sep 17 00:00:00 2001 From: timvisee Date: Thu, 2 Nov 2017 14:30:18 +0100 Subject: [PATCH 03/30] Require at least requests v2.14.2 to fix chardet Signed-off-by: timvisee --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 4a33c8df0..d59d8124b 100644 --- a/setup.py +++ b/setup.py @@ -20,7 +20,7 @@ SOURCE_DIR = os.path.join(ROOT_DIR) requirements = [ - 'requests >= 2.5.2, != 2.11.0, != 2.12.2, != 2.18.0', + 'requests >= 2.14.2, != 2.18.0', 'six >= 1.4.0', 'websocket-client >= 0.32.0', 'docker-pycreds >= 0.2.1' From aa3c4f026d435af98391568c30998414fe2baedf Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 7 Nov 2017 15:19:07 -0800 Subject: [PATCH 04/30] Add unlock_swarm and get_unlock_key to APIClient Signed-off-by: Joffrey F --- docker/api/swarm.py | 50 ++++++++++++++++++++++++++++- tests/integration/api_swarm_test.py | 10 ++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/docker/api/swarm.py b/docker/api/swarm.py index 576fd79bf..26ec75a91 100644 --- a/docker/api/swarm.py +++ b/docker/api/swarm.py @@ -1,7 +1,9 @@ import logging from six.moves import http_client +from .. import errors from .. import types from .. import utils + log = logging.getLogger(__name__) @@ -68,6 +70,16 @@ def create_swarm_spec(self, *args, **kwargs): kwargs['external_cas'] = [ext_ca] return types.SwarmSpec(self._version, *args, **kwargs) + @utils.minimum_version('1.24') + def get_unlock_key(self): + """ + Get the unlock key for this Swarm manager. + + Returns: + A ``dict`` containing an ``UnlockKey`` member + """ + return self._result(self._get(self._url('/swarm/unlockkey')), True) + @utils.minimum_version('1.24') def init_swarm(self, advertise_addr=None, listen_addr='0.0.0.0:2377', force_new_cluster=False, swarm_spec=None): @@ -270,10 +282,46 @@ def remove_node(self, node_id, force=False): self._raise_for_status(res) return True + @utils.minimum_version('1.24') + def unlock_swarm(self, key): + """ + Unlock a locked swarm. + + Args: + key (string): The unlock key as provided by + :py:meth:`get_unlock_key` + + Raises: + :py:class:`docker.errors.InvalidArgument` + If the key argument is in an incompatible format + + :py:class:`docker.errors.APIError` + If the server returns an error. + + Returns: + `True` if the request was successful. + + Example: + + >>> key = client.get_unlock_key() + >>> client.unlock_node(key) + + """ + if isinstance(key, dict): + if 'UnlockKey' not in key: + raise errors.InvalidArgument('Invalid unlock key format') + else: + key = {'UnlockKey': key} + + url = self._url('/swarm/unlock') + res = self._post_json(url, data=key) + self._raise_for_status(res) + return True + @utils.minimum_version('1.24') def update_node(self, node_id, version, node_spec=None): """ - Update the Node's configuration + Update the node's configuration Args: diff --git a/tests/integration/api_swarm_test.py b/tests/integration/api_swarm_test.py index 34b0879ce..1a945c5f2 100644 --- a/tests/integration/api_swarm_test.py +++ b/tests/integration/api_swarm_test.py @@ -13,6 +13,13 @@ def setUp(self): def tearDown(self): super(SwarmTest, self).tearDown() + try: + unlock_key = self.client.get_unlock_key() + if unlock_key.get('UnlockKey'): + self.unlock_swarm(unlock_key) + except docker.errors.APIError: + pass + force_leave_swarm(self.client) @requires_api_version('1.24') @@ -70,6 +77,9 @@ def test_init_swarm_with_autolock_managers(self): swarm_info['Spec']['EncryptionConfig']['AutoLockManagers'] is True ) + unlock_key = self.get_unlock_key() + assert unlock_key.get('UnlockKey') + @requires_api_version('1.25') @pytest.mark.xfail( reason="This doesn't seem to be taken into account by the engine" From 3bd053a4b703156e5e1f66e3e1b4c72beada2b33 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 7 Nov 2017 15:29:53 -0800 Subject: [PATCH 05/30] Add unlock methods to Swarm model Signed-off-by: Joffrey F --- docker/models/swarm.py | 8 ++++++++ docs/swarm.rst | 2 ++ tests/integration/api_swarm_test.py | 11 ++++++----- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/docker/models/swarm.py b/docker/models/swarm.py index 5a253c57b..7396e730d 100644 --- a/docker/models/swarm.py +++ b/docker/models/swarm.py @@ -29,6 +29,10 @@ def version(self): """ return self.attrs.get('Version').get('Index') + def get_unlock_key(self): + return self.client.api.get_unlock_key() + get_unlock_key.__doc__ = APIClient.get_unlock_key.__doc__ + def init(self, advertise_addr=None, listen_addr='0.0.0.0:2377', force_new_cluster=False, **kwargs): """ @@ -128,6 +132,10 @@ def reload(self): """ self.attrs = self.client.api.inspect_swarm() + def unlock(self, key): + return self.client.api.unlock_swarm(key) + unlock.__doc__ = APIClient.unlock_swarm.__doc__ + def update(self, rotate_worker_token=False, rotate_manager_token=False, **kwargs): """ diff --git a/docs/swarm.rst b/docs/swarm.rst index 0c21bae1a..cab9def70 100644 --- a/docs/swarm.rst +++ b/docs/swarm.rst @@ -12,9 +12,11 @@ These methods are available on ``client.swarm``: .. rst-class:: hide-signature .. py:class:: Swarm + .. automethod:: get_unlock_key() .. automethod:: init() .. automethod:: join() .. automethod:: leave() + .. automethod:: unlock() .. automethod:: update() .. automethod:: reload() diff --git a/tests/integration/api_swarm_test.py b/tests/integration/api_swarm_test.py index 1a945c5f2..56b012967 100644 --- a/tests/integration/api_swarm_test.py +++ b/tests/integration/api_swarm_test.py @@ -10,13 +10,13 @@ class SwarmTest(BaseAPIIntegrationTest): def setUp(self): super(SwarmTest, self).setUp() force_leave_swarm(self.client) + self._unlock_key = None def tearDown(self): super(SwarmTest, self).tearDown() try: - unlock_key = self.client.get_unlock_key() - if unlock_key.get('UnlockKey'): - self.unlock_swarm(unlock_key) + if self._unlock_key: + self.client.unlock_swarm(self._unlock_key) except docker.errors.APIError: pass @@ -71,14 +71,15 @@ def test_init_swarm_with_ca_config(self): def test_init_swarm_with_autolock_managers(self): spec = self.client.create_swarm_spec(autolock_managers=True) assert self.init_swarm(swarm_spec=spec) + # save unlock key for tearDown + self._unlock_key = self.client.get_unlock_key() swarm_info = self.client.inspect_swarm() assert ( swarm_info['Spec']['EncryptionConfig']['AutoLockManagers'] is True ) - unlock_key = self.get_unlock_key() - assert unlock_key.get('UnlockKey') + assert self._unlock_key.get('UnlockKey') @requires_api_version('1.25') @pytest.mark.xfail( From c7f1b5f84f9b574de370ef0bb00e84ad3b8a556f Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 8 Nov 2017 17:04:53 -0800 Subject: [PATCH 06/30] 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 87c864313..fd8224617 100644 --- a/docker/version.py +++ b/docker/version.py @@ -1,2 +1,2 @@ -version = "2.6.1" +version = "2.7.0-dev" version_info = tuple([int(d) for d in version.split("-")[0].split(".")]) From e6cc3c15400a386efff2c7672758d424637b7c14 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Sat, 11 Nov 2017 03:07:27 +0100 Subject: [PATCH 07/30] Remove test_update_swarm_name Docker currently only supports the "default" cluster in Swarm-mode, and an upcoming SwarmKit release will produce an error if the name of the cluster is updated, causing the test to fail. Given that renaming the cluster is not supported, this patch removes the test Signed-off-by: Sebastiaan van Stijn --- tests/integration/api_swarm_test.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/tests/integration/api_swarm_test.py b/tests/integration/api_swarm_test.py index 34b0879ce..4b00dd73e 100644 --- a/tests/integration/api_swarm_test.py +++ b/tests/integration/api_swarm_test.py @@ -126,24 +126,6 @@ def test_update_swarm(self): swarm_info_2['JoinTokens']['Worker'] ) - @requires_api_version('1.24') - def test_update_swarm_name(self): - assert self.init_swarm() - swarm_info_1 = self.client.inspect_swarm() - spec = self.client.create_swarm_spec( - node_cert_expiry=7776000000000000, name='reimuhakurei' - ) - assert self.client.update_swarm( - version=swarm_info_1['Version']['Index'], swarm_spec=spec - ) - swarm_info_2 = self.client.inspect_swarm() - - assert ( - swarm_info_1['Version']['Index'] != - swarm_info_2['Version']['Index'] - ) - assert swarm_info_2['Spec']['Name'] == 'reimuhakurei' - @requires_api_version('1.24') def test_list_nodes(self): assert self.init_swarm() From 6e5eb2eba707f95d03168dfa1384127134260aae Mon Sep 17 00:00:00 2001 From: Viktor Adam Date: Tue, 14 Nov 2017 21:10:23 +0000 Subject: [PATCH 08/30] Update service using previous spec Signed-off-by: Viktor Adam --- docker/api/service.py | 52 ++++- tests/integration/api_service_test.py | 313 ++++++++++++++++++++++++++ 2 files changed, 357 insertions(+), 8 deletions(-) diff --git a/docker/api/service.py b/docker/api/service.py index 4c10ef8ef..8e39ff510 100644 --- a/docker/api/service.py +++ b/docker/api/service.py @@ -306,7 +306,7 @@ def tasks(self, filters=None): def update_service(self, service, version, task_template=None, name=None, labels=None, mode=None, update_config=None, networks=None, endpoint_config=None, - endpoint_spec=None): + endpoint_spec=None, use_current_spec=False): """ Update a service. @@ -328,6 +328,8 @@ def update_service(self, service, version, task_template=None, name=None, the service to. Default: ``None``. endpoint_spec (EndpointSpec): Properties that can be configured to access and load balance a service. Default: ``None``. + use_current_spec (boolean): Use the undefined settings from the + previous specification of the service. Default: ``False`` Returns: ``True`` if successful. @@ -345,32 +347,66 @@ def update_service(self, service, version, task_template=None, name=None, _check_api_features(self._version, task_template, update_config) + if use_current_spec: + if utils.version_lt(self._version, '1.29'): + inspect_defaults = None + else: + inspect_defaults = True + current = self.inspect_service( + service, insert_defaults=inspect_defaults + )['Spec'] + + else: + current = {} + url = self._url('/services/{0}/update', service) data = {} headers = {} - if name is not None: - data['Name'] = name - if labels is not None: - data['Labels'] = labels + + data['Name'] = name if name is not None else current.get('Name') + data['Labels'] = labels if labels is not None else current.get('Labels') + if mode is not None: if not isinstance(mode, dict): mode = ServiceMode(mode) data['Mode'] = mode + else: + data['Mode'] = current.get('Mode') + + merged_task_template = current.get('TaskTemplate', {}) if task_template is not None: - image = task_template.get('ContainerSpec', {}).get('Image', None) + for task_template_key, task_template_value in task_template.items(): + if task_template_key == 'ContainerSpec': + if 'ContainerSpec' not in merged_task_template: + merged_task_template['ContainerSpec'] = {} + for container_spec_key, container_spec_value in task_template['ContainerSpec'].items(): + merged_task_template['ContainerSpec'][container_spec_key] = container_spec_value + else: + merged_task_template[task_template_key] = task_template_value + image = merged_task_template.get('ContainerSpec', {}).get('Image', None) if image is not None: registry, repo_name = auth.resolve_repository_name(image) auth_header = auth.get_config_header(self, registry) if auth_header: headers['X-Registry-Auth'] = auth_header - data['TaskTemplate'] = task_template + data['TaskTemplate'] = merged_task_template + if update_config is not None: data['UpdateConfig'] = update_config + else: + data['UpdateConfig'] = current.get('UpdateConfig') if networks is not None: - data['Networks'] = utils.convert_service_networks(networks) + data['TaskTemplate']['Networks'] = utils.convert_service_networks(networks) + else: + existing_networks = current.get('TaskTemplate', {}).get('Networks') or current.get('Networks') + if existing_networks is not None: + data['TaskTemplate']['Networks'] = existing_networks + if endpoint_spec is not None: data['EndpointSpec'] = endpoint_spec + else: + data['EndpointSpec'] = current.get('EndpointSpec') resp = self._post_json( url, data=data, params={'version': version}, headers=headers diff --git a/tests/integration/api_service_test.py b/tests/integration/api_service_test.py index b93115494..00ad84cce 100644 --- a/tests/integration/api_service_test.py +++ b/tests/integration/api_service_test.py @@ -710,3 +710,316 @@ def test_create_service_with_privileges(self): svc_info['Spec']['TaskTemplate']['ContainerSpec']['Privileges'] ) assert privileges['SELinuxContext']['Disable'] is True + + @requires_api_version('1.25') + def test_update_service_with_defaults_name(self): + container_spec = docker.types.ContainerSpec( + 'busybox', ['echo', 'hello'] + ) + task_tmpl = docker.types.TaskTemplate(container_spec) + name = self.get_service_name() + svc_id = self.client.create_service(task_tmpl, name=name) + svc_info = self.client.inspect_service(svc_id) + assert 'Name' in svc_info['Spec'] + assert svc_info['Spec']['Name'] == name + version_index = svc_info['Version']['Index'] + + task_tmpl = docker.types.TaskTemplate(container_spec, force_update=10) + self.client.update_service(name, version_index, task_tmpl, use_current_spec=True) + svc_info = self.client.inspect_service(svc_id) + new_index = svc_info['Version']['Index'] + assert new_index > version_index + assert 'Name' in svc_info['Spec'] + assert svc_info['Spec']['Name'] == name + + @requires_api_version('1.25') + def test_update_service_with_defaults_labels(self): + container_spec = docker.types.ContainerSpec( + 'busybox', ['echo', 'hello'] + ) + task_tmpl = docker.types.TaskTemplate(container_spec) + name = self.get_service_name() + svc_id = self.client.create_service(task_tmpl, name=name, labels={'service.label': 'SampleLabel'}) + svc_info = self.client.inspect_service(svc_id) + assert 'Labels' in svc_info['Spec'] + assert 'service.label' in svc_info['Spec']['Labels'] + assert svc_info['Spec']['Labels']['service.label'] == 'SampleLabel' + version_index = svc_info['Version']['Index'] + + task_tmpl = docker.types.TaskTemplate(container_spec, force_update=10) + self.client.update_service(name, version_index, task_tmpl, name=name, use_current_spec=True) + svc_info = self.client.inspect_service(svc_id) + new_index = svc_info['Version']['Index'] + assert new_index > version_index + assert 'Labels' in svc_info['Spec'] + assert 'service.label' in svc_info['Spec']['Labels'] + assert svc_info['Spec']['Labels']['service.label'] == 'SampleLabel' + + def test_update_service_with_defaults_mode(self): + container_spec = docker.types.ContainerSpec( + 'busybox', ['echo', 'hello'] + ) + task_tmpl = docker.types.TaskTemplate(container_spec) + name = self.get_service_name() + svc_id = self.client.create_service(task_tmpl, name=name, + mode=docker.types.ServiceMode(mode='replicated', replicas=2)) + svc_info = self.client.inspect_service(svc_id) + assert 'Mode' in svc_info['Spec'] + assert 'Replicated' in svc_info['Spec']['Mode'] + assert 'Replicas' in svc_info['Spec']['Mode']['Replicated'] + assert svc_info['Spec']['Mode']['Replicated']['Replicas'] == 2 + version_index = svc_info['Version']['Index'] + + self.client.update_service(name, version_index, labels={'force': 'update'}, use_current_spec=True) + svc_info = self.client.inspect_service(svc_id) + new_index = svc_info['Version']['Index'] + assert new_index > version_index + assert 'Mode' in svc_info['Spec'] + assert 'Replicated' in svc_info['Spec']['Mode'] + assert 'Replicas' in svc_info['Spec']['Mode']['Replicated'] + assert svc_info['Spec']['Mode']['Replicated']['Replicas'] == 2 + + def test_update_service_with_defaults_container_labels(self): + container_spec = docker.types.ContainerSpec( + 'busybox', ['echo', 'hello'], + labels={'container.label': 'SampleLabel'} + ) + task_tmpl = docker.types.TaskTemplate(container_spec) + name = self.get_service_name() + svc_id = self.client.create_service(task_tmpl, name=name, labels={'service.label': 'SampleLabel'}) + svc_info = self.client.inspect_service(svc_id) + assert 'TaskTemplate' in svc_info['Spec'] + assert 'ContainerSpec' in svc_info['Spec']['TaskTemplate'] + assert 'Labels' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] + assert svc_info['Spec']['TaskTemplate']['ContainerSpec']['Labels']['container.label'] == 'SampleLabel' + version_index = svc_info['Version']['Index'] + + self.client.update_service(name, version_index, labels={'force': 'update'}, use_current_spec=True) + svc_info = self.client.inspect_service(svc_id) + new_index = svc_info['Version']['Index'] + assert new_index > version_index + assert 'TaskTemplate' in svc_info['Spec'] + assert 'ContainerSpec' in svc_info['Spec']['TaskTemplate'] + assert 'Labels' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] + assert svc_info['Spec']['TaskTemplate']['ContainerSpec']['Labels']['container.label'] == 'SampleLabel' + + container_spec = docker.types.ContainerSpec( + 'busybox', ['echo', 'hello'] + ) + task_tmpl = docker.types.TaskTemplate(container_spec) + self.client.update_service(name, new_index, task_tmpl, use_current_spec=True) + svc_info = self.client.inspect_service(svc_id) + newer_index = svc_info['Version']['Index'] + assert newer_index > new_index + assert 'TaskTemplate' in svc_info['Spec'] + assert 'ContainerSpec' in svc_info['Spec']['TaskTemplate'] + assert 'Labels' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] + assert svc_info['Spec']['TaskTemplate']['ContainerSpec']['Labels']['container.label'] == 'SampleLabel' + + def test_update_service_with_defaults_update_config(self): + container_spec = docker.types.ContainerSpec(BUSYBOX, ['true']) + task_tmpl = docker.types.TaskTemplate(container_spec) + update_config = docker.types.UpdateConfig( + parallelism=10, delay=5, failure_action='pause' + ) + name = self.get_service_name() + svc_id = self.client.create_service( + task_tmpl, update_config=update_config, name=name + ) + svc_info = self.client.inspect_service(svc_id) + assert 'UpdateConfig' in svc_info['Spec'] + uc = svc_info['Spec']['UpdateConfig'] + assert update_config['Parallelism'] == uc['Parallelism'] + assert update_config['Delay'] == uc['Delay'] + assert update_config['FailureAction'] == uc['FailureAction'] + version_index = svc_info['Version']['Index'] + + self.client.update_service(name, version_index, labels={'force': 'update'}, use_current_spec=True) + svc_info = self.client.inspect_service(svc_id) + new_index = svc_info['Version']['Index'] + assert new_index > version_index + assert 'UpdateConfig' in svc_info['Spec'] + uc = svc_info['Spec']['UpdateConfig'] + assert update_config['Parallelism'] == uc['Parallelism'] + assert update_config['Delay'] == uc['Delay'] + assert update_config['FailureAction'] == uc['FailureAction'] + + def test_update_service_with_defaults_networks(self): + net1 = self.client.create_network( + 'dockerpytest_1', driver='overlay', ipam={'Driver': 'default'} + ) + self.tmp_networks.append(net1['Id']) + net2 = self.client.create_network( + 'dockerpytest_2', driver='overlay', ipam={'Driver': 'default'} + ) + self.tmp_networks.append(net2['Id']) + container_spec = docker.types.ContainerSpec(BUSYBOX, ['true']) + task_tmpl = docker.types.TaskTemplate(container_spec) + name = self.get_service_name() + svc_id = self.client.create_service( + task_tmpl, name=name, networks=[ + 'dockerpytest_1', {'Target': 'dockerpytest_2'} + ] + ) + svc_info = self.client.inspect_service(svc_id) + assert 'Networks' in svc_info['Spec'] + assert svc_info['Spec']['Networks'] == [ + {'Target': net1['Id']}, {'Target': net2['Id']} + ] + + version_index = svc_info['Version']['Index'] + + self.client.update_service(name, version_index, labels={'force': 'update'}, use_current_spec=True) + svc_info = self.client.inspect_service(svc_id) + new_index = svc_info['Version']['Index'] + assert new_index > version_index + assert 'Networks' in svc_info['Spec']['TaskTemplate'] + assert svc_info['Spec']['TaskTemplate']['Networks'] == [ + {'Target': net1['Id']}, {'Target': net2['Id']} + ] + + self.client.update_service(name, new_index, networks=[net1['Id']], use_current_spec=True) + svc_info = self.client.inspect_service(svc_id) + assert 'Networks' in svc_info['Spec']['TaskTemplate'] + assert svc_info['Spec']['TaskTemplate']['Networks'] == [ + {'Target': net1['Id']} + ] + + def test_update_service_with_defaults_endpoint_spec(self): + container_spec = docker.types.ContainerSpec(BUSYBOX, ['true']) + task_tmpl = docker.types.TaskTemplate(container_spec) + name = self.get_service_name() + endpoint_spec = docker.types.EndpointSpec(ports={ + 12357: (1990, 'udp'), + 12562: (678,), + 53243: 8080, + }) + svc_id = self.client.create_service( + task_tmpl, name=name, endpoint_spec=endpoint_spec + ) + svc_info = self.client.inspect_service(svc_id) + print(svc_info) + ports = svc_info['Spec']['EndpointSpec']['Ports'] + for port in ports: + if port['PublishedPort'] == 12562: + assert port['TargetPort'] == 678 + assert port['Protocol'] == 'tcp' + elif port['PublishedPort'] == 53243: + assert port['TargetPort'] == 8080 + assert port['Protocol'] == 'tcp' + elif port['PublishedPort'] == 12357: + assert port['TargetPort'] == 1990 + assert port['Protocol'] == 'udp' + else: + self.fail('Invalid port specification: {0}'.format(port)) + + assert len(ports) == 3 + + svc_info = self.client.inspect_service(svc_id) + version_index = svc_info['Version']['Index'] + + self.client.update_service(name, version_index, labels={'force': 'update'}, use_current_spec=True) + svc_info = self.client.inspect_service(svc_id) + new_index = svc_info['Version']['Index'] + assert new_index > version_index + + ports = svc_info['Spec']['EndpointSpec']['Ports'] + for port in ports: + if port['PublishedPort'] == 12562: + assert port['TargetPort'] == 678 + assert port['Protocol'] == 'tcp' + elif port['PublishedPort'] == 53243: + assert port['TargetPort'] == 8080 + assert port['Protocol'] == 'tcp' + elif port['PublishedPort'] == 12357: + assert port['TargetPort'] == 1990 + assert port['Protocol'] == 'udp' + else: + self.fail('Invalid port specification: {0}'.format(port)) + + assert len(ports) == 3 + + @requires_api_version('1.25') + def test_update_service_remove_healthcheck(self): + second = 1000000000 + hc = docker.types.Healthcheck( + test='true', retries=3, timeout=1 * second, + start_period=3 * second, interval=int(second / 2), + ) + container_spec = docker.types.ContainerSpec( + BUSYBOX, ['sleep', '999'], healthcheck=hc + ) + task_tmpl = docker.types.TaskTemplate(container_spec) + name = self.get_service_name() + svc_id = self.client.create_service(task_tmpl, name=name) + svc_info = self.client.inspect_service(svc_id) + assert ( + 'Healthcheck' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] + ) + assert ( + hc == + svc_info['Spec']['TaskTemplate']['ContainerSpec']['Healthcheck'] + ) + + container_spec = docker.types.ContainerSpec( + BUSYBOX, ['sleep', '999'], healthcheck={} + ) + task_tmpl = docker.types.TaskTemplate(container_spec) + + version_index = svc_info['Version']['Index'] + + self.client.update_service(name, version_index, task_tmpl, use_current_spec=True) + svc_info = self.client.inspect_service(svc_id) + new_index = svc_info['Version']['Index'] + assert new_index > version_index + assert ( + 'Healthcheck' not in svc_info['Spec']['TaskTemplate']['ContainerSpec'] or + not svc_info['Spec']['TaskTemplate']['ContainerSpec']['Healthcheck'] + ) + + def test_update_service_remove_labels(self): + container_spec = docker.types.ContainerSpec( + 'busybox', ['echo', 'hello'] + ) + task_tmpl = docker.types.TaskTemplate(container_spec) + name = self.get_service_name() + svc_id = self.client.create_service(task_tmpl, name=name, labels={'service.label': 'SampleLabel'}) + svc_info = self.client.inspect_service(svc_id) + assert 'Labels' in svc_info['Spec'] + assert 'service.label' in svc_info['Spec']['Labels'] + assert svc_info['Spec']['Labels']['service.label'] == 'SampleLabel' + version_index = svc_info['Version']['Index'] + + self.client.update_service(name, version_index, labels={}, use_current_spec=True) + svc_info = self.client.inspect_service(svc_id) + new_index = svc_info['Version']['Index'] + assert new_index > version_index + assert not svc_info['Spec'].get('Labels') + + def test_update_service_remove_container_labels(self): + container_spec = docker.types.ContainerSpec( + 'busybox', ['echo', 'hello'], + labels={'container.label': 'SampleLabel'} + ) + task_tmpl = docker.types.TaskTemplate(container_spec) + name = self.get_service_name() + svc_id = self.client.create_service(task_tmpl, name=name, labels={'service.label': 'SampleLabel'}) + svc_info = self.client.inspect_service(svc_id) + assert 'TaskTemplate' in svc_info['Spec'] + assert 'ContainerSpec' in svc_info['Spec']['TaskTemplate'] + assert 'Labels' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] + assert svc_info['Spec']['TaskTemplate']['ContainerSpec']['Labels']['container.label'] == 'SampleLabel' + version_index = svc_info['Version']['Index'] + + container_spec = docker.types.ContainerSpec( + 'busybox', ['echo', 'hello'], + labels={} + ) + task_tmpl = docker.types.TaskTemplate(container_spec) + self.client.update_service(name, version_index, task_tmpl, use_current_spec=True) + svc_info = self.client.inspect_service(svc_id) + new_index = svc_info['Version']['Index'] + assert new_index > version_index + assert 'TaskTemplate' in svc_info['Spec'] + assert 'ContainerSpec' in svc_info['Spec']['TaskTemplate'] + assert not svc_info['Spec']['TaskTemplate']['ContainerSpec'].get('Labels') From b2d08e64bceb81b75df8de6b0ad1948488bb4b28 Mon Sep 17 00:00:00 2001 From: Viktor Adam Date: Tue, 14 Nov 2017 23:32:19 +0000 Subject: [PATCH 09/30] Service model update changes Signed-off-by: Viktor Adam --- docker/api/service.py | 67 +++++--- docker/models/services.py | 11 +- docker/types/services.py | 9 +- tests/integration/api_service_test.py | 187 +++++++++++++++++++--- tests/integration/models_services_test.py | 123 +++++++++++++- tests/unit/models_services_test.py | 4 +- 6 files changed, 345 insertions(+), 56 deletions(-) diff --git a/docker/api/service.py b/docker/api/service.py index 8e39ff510..67eb02c39 100644 --- a/docker/api/service.py +++ b/docker/api/service.py @@ -363,8 +363,15 @@ def update_service(self, service, version, task_template=None, name=None, data = {} headers = {} - data['Name'] = name if name is not None else current.get('Name') - data['Labels'] = labels if labels is not None else current.get('Labels') + if name is not None: + data['Name'] = name + else: + data['Name'] = current.get('Name') + + if labels is not None: + data['Labels'] = labels + else: + data['Labels'] = current.get('Labels') if mode is not None: if not isinstance(mode, dict): @@ -373,23 +380,17 @@ def update_service(self, service, version, task_template=None, name=None, else: data['Mode'] = current.get('Mode') - merged_task_template = current.get('TaskTemplate', {}) - if task_template is not None: - for task_template_key, task_template_value in task_template.items(): - if task_template_key == 'ContainerSpec': - if 'ContainerSpec' not in merged_task_template: - merged_task_template['ContainerSpec'] = {} - for container_spec_key, container_spec_value in task_template['ContainerSpec'].items(): - merged_task_template['ContainerSpec'][container_spec_key] = container_spec_value - else: - merged_task_template[task_template_key] = task_template_value - image = merged_task_template.get('ContainerSpec', {}).get('Image', None) - if image is not None: - registry, repo_name = auth.resolve_repository_name(image) - auth_header = auth.get_config_header(self, registry) - if auth_header: - headers['X-Registry-Auth'] = auth_header - data['TaskTemplate'] = merged_task_template + data['TaskTemplate'] = self._merge_task_template( + current.get('TaskTemplate', {}), task_template + ) + + container_spec = data['TaskTemplate'].get('ContainerSpec', {}) + image = container_spec.get('Image', None) + if image is not None: + registry, repo_name = auth.resolve_repository_name(image) + auth_header = auth.get_config_header(self, registry) + if auth_header: + headers['X-Registry-Auth'] = auth_header if update_config is not None: data['UpdateConfig'] = update_config @@ -397,11 +398,15 @@ def update_service(self, service, version, task_template=None, name=None, data['UpdateConfig'] = current.get('UpdateConfig') if networks is not None: - data['TaskTemplate']['Networks'] = utils.convert_service_networks(networks) - else: - existing_networks = current.get('TaskTemplate', {}).get('Networks') or current.get('Networks') - if existing_networks is not None: - data['TaskTemplate']['Networks'] = existing_networks + converted_networks = utils.convert_service_networks(networks) + data['TaskTemplate']['Networks'] = converted_networks + elif data['TaskTemplate'].get('Networks') is None: + current_task_template = current.get('TaskTemplate', {}) + current_networks = current_task_template.get('Networks') + if current_networks is None: + current_networks = current.get('Networks') + if current_networks is not None: + data['TaskTemplate']['Networks'] = current_networks if endpoint_spec is not None: data['EndpointSpec'] = endpoint_spec @@ -413,3 +418,17 @@ def update_service(self, service, version, task_template=None, name=None, ) self._raise_for_status(resp) return True + + @staticmethod + def _merge_task_template(current, override): + merged = current.copy() + if override is not None: + for ts_key, ts_value in override.items(): + if ts_key == 'ContainerSpec': + if 'ContainerSpec' not in merged: + merged['ContainerSpec'] = {} + for cs_key, cs_value in override['ContainerSpec'].items(): + merged['ContainerSpec'][cs_key] = cs_value + else: + merged[ts_key] = ts_value + return merged diff --git a/docker/models/services.py b/docker/models/services.py index 6fc5c2a5c..39c86efb2 100644 --- a/docker/models/services.py +++ b/docker/models/services.py @@ -251,6 +251,7 @@ def list(self, **kwargs): # kwargs to copy straight over to TaskTemplate TASK_TEMPLATE_KWARGS = [ + 'networks', 'resources', 'restart_policy', ] @@ -261,7 +262,6 @@ def list(self, **kwargs): 'labels', 'mode', 'update_config', - 'networks', 'endpoint_spec', ] @@ -295,6 +295,15 @@ def _get_create_service_kwargs(func_name, kwargs): 'Options': kwargs.pop('log_driver_options', {}) } + if func_name == 'update': + if 'force_update' in kwargs: + task_template_kwargs['force_update'] = kwargs.pop('force_update') + + # use the current spec by default if updating the service + # through the model + use_current_spec = kwargs.pop('use_current_spec', True) + create_kwargs['use_current_spec'] = use_current_spec + # All kwargs should have been consumed by this point, so raise # error if any are left if kwargs: diff --git a/docker/types/services.py b/docker/types/services.py index 9031e609a..109b22e50 100644 --- a/docker/types/services.py +++ b/docker/types/services.py @@ -4,7 +4,7 @@ from ..constants import IS_WINDOWS_PLATFORM from ..utils import ( check_resource, format_environment, format_extra_hosts, parse_bytes, - split_command, + split_command, convert_service_networks, ) @@ -26,11 +26,14 @@ class TaskTemplate(dict): placement (Placement): Placement instructions for the scheduler. If a list is passed instead, it is assumed to be a list of constraints as part of a :py:class:`Placement` object. + networks (:py:class:`list`): List of network names or IDs to attach + the containers to. force_update (int): A counter that triggers an update even if no relevant parameters have been changed. """ def __init__(self, container_spec, resources=None, restart_policy=None, - placement=None, log_driver=None, force_update=None): + placement=None, log_driver=None, networks=None, + force_update=None): self['ContainerSpec'] = container_spec if resources: self['Resources'] = resources @@ -42,6 +45,8 @@ def __init__(self, container_spec, resources=None, restart_policy=None, self['Placement'] = placement if log_driver: self['LogDriver'] = log_driver + if networks: + self['Networks'] = convert_service_networks(networks) if force_update is not None: if not isinstance(force_update, int): diff --git a/tests/integration/api_service_test.py b/tests/integration/api_service_test.py index 00ad84cce..60fdcf880 100644 --- a/tests/integration/api_service_test.py +++ b/tests/integration/api_service_test.py @@ -725,7 +725,9 @@ def test_update_service_with_defaults_name(self): version_index = svc_info['Version']['Index'] task_tmpl = docker.types.TaskTemplate(container_spec, force_update=10) - self.client.update_service(name, version_index, task_tmpl, use_current_spec=True) + self._update_service( + svc_id, name, version_index, task_tmpl, use_current_spec=True + ) svc_info = self.client.inspect_service(svc_id) new_index = svc_info['Version']['Index'] assert new_index > version_index @@ -739,7 +741,9 @@ def test_update_service_with_defaults_labels(self): ) task_tmpl = docker.types.TaskTemplate(container_spec) name = self.get_service_name() - svc_id = self.client.create_service(task_tmpl, name=name, labels={'service.label': 'SampleLabel'}) + svc_id = self.client.create_service( + task_tmpl, name=name, labels={'service.label': 'SampleLabel'} + ) svc_info = self.client.inspect_service(svc_id) assert 'Labels' in svc_info['Spec'] assert 'service.label' in svc_info['Spec']['Labels'] @@ -747,7 +751,10 @@ def test_update_service_with_defaults_labels(self): version_index = svc_info['Version']['Index'] task_tmpl = docker.types.TaskTemplate(container_spec, force_update=10) - self.client.update_service(name, version_index, task_tmpl, name=name, use_current_spec=True) + self._update_service( + svc_id, name, version_index, task_tmpl, name=name, + use_current_spec=True + ) svc_info = self.client.inspect_service(svc_id) new_index = svc_info['Version']['Index'] assert new_index > version_index @@ -761,8 +768,10 @@ def test_update_service_with_defaults_mode(self): ) task_tmpl = docker.types.TaskTemplate(container_spec) name = self.get_service_name() - svc_id = self.client.create_service(task_tmpl, name=name, - mode=docker.types.ServiceMode(mode='replicated', replicas=2)) + svc_id = self.client.create_service( + task_tmpl, name=name, + mode=docker.types.ServiceMode(mode='replicated', replicas=2) + ) svc_info = self.client.inspect_service(svc_id) assert 'Mode' in svc_info['Spec'] assert 'Replicated' in svc_info['Spec']['Mode'] @@ -770,7 +779,10 @@ def test_update_service_with_defaults_mode(self): assert svc_info['Spec']['Mode']['Replicated']['Replicas'] == 2 version_index = svc_info['Version']['Index'] - self.client.update_service(name, version_index, labels={'force': 'update'}, use_current_spec=True) + self._update_service( + svc_id, name, version_index, labels={'force': 'update'}, + use_current_spec=True + ) svc_info = self.client.inspect_service(svc_id) new_index = svc_info['Version']['Index'] assert new_index > version_index @@ -786,35 +798,45 @@ def test_update_service_with_defaults_container_labels(self): ) task_tmpl = docker.types.TaskTemplate(container_spec) name = self.get_service_name() - svc_id = self.client.create_service(task_tmpl, name=name, labels={'service.label': 'SampleLabel'}) + svc_id = self.client.create_service( + task_tmpl, name=name, labels={'service.label': 'SampleLabel'} + ) svc_info = self.client.inspect_service(svc_id) assert 'TaskTemplate' in svc_info['Spec'] assert 'ContainerSpec' in svc_info['Spec']['TaskTemplate'] assert 'Labels' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] - assert svc_info['Spec']['TaskTemplate']['ContainerSpec']['Labels']['container.label'] == 'SampleLabel' + labels = svc_info['Spec']['TaskTemplate']['ContainerSpec']['Labels'] + assert labels['container.label'] == 'SampleLabel' version_index = svc_info['Version']['Index'] - self.client.update_service(name, version_index, labels={'force': 'update'}, use_current_spec=True) + self._update_service( + svc_id, name, version_index, labels={'force': 'update'}, + use_current_spec=True + ) svc_info = self.client.inspect_service(svc_id) new_index = svc_info['Version']['Index'] assert new_index > version_index assert 'TaskTemplate' in svc_info['Spec'] assert 'ContainerSpec' in svc_info['Spec']['TaskTemplate'] assert 'Labels' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] - assert svc_info['Spec']['TaskTemplate']['ContainerSpec']['Labels']['container.label'] == 'SampleLabel' + labels = svc_info['Spec']['TaskTemplate']['ContainerSpec']['Labels'] + assert labels['container.label'] == 'SampleLabel' container_spec = docker.types.ContainerSpec( 'busybox', ['echo', 'hello'] ) task_tmpl = docker.types.TaskTemplate(container_spec) - self.client.update_service(name, new_index, task_tmpl, use_current_spec=True) + self._update_service( + svc_id, name, new_index, task_tmpl, use_current_spec=True + ) svc_info = self.client.inspect_service(svc_id) newer_index = svc_info['Version']['Index'] assert newer_index > new_index assert 'TaskTemplate' in svc_info['Spec'] assert 'ContainerSpec' in svc_info['Spec']['TaskTemplate'] assert 'Labels' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] - assert svc_info['Spec']['TaskTemplate']['ContainerSpec']['Labels']['container.label'] == 'SampleLabel' + labels = svc_info['Spec']['TaskTemplate']['ContainerSpec']['Labels'] + assert labels['container.label'] == 'SampleLabel' def test_update_service_with_defaults_update_config(self): container_spec = docker.types.ContainerSpec(BUSYBOX, ['true']) @@ -834,7 +856,10 @@ def test_update_service_with_defaults_update_config(self): assert update_config['FailureAction'] == uc['FailureAction'] version_index = svc_info['Version']['Index'] - self.client.update_service(name, version_index, labels={'force': 'update'}, use_current_spec=True) + self._update_service( + svc_id, name, version_index, labels={'force': 'update'}, + use_current_spec=True + ) svc_info = self.client.inspect_service(svc_id) new_index = svc_info['Version']['Index'] assert new_index > version_index @@ -869,7 +894,10 @@ def test_update_service_with_defaults_networks(self): version_index = svc_info['Version']['Index'] - self.client.update_service(name, version_index, labels={'force': 'update'}, use_current_spec=True) + self._update_service( + svc_id, name, version_index, labels={'force': 'update'}, + use_current_spec=True + ) svc_info = self.client.inspect_service(svc_id) new_index = svc_info['Version']['Index'] assert new_index > version_index @@ -878,7 +906,10 @@ def test_update_service_with_defaults_networks(self): {'Target': net1['Id']}, {'Target': net2['Id']} ] - self.client.update_service(name, new_index, networks=[net1['Id']], use_current_spec=True) + self._update_service( + svc_id, name, new_index, networks=[net1['Id']], + use_current_spec=True + ) svc_info = self.client.inspect_service(svc_id) assert 'Networks' in svc_info['Spec']['TaskTemplate'] assert svc_info['Spec']['TaskTemplate']['Networks'] == [ @@ -918,7 +949,10 @@ def test_update_service_with_defaults_endpoint_spec(self): svc_info = self.client.inspect_service(svc_id) version_index = svc_info['Version']['Index'] - self.client.update_service(name, version_index, labels={'force': 'update'}, use_current_spec=True) + self._update_service( + svc_id, name, version_index, labels={'force': 'update'}, + use_current_spec=True + ) svc_info = self.client.inspect_service(svc_id) new_index = svc_info['Version']['Index'] assert new_index > version_index @@ -968,13 +1002,16 @@ def test_update_service_remove_healthcheck(self): version_index = svc_info['Version']['Index'] - self.client.update_service(name, version_index, task_tmpl, use_current_spec=True) + self._update_service( + svc_id, name, version_index, task_tmpl, use_current_spec=True + ) svc_info = self.client.inspect_service(svc_id) new_index = svc_info['Version']['Index'] assert new_index > version_index + container_spec = svc_info['Spec']['TaskTemplate']['ContainerSpec'] assert ( - 'Healthcheck' not in svc_info['Spec']['TaskTemplate']['ContainerSpec'] or - not svc_info['Spec']['TaskTemplate']['ContainerSpec']['Healthcheck'] + 'Healthcheck' not in container_spec or + not container_spec['Healthcheck'] ) def test_update_service_remove_labels(self): @@ -983,14 +1020,18 @@ def test_update_service_remove_labels(self): ) task_tmpl = docker.types.TaskTemplate(container_spec) name = self.get_service_name() - svc_id = self.client.create_service(task_tmpl, name=name, labels={'service.label': 'SampleLabel'}) + svc_id = self.client.create_service( + task_tmpl, name=name, labels={'service.label': 'SampleLabel'} + ) svc_info = self.client.inspect_service(svc_id) assert 'Labels' in svc_info['Spec'] assert 'service.label' in svc_info['Spec']['Labels'] assert svc_info['Spec']['Labels']['service.label'] == 'SampleLabel' version_index = svc_info['Version']['Index'] - self.client.update_service(name, version_index, labels={}, use_current_spec=True) + self._update_service( + svc_id, name, version_index, labels={}, use_current_spec=True + ) svc_info = self.client.inspect_service(svc_id) new_index = svc_info['Version']['Index'] assert new_index > version_index @@ -1003,12 +1044,15 @@ def test_update_service_remove_container_labels(self): ) task_tmpl = docker.types.TaskTemplate(container_spec) name = self.get_service_name() - svc_id = self.client.create_service(task_tmpl, name=name, labels={'service.label': 'SampleLabel'}) + svc_id = self.client.create_service( + task_tmpl, name=name, labels={'service.label': 'SampleLabel'} + ) svc_info = self.client.inspect_service(svc_id) assert 'TaskTemplate' in svc_info['Spec'] assert 'ContainerSpec' in svc_info['Spec']['TaskTemplate'] assert 'Labels' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] - assert svc_info['Spec']['TaskTemplate']['ContainerSpec']['Labels']['container.label'] == 'SampleLabel' + labels = svc_info['Spec']['TaskTemplate']['ContainerSpec']['Labels'] + assert labels['container.label'] == 'SampleLabel' version_index = svc_info['Version']['Index'] container_spec = docker.types.ContainerSpec( @@ -1016,10 +1060,103 @@ def test_update_service_remove_container_labels(self): labels={} ) task_tmpl = docker.types.TaskTemplate(container_spec) - self.client.update_service(name, version_index, task_tmpl, use_current_spec=True) + self._update_service( + svc_id, name, version_index, task_tmpl, use_current_spec=True + ) svc_info = self.client.inspect_service(svc_id) new_index = svc_info['Version']['Index'] assert new_index > version_index assert 'TaskTemplate' in svc_info['Spec'] assert 'ContainerSpec' in svc_info['Spec']['TaskTemplate'] - assert not svc_info['Spec']['TaskTemplate']['ContainerSpec'].get('Labels') + container_spec = svc_info['Spec']['TaskTemplate']['ContainerSpec'] + assert not container_spec.get('Labels') + + @requires_api_version('1.29') + def test_update_service_with_network_change(self): + container_spec = docker.types.ContainerSpec( + 'busybox', ['echo', 'hello'] + ) + task_tmpl = docker.types.TaskTemplate(container_spec) + net1 = self.client.create_network( + 'dockerpytest_1', driver='overlay', ipam={'Driver': 'default'} + ) + self.tmp_networks.append(net1['Id']) + net2 = self.client.create_network( + 'dockerpytest_2', driver='overlay', ipam={'Driver': 'default'} + ) + self.tmp_networks.append(net2['Id']) + name = self.get_service_name() + svc_id = self.client.create_service( + task_tmpl, name=name, networks=[net1['Id']] + ) + svc_info = self.client.inspect_service(svc_id) + assert 'Networks' in svc_info['Spec'] + assert len(svc_info['Spec']['Networks']) > 0 + assert svc_info['Spec']['Networks'][0]['Target'] == net1['Id'] + + svc_info = self.client.inspect_service(svc_id) + version_index = svc_info['Version']['Index'] + + task_tmpl = docker.types.TaskTemplate(container_spec) + self._update_service( + svc_id, name, version_index, task_tmpl, name=name, + networks=[net2['Id']], use_current_spec=True + ) + svc_info = self.client.inspect_service(svc_id) + task_template = svc_info['Spec']['TaskTemplate'] + assert 'Networks' in task_template + assert len(task_template['Networks']) > 0 + assert task_template['Networks'][0]['Target'] == net2['Id'] + + svc_info = self.client.inspect_service(svc_id) + new_index = svc_info['Version']['Index'] + assert new_index > version_index + + self._update_service( + svc_id, name, new_index, name=name, networks=[net1['Id']], + use_current_spec=True + ) + svc_info = self.client.inspect_service(svc_id) + task_template = svc_info['Spec']['TaskTemplate'] + assert 'ContainerSpec' in task_template + new_spec = task_template['ContainerSpec'] + assert 'Image' in new_spec + assert new_spec['Image'].split(':')[0] == 'busybox' + assert 'Command' in new_spec + assert new_spec['Command'] == ['echo', 'hello'] + assert 'Networks' in task_template + assert len(task_template['Networks']) > 0 + assert task_template['Networks'][0]['Target'] == net1['Id'] + + svc_info = self.client.inspect_service(svc_id) + new_index = svc_info['Version']['Index'] + + task_tmpl = docker.types.TaskTemplate( + container_spec, networks=[net2['Id']] + ) + self._update_service( + svc_id, name, new_index, task_tmpl, name=name, + use_current_spec=True + ) + svc_info = self.client.inspect_service(svc_id) + task_template = svc_info['Spec']['TaskTemplate'] + assert 'Networks' in task_template + assert len(task_template['Networks']) > 0 + assert task_template['Networks'][0]['Target'] == net2['Id'] + + def _update_service(self, svc_id, *args, **kwargs): + # service update tests seem to be a bit flaky + # give them a chance to retry the update with a new version index + try: + self.client.update_service(*args, **kwargs) + except docker.errors.APIError as e: + if e.explanation == "update out of sequence": + svc_info = self.client.inspect_service(svc_id) + version_index = svc_info['Version']['Index'] + + if len(args) > 1: + args = (args[0], version_index) + args[2:] + else: + kwargs['version'] = version_index + + self.client.update_service(*args, **kwargs) diff --git a/tests/integration/models_services_test.py b/tests/integration/models_services_test.py index 6b5dab531..ee8a3487f 100644 --- a/tests/integration/models_services_test.py +++ b/tests/integration/models_services_test.py @@ -1,7 +1,6 @@ import unittest import docker -import pytest from .. import helpers from .base import TEST_API_VERSION @@ -36,6 +35,25 @@ def test_create(self): assert "alpine" in container_spec['Image'] assert container_spec['Labels'] == {'container': 'label'} + def test_create_with_network(self): + client = docker.from_env(version=TEST_API_VERSION) + name = helpers.random_name() + network = client.networks.create( + helpers.random_name(), driver='overlay' + ) + service = client.services.create( + # create arguments + name=name, + # ContainerSpec arguments + image="alpine", + command="sleep 300", + networks=[network.id] + ) + assert 'Networks' in service.attrs['Spec']['TaskTemplate'] + networks = service.attrs['Spec']['TaskTemplate']['Networks'] + assert len(networks) == 1 + assert networks[0]['Target'] == network.id + def test_get(self): client = docker.from_env(version=TEST_API_VERSION) name = helpers.random_name() @@ -82,7 +100,6 @@ def test_tasks(self): assert len(tasks) == 1 assert tasks[0]['ServiceID'] == service2.id - @pytest.mark.skip(reason="Makes Swarm unstable?") def test_update(self): client = docker.from_env(version=TEST_API_VERSION) service = client.services.create( @@ -101,3 +118,105 @@ def test_update(self): service.reload() container_spec = service.attrs['Spec']['TaskTemplate']['ContainerSpec'] assert container_spec['Command'] == ["sleep", "600"] + + def test_update_retains_service_labels(self): + client = docker.from_env(version=TEST_API_VERSION) + service = client.services.create( + # create arguments + name=helpers.random_name(), + labels={'service.label': 'SampleLabel'}, + # ContainerSpec arguments + image="alpine", + command="sleep 300" + ) + service.update( + # create argument + name=service.name, + # ContainerSpec argument + command="sleep 600" + ) + service.reload() + labels = service.attrs['Spec']['Labels'] + assert labels == {'service.label': 'SampleLabel'} + + def test_update_retains_container_labels(self): + client = docker.from_env(version=TEST_API_VERSION) + service = client.services.create( + # create arguments + name=helpers.random_name(), + # ContainerSpec arguments + image="alpine", + command="sleep 300", + container_labels={'container.label': 'SampleLabel'} + ) + service.update( + # create argument + name=service.name, + # ContainerSpec argument + command="sleep 600" + ) + service.reload() + container_spec = service.attrs['Spec']['TaskTemplate']['ContainerSpec'] + assert container_spec['Labels'] == {'container.label': 'SampleLabel'} + + def test_update_remove_service_labels(self): + client = docker.from_env(version=TEST_API_VERSION) + service = client.services.create( + # create arguments + name=helpers.random_name(), + labels={'service.label': 'SampleLabel'}, + # ContainerSpec arguments + image="alpine", + command="sleep 300" + ) + service.update( + # create argument + name=service.name, + labels={}, + # ContainerSpec argument + command="sleep 600" + ) + service.reload() + assert not service.attrs['Spec'].get('Labels') + + def test_scale_service(self): + client = docker.from_env(version=TEST_API_VERSION) + service = client.services.create( + # create arguments + name=helpers.random_name(), + # ContainerSpec arguments + image="alpine", + command="sleep 300" + ) + assert len(service.tasks()) == 1 + service.update( + # create argument + name=service.name, + mode=docker.types.ServiceMode('replicated', replicas=2), + # ContainerSpec argument + command="sleep 600" + ) + service.reload() + assert len(service.tasks()) >= 2 + + @helpers.requires_api_version('1.25') + def test_restart_service(self): + client = docker.from_env(version=TEST_API_VERSION) + service = client.services.create( + # create arguments + name=helpers.random_name(), + # ContainerSpec arguments + image="alpine", + command="sleep 300" + ) + initial_version = service.version + service.update( + # create argument + name=service.name, + # task template argument + force_update=10, + # ContainerSpec argument + command="sleep 600" + ) + service.reload() + assert service.version > initial_version diff --git a/tests/unit/models_services_test.py b/tests/unit/models_services_test.py index e7e317d52..247bb4a4a 100644 --- a/tests/unit/models_services_test.py +++ b/tests/unit/models_services_test.py @@ -35,18 +35,18 @@ def test_get_create_service_kwargs(self): 'labels': {'key': 'value'}, 'mode': 'global', 'update_config': {'update': 'config'}, - 'networks': ['somenet'], 'endpoint_spec': {'blah': 'blah'}, } assert set(task_template.keys()) == set([ 'ContainerSpec', 'Resources', 'RestartPolicy', 'Placement', - 'LogDriver' + 'LogDriver', 'Networks' ]) assert task_template['Placement'] == {'Constraints': ['foo=bar']} assert task_template['LogDriver'] == { 'Name': 'logdriver', 'Options': {'foo': 'bar'} } + assert task_template['Networks'] == [{'Target': 'somenet'}] assert set(task_template['ContainerSpec'].keys()) == set([ 'Image', 'Command', 'Args', 'Hostname', 'Env', 'Dir', 'User', 'Labels', 'Mounts', 'StopGracePeriod' From c78e73bf7ac87feb8bef35921492a98e5727d9a5 Mon Sep 17 00:00:00 2001 From: Viktor Adam Date: Wed, 15 Nov 2017 08:17:16 +0000 Subject: [PATCH 10/30] Attempting to make service update tests less flaky Signed-off-by: Viktor Adam --- tests/integration/api_service_test.py | 4 +++- tests/integration/models_services_test.py | 10 +++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/integration/api_service_test.py b/tests/integration/api_service_test.py index 60fdcf880..10ae1804b 100644 --- a/tests/integration/api_service_test.py +++ b/tests/integration/api_service_test.py @@ -1150,7 +1150,7 @@ def _update_service(self, svc_id, *args, **kwargs): try: self.client.update_service(*args, **kwargs) except docker.errors.APIError as e: - if e.explanation == "update out of sequence": + if e.explanation.endswith("update out of sequence"): svc_info = self.client.inspect_service(svc_id) version_index = svc_info['Version']['Index'] @@ -1160,3 +1160,5 @@ def _update_service(self, svc_id, *args, **kwargs): kwargs['version'] = version_index self.client.update_service(*args, **kwargs) + else: + raise diff --git a/tests/integration/models_services_test.py b/tests/integration/models_services_test.py index ee8a3487f..cb96ff188 100644 --- a/tests/integration/models_services_test.py +++ b/tests/integration/models_services_test.py @@ -188,7 +188,10 @@ def test_scale_service(self): image="alpine", command="sleep 300" ) - assert len(service.tasks()) == 1 + tasks = [] + while len(tasks) == 0: + tasks = service.tasks() + assert len(tasks) == 1 service.update( # create argument name=service.name, @@ -196,8 +199,9 @@ def test_scale_service(self): # ContainerSpec argument command="sleep 600" ) - service.reload() - assert len(service.tasks()) >= 2 + while len(tasks) == 1: + tasks = service.tasks() + assert len(tasks) >= 2 @helpers.requires_api_version('1.25') def test_restart_service(self): From 828b865bd7629c08ae5dcc67ca0c606253d8d4ec Mon Sep 17 00:00:00 2001 From: Viktor Adam Date: Wed, 15 Nov 2017 18:30:05 +0000 Subject: [PATCH 11/30] Fix resetting ContainerSpec properties to None Signed-off-by: Viktor Adam --- docker/api/service.py | 5 +++-- tests/integration/models_services_test.py | 8 ++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docker/api/service.py b/docker/api/service.py index 67eb02c39..768d51312 100644 --- a/docker/api/service.py +++ b/docker/api/service.py @@ -428,7 +428,8 @@ def _merge_task_template(current, override): if 'ContainerSpec' not in merged: merged['ContainerSpec'] = {} for cs_key, cs_value in override['ContainerSpec'].items(): - merged['ContainerSpec'][cs_key] = cs_value - else: + if cs_value is not None: + merged['ContainerSpec'][cs_key] = cs_value + elif ts_value is not None: merged[ts_key] = ts_value return merged diff --git a/tests/integration/models_services_test.py b/tests/integration/models_services_test.py index cb96ff188..ca8be48de 100644 --- a/tests/integration/models_services_test.py +++ b/tests/integration/models_services_test.py @@ -193,15 +193,15 @@ def test_scale_service(self): tasks = service.tasks() assert len(tasks) == 1 service.update( - # create argument - name=service.name, mode=docker.types.ServiceMode('replicated', replicas=2), - # ContainerSpec argument - command="sleep 600" ) while len(tasks) == 1: tasks = service.tasks() assert len(tasks) >= 2 + # check that the container spec is not overridden with None + service.reload() + spec = service.attrs['Spec']['TaskTemplate']['ContainerSpec'] + assert spec.get('Command') == ['sleep', '300'] @helpers.requires_api_version('1.25') def test_restart_service(self): From 7829b728a40b59b6efd1d0a3d8b92f48d351e5aa Mon Sep 17 00:00:00 2001 From: Viktor Adam Date: Thu, 16 Nov 2017 23:15:31 +0000 Subject: [PATCH 12/30] Fetch network details with network lists greedily Signed-off-by: Viktor Adam --- docker/api/network.py | 10 ++++-- docker/models/networks.py | 13 ++++++-- tests/integration/api_network_test.py | 37 +++++++++++++++++++++++ tests/integration/models_networks_test.py | 10 ++++-- tests/unit/models_networks_test.py | 8 +++-- 5 files changed, 70 insertions(+), 8 deletions(-) diff --git a/docker/api/network.py b/docker/api/network.py index 797780858..09f5a8bd5 100644 --- a/docker/api/network.py +++ b/docker/api/network.py @@ -6,7 +6,7 @@ class NetworkApiMixin(object): @minimum_version('1.21') - def networks(self, names=None, ids=None, filters=None): + def networks(self, names=None, ids=None, filters=None, greedy=False): """ List networks. Similar to the ``docker networks ls`` command. @@ -18,6 +18,8 @@ def networks(self, names=None, ids=None, filters=None): - ``driver=[]`` Matches a network's driver. - ``label=[]`` or ``label=[=]``. - ``type=["custom"|"builtin"]`` Filters networks by type. + greedy (bool): Fetch more details for each network individually. + You might want this to get the containers attached to them. Returns: (dict): List of network objects. @@ -36,7 +38,11 @@ def networks(self, names=None, ids=None, filters=None): params = {'filters': utils.convert_filters(filters)} url = self._url("/networks") res = self._get(url, params=params) - return self._result(res, json=True) + result = self._result(res, json=True) + if greedy: + return [self.inspect_network(net['Id']) for net in result] + else: + return result @minimum_version('1.21') def create_network(self, name, driver=None, options=None, ipam=None, diff --git a/docker/models/networks.py b/docker/models/networks.py index 158af99b8..06ff22b16 100644 --- a/docker/models/networks.py +++ b/docker/models/networks.py @@ -153,7 +153,7 @@ def create(self, name, *args, **kwargs): resp = self.client.api.create_network(name, *args, **kwargs) return self.get(resp['Id']) - def get(self, network_id): + def get(self, network_id, *args, **kwargs): """ Get a network by its ID. @@ -175,7 +175,9 @@ def get(self, network_id): If the server returns an error. """ - return self.prepare_model(self.client.api.inspect_network(network_id)) + return self.prepare_model( + self.client.api.inspect_network(network_id, *args, **kwargs) + ) def list(self, *args, **kwargs): """ @@ -184,6 +186,13 @@ def list(self, *args, **kwargs): Args: names (:py:class:`list`): List of names to filter by. ids (:py:class:`list`): List of ids to filter by. + filters (dict): Filters to be processed on the network list. + Available filters: + - ``driver=[]`` Matches a network's driver. + - ``label=[]`` or ``label=[=]``. + - ``type=["custom"|"builtin"]`` Filters networks by type. + greedy (bool): Fetch more details for each network individually. + You might want this to get the containers attached to them. Returns: (list of :py:class:`Network`) The networks on the server. diff --git a/tests/integration/api_network_test.py b/tests/integration/api_network_test.py index f4fefde5b..57d263045 100644 --- a/tests/integration/api_network_test.py +++ b/tests/integration/api_network_test.py @@ -112,6 +112,16 @@ def test_connect_and_disconnect_container(self): [container['Id']] ) + network_list = self.client.networks(ids=[net_id], greedy=True) + self.assertEqual( + list( + key + for net in network_list + for key in net['Containers'].keys() + ), + [container['Id']] + ) + with pytest.raises(docker.errors.APIError): self.client.connect_container_to_network(container, net_id) @@ -140,10 +150,27 @@ def test_connect_and_force_disconnect_container(self): [container['Id']] ) + network_list = self.client.networks(ids=[net_id], greedy=True) + self.assertEqual( + list( + key + for net in network_list + for key in net['Containers'].keys() + ), + [container['Id']] + ) + self.client.disconnect_container_from_network(container, net_id, True) network_data = self.client.inspect_network(net_id) self.assertFalse(network_data.get('Containers')) + network_list = self.client.networks(ids=[net_id], greedy=True) + self.assertFalse(list( + key + for net in network_list + for key in net['Containers'].keys() + )) + with pytest.raises(docker.errors.APIError): self.client.disconnect_container_from_network( container, net_id, force=True @@ -183,6 +210,16 @@ def test_connect_on_container_create(self): list(network_data['Containers'].keys()), [container['Id']]) + network_list = self.client.networks(ids=[net_id], greedy=True) + self.assertEqual( + list( + key + for net in network_list + for key in net['Containers'].keys() + ), + [container['Id']] + ) + self.client.disconnect_container_from_network(container, net_id) network_data = self.client.inspect_network(net_id) self.assertFalse(network_data.get('Containers')) diff --git a/tests/integration/models_networks_test.py b/tests/integration/models_networks_test.py index 105dcc594..25fea33ed 100644 --- a/tests/integration/models_networks_test.py +++ b/tests/integration/models_networks_test.py @@ -3,7 +3,7 @@ from .base import BaseIntegrationTest, TEST_API_VERSION -class ImageCollectionTest(BaseIntegrationTest): +class NetworkCollectionTest(BaseIntegrationTest): def test_create(self): client = docker.from_env(version=TEST_API_VERSION) @@ -47,7 +47,7 @@ def test_list_remove(self): assert network.id not in [n.id for n in client.networks.list()] -class ImageTest(BaseIntegrationTest): +class NetworkTest(BaseIntegrationTest): def test_connect_disconnect(self): client = docker.from_env(version=TEST_API_VERSION) @@ -59,6 +59,12 @@ def test_connect_disconnect(self): network.connect(container) container.start() assert client.networks.get(network.id).containers == [container] + network_containers = list( + c + for net in client.networks.list(greedy=True) + for c in net.containers + ) + assert network_containers == [container] network.disconnect(container) assert network.containers == [] assert client.networks.get(network.id).containers == [] diff --git a/tests/unit/models_networks_test.py b/tests/unit/models_networks_test.py index 943b90456..df0650a29 100644 --- a/tests/unit/models_networks_test.py +++ b/tests/unit/models_networks_test.py @@ -4,7 +4,7 @@ from .fake_api_client import make_fake_client -class ImageCollectionTest(unittest.TestCase): +class NetworkCollectionTest(unittest.TestCase): def test_create(self): client = make_fake_client() @@ -36,8 +36,12 @@ def test_list(self): client.networks.list(names=["foobar"]) assert client.api.networks.called_once_with(names=["foobar"]) + client = make_fake_client() + client.networks.list(greedy=True) + assert client.api.networks.called_once_with(greedy=True) + -class ImageTest(unittest.TestCase): +class NetworkTest(unittest.TestCase): def test_connect(self): client = make_fake_client() From 2878900a71a026803fd89cc14c47ac31adbf1485 Mon Sep 17 00:00:00 2001 From: Viktor Adam Date: Sun, 19 Nov 2017 21:03:07 +0000 Subject: [PATCH 13/30] Fixing integration tests Signed-off-by: Viktor Adam --- tests/integration/api_network_test.py | 4 ++++ tests/integration/models_networks_test.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/integration/api_network_test.py b/tests/integration/api_network_test.py index 57d263045..bb900d456 100644 --- a/tests/integration/api_network_test.py +++ b/tests/integration/api_network_test.py @@ -118,6 +118,7 @@ def test_connect_and_disconnect_container(self): key for net in network_list for key in net['Containers'].keys() + if net['Id'] == net_id ), [container['Id']] ) @@ -156,6 +157,7 @@ def test_connect_and_force_disconnect_container(self): key for net in network_list for key in net['Containers'].keys() + if net['Id'] == net_id ), [container['Id']] ) @@ -169,6 +171,7 @@ def test_connect_and_force_disconnect_container(self): key for net in network_list for key in net['Containers'].keys() + if net['Id'] == net_id )) with pytest.raises(docker.errors.APIError): @@ -216,6 +219,7 @@ def test_connect_on_container_create(self): key for net in network_list for key in net['Containers'].keys() + if net['Id'] == net_id ), [container['Id']] ) diff --git a/tests/integration/models_networks_test.py b/tests/integration/models_networks_test.py index 25fea33ed..08d7ad295 100644 --- a/tests/integration/models_networks_test.py +++ b/tests/integration/models_networks_test.py @@ -61,7 +61,7 @@ def test_connect_disconnect(self): assert client.networks.get(network.id).containers == [container] network_containers = list( c - for net in client.networks.list(greedy=True) + for net in client.networks.list(ids=[network.id], greedy=True) for c in net.containers ) assert network_containers == [container] From f3dbd017f8c41774f7176319ba1d80a8a1101593 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damien=20Nad=C3=A9?= Date: Mon, 20 Nov 2017 16:47:36 +0100 Subject: [PATCH 14/30] Fix for #1815: make APIClient.stop honor container StopTimeout value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Damien Nadé --- docker/api/container.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docker/api/container.py b/docker/api/container.py index f3c33c978..fea64cde7 100644 --- a/docker/api/container.py +++ b/docker/api/container.py @@ -1112,20 +1112,26 @@ def stats(self, container, decode=None, stream=True): json=True) @utils.check_resource('container') - def stop(self, container, timeout=10): + def stop(self, container, timeout=None): """ Stops a container. Similar to the ``docker stop`` command. Args: container (str): The container to stop timeout (int): Timeout in seconds to wait for the container to - stop before sending a ``SIGKILL``. Default: 10 + stop before sending a ``SIGKILL``. If None, then the + StopTimeout value of the container will be used. + Default: None Raises: :py:class:`docker.errors.APIError` If the server returns an error. """ - params = {'t': timeout} + if timeout is None: + params = {} + timeout = 10 + else: + params = {'t': timeout} url = self._url("/containers/{0}/stop", container) res = self._post(url, params=params, From 6cce101f012d1875029e0a10f45d2d3ad01aedfe Mon Sep 17 00:00:00 2001 From: Alex Villarreal Date: Tue, 21 Nov 2017 10:07:02 -0600 Subject: [PATCH 15/30] Add missing call to string format in log message Signed-off-by: Alejandro Villarreal --- docker/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/auth.py b/docker/auth.py index c3fb062e9..c0cae5d97 100644 --- a/docker/auth.py +++ b/docker/auth.py @@ -203,7 +203,7 @@ def parse_auth(entries, raise_on_error=False): # https://github.com/docker/compose/issues/3265 log.debug( 'Auth data for {0} is absent. Client might be using a ' - 'credentials store instead.' + 'credentials store instead.'.format(registry) ) conf[registry] = {} continue From 36ed843e2bbfd50698c16bfbd898d915019ca94d Mon Sep 17 00:00:00 2001 From: Viktor Adam Date: Tue, 21 Nov 2017 21:59:11 +0000 Subject: [PATCH 16/30] Only allow greedy queries on the model Signed-off-by: Viktor Adam --- docker/api/network.py | 10 ++----- docker/models/networks.py | 8 +++++- tests/integration/api_network_test.py | 41 --------------------------- tests/unit/models_networks_test.py | 4 --- 4 files changed, 9 insertions(+), 54 deletions(-) diff --git a/docker/api/network.py b/docker/api/network.py index 09f5a8bd5..797780858 100644 --- a/docker/api/network.py +++ b/docker/api/network.py @@ -6,7 +6,7 @@ class NetworkApiMixin(object): @minimum_version('1.21') - def networks(self, names=None, ids=None, filters=None, greedy=False): + def networks(self, names=None, ids=None, filters=None): """ List networks. Similar to the ``docker networks ls`` command. @@ -18,8 +18,6 @@ def networks(self, names=None, ids=None, filters=None, greedy=False): - ``driver=[]`` Matches a network's driver. - ``label=[]`` or ``label=[=]``. - ``type=["custom"|"builtin"]`` Filters networks by type. - greedy (bool): Fetch more details for each network individually. - You might want this to get the containers attached to them. Returns: (dict): List of network objects. @@ -38,11 +36,7 @@ def networks(self, names=None, ids=None, filters=None, greedy=False): params = {'filters': utils.convert_filters(filters)} url = self._url("/networks") res = self._get(url, params=params) - result = self._result(res, json=True) - if greedy: - return [self.inspect_network(net['Id']) for net in result] - else: - return result + return self._result(res, json=True) @minimum_version('1.21') def create_network(self, name, driver=None, options=None, ipam=None, diff --git a/docker/models/networks.py b/docker/models/networks.py index 06ff22b16..1c2fbf246 100644 --- a/docker/models/networks.py +++ b/docker/models/networks.py @@ -1,4 +1,5 @@ from ..api import APIClient +from ..utils import version_gte from .containers import Container from .resource import Model, Collection @@ -201,8 +202,13 @@ def list(self, *args, **kwargs): :py:class:`docker.errors.APIError` If the server returns an error. """ + greedy = kwargs.pop('greedy', False) resp = self.client.api.networks(*args, **kwargs) - return [self.prepare_model(item) for item in resp] + networks = [self.prepare_model(item) for item in resp] + if greedy and version_gte(self.client.api._version, '1.28'): + for net in networks: + net.reload() + return networks def prune(self, filters=None): self.client.api.prune_networks(filters=filters) diff --git a/tests/integration/api_network_test.py b/tests/integration/api_network_test.py index bb900d456..f4fefde5b 100644 --- a/tests/integration/api_network_test.py +++ b/tests/integration/api_network_test.py @@ -112,17 +112,6 @@ def test_connect_and_disconnect_container(self): [container['Id']] ) - network_list = self.client.networks(ids=[net_id], greedy=True) - self.assertEqual( - list( - key - for net in network_list - for key in net['Containers'].keys() - if net['Id'] == net_id - ), - [container['Id']] - ) - with pytest.raises(docker.errors.APIError): self.client.connect_container_to_network(container, net_id) @@ -151,29 +140,10 @@ def test_connect_and_force_disconnect_container(self): [container['Id']] ) - network_list = self.client.networks(ids=[net_id], greedy=True) - self.assertEqual( - list( - key - for net in network_list - for key in net['Containers'].keys() - if net['Id'] == net_id - ), - [container['Id']] - ) - self.client.disconnect_container_from_network(container, net_id, True) network_data = self.client.inspect_network(net_id) self.assertFalse(network_data.get('Containers')) - network_list = self.client.networks(ids=[net_id], greedy=True) - self.assertFalse(list( - key - for net in network_list - for key in net['Containers'].keys() - if net['Id'] == net_id - )) - with pytest.raises(docker.errors.APIError): self.client.disconnect_container_from_network( container, net_id, force=True @@ -213,17 +183,6 @@ def test_connect_on_container_create(self): list(network_data['Containers'].keys()), [container['Id']]) - network_list = self.client.networks(ids=[net_id], greedy=True) - self.assertEqual( - list( - key - for net in network_list - for key in net['Containers'].keys() - if net['Id'] == net_id - ), - [container['Id']] - ) - self.client.disconnect_container_from_network(container, net_id) network_data = self.client.inspect_network(net_id) self.assertFalse(network_data.get('Containers')) diff --git a/tests/unit/models_networks_test.py b/tests/unit/models_networks_test.py index df0650a29..58c9fce66 100644 --- a/tests/unit/models_networks_test.py +++ b/tests/unit/models_networks_test.py @@ -36,10 +36,6 @@ def test_list(self): client.networks.list(names=["foobar"]) assert client.api.networks.called_once_with(names=["foobar"]) - client = make_fake_client() - client.networks.list(greedy=True) - assert client.api.networks.called_once_with(greedy=True) - class NetworkTest(unittest.TestCase): From 5c5705045be72530091a51372ae920f958192bfb Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 29 Nov 2017 16:42:28 -0800 Subject: [PATCH 17/30] Fix common issues with build context creation: inaccessible files and fifos Signed-off-by: Joffrey F --- docker/utils/utils.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/docker/utils/utils.py b/docker/utils/utils.py index a123fd8f8..6cac4bce6 100644 --- a/docker/utils/utils.py +++ b/docker/utils/utils.py @@ -97,7 +97,12 @@ def create_archive(root, files=None, fileobj=None, gzip=False): if files is None: files = build_file_list(root) for path in files: - i = t.gettarinfo(os.path.join(root, path), arcname=path) + full_path = os.path.join(root, path) + if not os.access(full_path, os.R_OK): + raise IOError( + 'Can not access file in context: {}'.format(full_path) + ) + i = t.gettarinfo(full_path, arcname=path) if i is None: # This happens when we encounter a socket file. We can safely # ignore it and proceed. @@ -108,12 +113,14 @@ def create_archive(root, files=None, fileobj=None, gzip=False): # and directories executable by default. i.mode = i.mode & 0o755 | 0o111 - try: - # We open the file object in binary mode for Windows support. - with open(os.path.join(root, path), 'rb') as f: - t.addfile(i, f) - except IOError: - # When we encounter a directory the file object is set to None. + if i.isfile(): + try: + with open(full_path, 'rb') as f: + t.addfile(i, f) + except IOError: + t.addfile(i, None) + else: + # Directories, FIFOs, symlinks... don't need to be read. t.addfile(i, None) t.close() fileobj.seek(0) From 8d770b012d6786ffb468e0fc929bde309a9500b1 Mon Sep 17 00:00:00 2001 From: Michael Hankin Date: Sun, 3 Dec 2017 14:54:28 -0600 Subject: [PATCH 18/30] Change format of extra hosts Signed-off-by: Michael Hankin --- docker/utils/utils.py | 2 +- tests/integration/api_service_test.py | 4 ++-- tests/unit/models_containers_test.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docker/utils/utils.py b/docker/utils/utils.py index a123fd8f8..3e2a710d0 100644 --- a/docker/utils/utils.py +++ b/docker/utils/utils.py @@ -566,7 +566,7 @@ def format_env(key, value): def format_extra_hosts(extra_hosts): return [ - '{}:{}'.format(k, v) for k, v in sorted(six.iteritems(extra_hosts)) + '{} {}'.format(v, k) for k, v in sorted(six.iteritems(extra_hosts)) ] diff --git a/tests/integration/api_service_test.py b/tests/integration/api_service_test.py index b93115494..05b5ba752 100644 --- a/tests/integration/api_service_test.py +++ b/tests/integration/api_service_test.py @@ -588,8 +588,8 @@ def test_create_service_with_hosts(self): assert 'Hosts' in svc_info['Spec']['TaskTemplate']['ContainerSpec'] hosts = svc_info['Spec']['TaskTemplate']['ContainerSpec']['Hosts'] assert len(hosts) == 2 - assert 'foobar:127.0.0.1' in hosts - assert 'baz:8.8.8.8' in hosts + assert '127.0.0.1 foobar' in hosts + assert '8.8.8.8 baz' in hosts @requires_api_version('1.25') def test_create_service_with_hostname(self): diff --git a/tests/unit/models_containers_test.py b/tests/unit/models_containers_test.py index 5eaa45ac6..29a5caad2 100644 --- a/tests/unit/models_containers_test.py +++ b/tests/unit/models_containers_test.py @@ -141,7 +141,7 @@ def test_create_container_args(self): 'Dns': ['8.8.8.8'], 'DnsOptions': ['foo'], 'DnsSearch': ['example.com'], - 'ExtraHosts': ['foo:1.2.3.4'], + 'ExtraHosts': ['1.2.3.4 foo'], 'GroupAdd': ['blah'], 'IpcMode': 'foo', 'KernelMemory': 123, From 0134939c2c5cc6920339a65a69305227849a452d Mon Sep 17 00:00:00 2001 From: Michael Hankin Date: Tue, 5 Dec 2017 21:19:37 -0600 Subject: [PATCH 19/30] Change format in which hosts are being stored for Swarm services Signed-off-by: Michael Hankin --- docker/types/services.py | 2 +- docker/utils/utils.py | 10 ++++++++-- tests/unit/models_containers_test.py | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/docker/types/services.py b/docker/types/services.py index 9031e609a..5b6af8f5a 100644 --- a/docker/types/services.py +++ b/docker/types/services.py @@ -137,7 +137,7 @@ def __init__(self, image, command=None, args=None, hostname=None, env=None, if labels is not None: self['Labels'] = labels if hosts is not None: - self['Hosts'] = format_extra_hosts(hosts) + self['Hosts'] = format_extra_hosts(hosts, task=True) if mounts is not None: parsed_mounts = [] diff --git a/docker/utils/utils.py b/docker/utils/utils.py index 3e2a710d0..845af6538 100644 --- a/docker/utils/utils.py +++ b/docker/utils/utils.py @@ -564,9 +564,15 @@ def format_env(key, value): return [format_env(*var) for var in six.iteritems(environment)] -def format_extra_hosts(extra_hosts): +def format_extra_hosts(extra_hosts, task=False): + # Use format dictated by Swarm API if container is part of a task + if task: + return [ + '{} {}'.format(v, k) for k, v in sorted(six.iteritems(extra_hosts)) + ] + return [ - '{} {}'.format(v, k) for k, v in sorted(six.iteritems(extra_hosts)) + '{}:{}'.format(k, v) for k, v in sorted(six.iteritems(extra_hosts)) ] diff --git a/tests/unit/models_containers_test.py b/tests/unit/models_containers_test.py index 29a5caad2..5eaa45ac6 100644 --- a/tests/unit/models_containers_test.py +++ b/tests/unit/models_containers_test.py @@ -141,7 +141,7 @@ def test_create_container_args(self): 'Dns': ['8.8.8.8'], 'DnsOptions': ['foo'], 'DnsSearch': ['example.com'], - 'ExtraHosts': ['1.2.3.4 foo'], + 'ExtraHosts': ['foo:1.2.3.4'], 'GroupAdd': ['blah'], 'IpcMode': 'foo', 'KernelMemory': 123, From 9d23278643cb6b4a097e833915a73ab9a2eba10d Mon Sep 17 00:00:00 2001 From: Anthony Sottile Date: Thu, 7 Dec 2017 13:52:27 -0800 Subject: [PATCH 20/30] container: fix docstring for containers() Signed-off-by: Anthony Sottile --- docker/api/container.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker/api/container.py b/docker/api/container.py index f3c33c978..8dd89ccf9 100644 --- a/docker/api/container.py +++ b/docker/api/container.py @@ -139,7 +139,8 @@ def containers(self, quiet=False, all=False, trunc=False, latest=False, Args: quiet (bool): Only display numeric Ids all (bool): Show all containers. Only running containers are shown - by default trunc (bool): Truncate output + by default + trunc (bool): Truncate output latest (bool): Show only the latest created container, include non-running ones. since (str): Show only containers created since Id or Name, include From 61bc8bea7f4f4cc9e57da2a9ae36ce5002e129f9 Mon Sep 17 00:00:00 2001 From: Michael Hankin Date: Tue, 12 Dec 2017 15:49:07 -0600 Subject: [PATCH 21/30] Add support for order property when updating a service Signed-off-by: Michael Hankin --- docker/api/service.py | 4 ++++ docker/types/services.py | 11 ++++++++++- tests/integration/api_service_test.py | 18 ++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/docker/api/service.py b/docker/api/service.py index 4c10ef8ef..df899f514 100644 --- a/docker/api/service.py +++ b/docker/api/service.py @@ -19,6 +19,10 @@ def raise_version_error(param, min_version): if 'Monitor' in update_config: raise_version_error('UpdateConfig.monitor', '1.25') + if utils.version_lt(version, '1.29'): + if 'Order' in update_config: + raise_version_error('UpdateConfig.order', '1.29') + if task_template is not None: if 'ForceUpdate' in task_template and utils.version_lt( version, '1.25'): diff --git a/docker/types/services.py b/docker/types/services.py index 9031e609a..14e2cc32b 100644 --- a/docker/types/services.py +++ b/docker/types/services.py @@ -334,9 +334,12 @@ class UpdateConfig(dict): max_failure_ratio (float): The fraction of tasks that may fail during an update before the failure action is invoked, specified as a floating point number between 0 and 1. Default: 0 + order (string): Specifies the order of operations when rolling out an + updated task. Either ``start_first`` or ``stop_first`` are accepted. + Default: ``stop_first`` """ def __init__(self, parallelism=0, delay=None, failure_action='continue', - monitor=None, max_failure_ratio=None): + monitor=None, max_failure_ratio=None, order='stop-first'): self['Parallelism'] = parallelism if delay is not None: self['Delay'] = delay @@ -360,6 +363,12 @@ def __init__(self, parallelism=0, delay=None, failure_action='continue', ) self['MaxFailureRatio'] = max_failure_ratio + if order not in ('start-first', 'stop-first'): + raise errors.InvalidArgument( + 'order must be either `start-first` or `stop-first`' + ) + self['Order'] = order + class RestartConditionTypesEnum(object): _values = ( diff --git a/tests/integration/api_service_test.py b/tests/integration/api_service_test.py index b93115494..fb57f6395 100644 --- a/tests/integration/api_service_test.py +++ b/tests/integration/api_service_test.py @@ -386,6 +386,24 @@ def test_create_service_with_env(self): assert 'Env' in con_spec assert con_spec['Env'] == ['DOCKER_PY_TEST=1'] + @requires_api_version('1.29') + def test_create_service_with_update_order(self): + container_spec = docker.types.ContainerSpec(BUSYBOX, ['true']) + task_tmpl = docker.types.TaskTemplate(container_spec) + update_config = docker.types.UpdateConfig( + parallelism=10, delay=5, order='start-first' + ) + name = self.get_service_name() + svc_id = self.client.create_service( + task_tmpl, update_config=update_config, name=name + ) + svc_info = self.client.inspect_service(svc_id) + assert 'UpdateConfig' in svc_info['Spec'] + uc = svc_info['Spec']['UpdateConfig'] + assert update_config['Parallelism'] == uc['Parallelism'] + assert update_config['Delay'] == uc['Delay'] + assert update_config['Order'] == uc['Order'] + @requires_api_version('1.25') def test_create_service_with_tty(self): container_spec = docker.types.ContainerSpec( From 7db76737ca661f242f9345a2be9a317c79da7575 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 13 Dec 2017 18:32:59 -0800 Subject: [PATCH 22/30] Fix URL-quoting for resource names containing spaces Signed-off-by: Joffrey F --- docker/api/client.py | 2 +- tests/integration/api_network_test.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docker/api/client.py b/docker/api/client.py index cbe74b916..01a83ea49 100644 --- a/docker/api/client.py +++ b/docker/api/client.py @@ -206,7 +206,7 @@ def _url(self, pathfmt, *args, **kwargs): 'instead'.format(arg, type(arg)) ) - quote_f = partial(six.moves.urllib.parse.quote_plus, safe="/:") + quote_f = partial(six.moves.urllib.parse.quote, safe="/:") args = map(quote_f, args) if kwargs.get('versioned_api', True): diff --git a/tests/integration/api_network_test.py b/tests/integration/api_network_test.py index f4fefde5b..10e09dd70 100644 --- a/tests/integration/api_network_test.py +++ b/tests/integration/api_network_test.py @@ -484,3 +484,10 @@ def test_create_inspect_network_with_scope(self): assert self.client.inspect_network(net_name_swarm, scope='swarm') with pytest.raises(docker.errors.NotFound): self.client.inspect_network(net_name_swarm, scope='local') + + @requires_api_version('1.21') + def test_create_remove_network_with_space_in_name(self): + net_id = self.client.create_network('test 01') + self.tmp_networks.append(net_id) + assert self.client.inspect_network('test 01') + assert self.client.remove_network('test 01') is None # does not raise From 445cb18723fe4e9aa3f98020b001842cc9ee8273 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Wed, 13 Dec 2017 19:06:29 -0800 Subject: [PATCH 23/30] Add integration test for CPU realtime options Signed-off-by: Joffrey F --- tests/integration/api_container_test.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/integration/api_container_test.py b/tests/integration/api_container_test.py index f03ccdb43..5e30eee27 100644 --- a/tests/integration/api_container_test.py +++ b/tests/integration/api_container_test.py @@ -464,6 +464,20 @@ def test_create_with_init_path(self): config = self.client.inspect_container(ctnr) assert config['HostConfig']['InitPath'] == "/usr/libexec/docker-init" + @requires_api_version('1.24') + @pytest.mark.xfail(not os.path.exists('/sys/fs/cgroup/cpu.rt_runtime_us'), + reason='CONFIG_RT_GROUP_SCHED isn\'t enabled') + def test_create_with_cpu_rt_options(self): + ctnr = self.client.create_container( + BUSYBOX, 'true', host_config=self.client.create_host_config( + cpu_rt_period=1000, cpu_rt_runtime=500 + ) + ) + self.tmp_containers.append(ctnr) + config = self.client.inspect_container(ctnr) + assert config['HostConfig']['CpuRealtimeRuntime'] == 500 + assert config['HostConfig']['CpuRealtimePeriod'] == 1000 + class VolumeBindTest(BaseAPIIntegrationTest): def setUp(self): From a66c89247a1f896090e23ea6820d77a40cca978b Mon Sep 17 00:00:00 2001 From: Viktor Adam Date: Thu, 14 Dec 2017 09:55:36 +0000 Subject: [PATCH 24/30] Renaming new argument Signed-off-by: Viktor Adam --- docker/api/service.py | 53 ++++++++++++--------------- docker/models/services.py | 6 +-- tests/integration/api_service_test.py | 30 +++++++-------- 3 files changed, 41 insertions(+), 48 deletions(-) diff --git a/docker/api/service.py b/docker/api/service.py index 768d51312..66d98c784 100644 --- a/docker/api/service.py +++ b/docker/api/service.py @@ -62,6 +62,21 @@ def raise_version_error(param, min_version): raise_version_error('ContainerSpec.privileges', '1.30') +def _merge_task_template(current, override): + merged = current.copy() + if override is not None: + for ts_key, ts_value in override.items(): + if ts_key == 'ContainerSpec': + if 'ContainerSpec' not in merged: + merged['ContainerSpec'] = {} + for cs_key, cs_value in override['ContainerSpec'].items(): + if cs_value is not None: + merged['ContainerSpec'][cs_key] = cs_value + elif ts_value is not None: + merged[ts_key] = ts_value + return merged + + class ServiceApiMixin(object): @utils.minimum_version('1.24') def create_service( @@ -306,7 +321,7 @@ def tasks(self, filters=None): def update_service(self, service, version, task_template=None, name=None, labels=None, mode=None, update_config=None, networks=None, endpoint_config=None, - endpoint_spec=None, use_current_spec=False): + endpoint_spec=None, fetch_current_spec=False): """ Update a service. @@ -328,8 +343,8 @@ def update_service(self, service, version, task_template=None, name=None, the service to. Default: ``None``. endpoint_spec (EndpointSpec): Properties that can be configured to access and load balance a service. Default: ``None``. - use_current_spec (boolean): Use the undefined settings from the - previous specification of the service. Default: ``False`` + fetch_current_spec (boolean): Use the undefined settings from the + current specification of the service. Default: ``False`` Returns: ``True`` if successful. @@ -347,11 +362,10 @@ def update_service(self, service, version, task_template=None, name=None, _check_api_features(self._version, task_template, update_config) - if use_current_spec: + if fetch_current_spec: + inspect_defaults = True if utils.version_lt(self._version, '1.29'): inspect_defaults = None - else: - inspect_defaults = True current = self.inspect_service( service, insert_defaults=inspect_defaults )['Spec'] @@ -363,15 +377,9 @@ def update_service(self, service, version, task_template=None, name=None, data = {} headers = {} - if name is not None: - data['Name'] = name - else: - data['Name'] = current.get('Name') + data['Name'] = current.get('Name') if name is None else name - if labels is not None: - data['Labels'] = labels - else: - data['Labels'] = current.get('Labels') + data['Labels'] = current.get('Labels') if labels is None else labels if mode is not None: if not isinstance(mode, dict): @@ -380,7 +388,7 @@ def update_service(self, service, version, task_template=None, name=None, else: data['Mode'] = current.get('Mode') - data['TaskTemplate'] = self._merge_task_template( + data['TaskTemplate'] = _merge_task_template( current.get('TaskTemplate', {}), task_template ) @@ -418,18 +426,3 @@ def update_service(self, service, version, task_template=None, name=None, ) self._raise_for_status(resp) return True - - @staticmethod - def _merge_task_template(current, override): - merged = current.copy() - if override is not None: - for ts_key, ts_value in override.items(): - if ts_key == 'ContainerSpec': - if 'ContainerSpec' not in merged: - merged['ContainerSpec'] = {} - for cs_key, cs_value in override['ContainerSpec'].items(): - if cs_value is not None: - merged['ContainerSpec'][cs_key] = cs_value - elif ts_value is not None: - merged[ts_key] = ts_value - return merged diff --git a/docker/models/services.py b/docker/models/services.py index 39c86efb2..009e4551a 100644 --- a/docker/models/services.py +++ b/docker/models/services.py @@ -299,10 +299,10 @@ def _get_create_service_kwargs(func_name, kwargs): if 'force_update' in kwargs: task_template_kwargs['force_update'] = kwargs.pop('force_update') - # use the current spec by default if updating the service + # fetch the current spec by default if updating the service # through the model - use_current_spec = kwargs.pop('use_current_spec', True) - create_kwargs['use_current_spec'] = use_current_spec + fetch_current_spec = kwargs.pop('fetch_current_spec', True) + create_kwargs['fetch_current_spec'] = fetch_current_spec # All kwargs should have been consumed by this point, so raise # error if any are left diff --git a/tests/integration/api_service_test.py b/tests/integration/api_service_test.py index 10ae1804b..a35d3a576 100644 --- a/tests/integration/api_service_test.py +++ b/tests/integration/api_service_test.py @@ -726,7 +726,7 @@ def test_update_service_with_defaults_name(self): task_tmpl = docker.types.TaskTemplate(container_spec, force_update=10) self._update_service( - svc_id, name, version_index, task_tmpl, use_current_spec=True + svc_id, name, version_index, task_tmpl, fetch_current_spec=True ) svc_info = self.client.inspect_service(svc_id) new_index = svc_info['Version']['Index'] @@ -753,7 +753,7 @@ def test_update_service_with_defaults_labels(self): task_tmpl = docker.types.TaskTemplate(container_spec, force_update=10) self._update_service( svc_id, name, version_index, task_tmpl, name=name, - use_current_spec=True + fetch_current_spec=True ) svc_info = self.client.inspect_service(svc_id) new_index = svc_info['Version']['Index'] @@ -781,7 +781,7 @@ def test_update_service_with_defaults_mode(self): self._update_service( svc_id, name, version_index, labels={'force': 'update'}, - use_current_spec=True + fetch_current_spec=True ) svc_info = self.client.inspect_service(svc_id) new_index = svc_info['Version']['Index'] @@ -811,7 +811,7 @@ def test_update_service_with_defaults_container_labels(self): self._update_service( svc_id, name, version_index, labels={'force': 'update'}, - use_current_spec=True + fetch_current_spec=True ) svc_info = self.client.inspect_service(svc_id) new_index = svc_info['Version']['Index'] @@ -827,7 +827,7 @@ def test_update_service_with_defaults_container_labels(self): ) task_tmpl = docker.types.TaskTemplate(container_spec) self._update_service( - svc_id, name, new_index, task_tmpl, use_current_spec=True + svc_id, name, new_index, task_tmpl, fetch_current_spec=True ) svc_info = self.client.inspect_service(svc_id) newer_index = svc_info['Version']['Index'] @@ -858,7 +858,7 @@ def test_update_service_with_defaults_update_config(self): self._update_service( svc_id, name, version_index, labels={'force': 'update'}, - use_current_spec=True + fetch_current_spec=True ) svc_info = self.client.inspect_service(svc_id) new_index = svc_info['Version']['Index'] @@ -896,7 +896,7 @@ def test_update_service_with_defaults_networks(self): self._update_service( svc_id, name, version_index, labels={'force': 'update'}, - use_current_spec=True + fetch_current_spec=True ) svc_info = self.client.inspect_service(svc_id) new_index = svc_info['Version']['Index'] @@ -908,7 +908,7 @@ def test_update_service_with_defaults_networks(self): self._update_service( svc_id, name, new_index, networks=[net1['Id']], - use_current_spec=True + fetch_current_spec=True ) svc_info = self.client.inspect_service(svc_id) assert 'Networks' in svc_info['Spec']['TaskTemplate'] @@ -951,7 +951,7 @@ def test_update_service_with_defaults_endpoint_spec(self): self._update_service( svc_id, name, version_index, labels={'force': 'update'}, - use_current_spec=True + fetch_current_spec=True ) svc_info = self.client.inspect_service(svc_id) new_index = svc_info['Version']['Index'] @@ -1003,7 +1003,7 @@ def test_update_service_remove_healthcheck(self): version_index = svc_info['Version']['Index'] self._update_service( - svc_id, name, version_index, task_tmpl, use_current_spec=True + svc_id, name, version_index, task_tmpl, fetch_current_spec=True ) svc_info = self.client.inspect_service(svc_id) new_index = svc_info['Version']['Index'] @@ -1030,7 +1030,7 @@ def test_update_service_remove_labels(self): version_index = svc_info['Version']['Index'] self._update_service( - svc_id, name, version_index, labels={}, use_current_spec=True + svc_id, name, version_index, labels={}, fetch_current_spec=True ) svc_info = self.client.inspect_service(svc_id) new_index = svc_info['Version']['Index'] @@ -1061,7 +1061,7 @@ def test_update_service_remove_container_labels(self): ) task_tmpl = docker.types.TaskTemplate(container_spec) self._update_service( - svc_id, name, version_index, task_tmpl, use_current_spec=True + svc_id, name, version_index, task_tmpl, fetch_current_spec=True ) svc_info = self.client.inspect_service(svc_id) new_index = svc_info['Version']['Index'] @@ -1100,7 +1100,7 @@ def test_update_service_with_network_change(self): task_tmpl = docker.types.TaskTemplate(container_spec) self._update_service( svc_id, name, version_index, task_tmpl, name=name, - networks=[net2['Id']], use_current_spec=True + networks=[net2['Id']], fetch_current_spec=True ) svc_info = self.client.inspect_service(svc_id) task_template = svc_info['Spec']['TaskTemplate'] @@ -1114,7 +1114,7 @@ def test_update_service_with_network_change(self): self._update_service( svc_id, name, new_index, name=name, networks=[net1['Id']], - use_current_spec=True + fetch_current_spec=True ) svc_info = self.client.inspect_service(svc_id) task_template = svc_info['Spec']['TaskTemplate'] @@ -1136,7 +1136,7 @@ def test_update_service_with_network_change(self): ) self._update_service( svc_id, name, new_index, task_tmpl, name=name, - use_current_spec=True + fetch_current_spec=True ) svc_info = self.client.inspect_service(svc_id) task_template = svc_info['Spec']['TaskTemplate'] From 49d09583aa7a82859cfaa2415c26833cd2473519 Mon Sep 17 00:00:00 2001 From: Michael Hankin Date: Thu, 14 Dec 2017 08:58:26 -0600 Subject: [PATCH 25/30] Correct default value of order parameter Signed-off-by: Michael Hankin --- docker/types/services.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docker/types/services.py b/docker/types/services.py index 14e2cc32b..bc77b0abc 100644 --- a/docker/types/services.py +++ b/docker/types/services.py @@ -336,10 +336,9 @@ class UpdateConfig(dict): floating point number between 0 and 1. Default: 0 order (string): Specifies the order of operations when rolling out an updated task. Either ``start_first`` or ``stop_first`` are accepted. - Default: ``stop_first`` """ def __init__(self, parallelism=0, delay=None, failure_action='continue', - monitor=None, max_failure_ratio=None, order='stop-first'): + monitor=None, max_failure_ratio=None, order=None): self['Parallelism'] = parallelism if delay is not None: self['Delay'] = delay @@ -363,11 +362,12 @@ def __init__(self, parallelism=0, delay=None, failure_action='continue', ) self['MaxFailureRatio'] = max_failure_ratio - if order not in ('start-first', 'stop-first'): - raise errors.InvalidArgument( - 'order must be either `start-first` or `stop-first`' - ) - self['Order'] = order + if order is not None: + if order not in ('start-first', 'stop-first'): + raise errors.InvalidArgument( + 'order must be either `start-first` or `stop-first`' + ) + self['Order'] = order class RestartConditionTypesEnum(object): From b6d0dc1e5a67e39a497052e713df9e478ea15d29 Mon Sep 17 00:00:00 2001 From: Felipe Ruhland Date: Thu, 14 Dec 2017 22:33:11 -0200 Subject: [PATCH 26/30] Fixed DEFAULT API VERSION in docstrings. Signed-off-by: Felipe Ruhland --- docker/api/client.py | 16 ++++++++-------- docker/client.py | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docker/api/client.py b/docker/api/client.py index 01a83ea49..f0a86d459 100644 --- a/docker/api/client.py +++ b/docker/api/client.py @@ -63,21 +63,21 @@ class APIClient( >>> import docker >>> client = docker.APIClient(base_url='unix://var/run/docker.sock') >>> client.version() - {u'ApiVersion': u'1.24', + {u'ApiVersion': u'1.33', u'Arch': u'amd64', - u'BuildTime': u'2016-09-27T23:38:15.810178467+00:00', - u'Experimental': True, - u'GitCommit': u'45bed2c', - u'GoVersion': u'go1.6.3', - u'KernelVersion': u'4.4.22-moby', + u'BuildTime': u'2017-11-19T18:46:37.000000000+00:00', + u'GitCommit': u'f4ffd2511c', + u'GoVersion': u'go1.9.2', + u'KernelVersion': u'4.14.3-1-ARCH', + u'MinAPIVersion': u'1.12', u'Os': u'linux', - u'Version': u'1.12.2-rc1'} + u'Version': u'17.10.0-ce'} Args: base_url (str): URL to the Docker server. For example, ``unix:///var/run/docker.sock`` or ``tcp://127.0.0.1:1234``. version (str): The version of the API to use. Set to ``auto`` to - automatically detect the server's version. Default: ``1.26`` + automatically detect the server's version. Default: ``1.30`` timeout (int): Default timeout for API calls, in seconds. tls (bool or :py:class:`~docker.tls.TLSConfig`): Enable TLS. Pass ``True`` to enable it with default options, or pass a diff --git a/docker/client.py b/docker/client.py index 29968c1f0..467583e63 100644 --- a/docker/client.py +++ b/docker/client.py @@ -26,7 +26,7 @@ class DockerClient(object): base_url (str): URL to the Docker server. For example, ``unix:///var/run/docker.sock`` or ``tcp://127.0.0.1:1234``. version (str): The version of the API to use. Set to ``auto`` to - automatically detect the server's version. Default: ``1.26`` + automatically detect the server's version. Default: ``1.30`` timeout (int): Default timeout for API calls, in seconds. tls (bool or :py:class:`~docker.tls.TLSConfig`): Enable TLS. Pass ``True`` to enable it with default options, or pass a @@ -60,7 +60,7 @@ def from_env(cls, **kwargs): Args: version (str): The version of the API to use. Set to ``auto`` to - automatically detect the server's version. Default: ``1.26`` + automatically detect the server's version. Default: ``1.30`` timeout (int): Default timeout for API calls, in seconds. ssl_version (int): A valid `SSL version`_. assert_hostname (bool): Verify the hostname of the server. From 6b8dfe42499345aaa1701d835ea0a9b86a00f1a6 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Thu, 14 Dec 2017 15:12:08 -0800 Subject: [PATCH 27/30] Retrieve container logs before container exits / is removed Signed-off-by: Joffrey F --- docker/models/containers.py | 29 ++++++++++++++------- tests/integration/models_containers_test.py | 23 +++++++++++++--- tests/unit/fake_api_client.py | 2 +- tests/unit/models_containers_test.py | 7 +++-- 4 files changed, 43 insertions(+), 18 deletions(-) diff --git a/docker/models/containers.py b/docker/models/containers.py index 4e3d218e5..f16b7cd60 100644 --- a/docker/models/containers.py +++ b/docker/models/containers.py @@ -629,6 +629,9 @@ def run(self, image, command=None, stdout=True, stderr=False, (e.g. ``SIGINT``). storage_opt (dict): Storage driver options per container as a key-value mapping. + stream (bool): If true and ``detach`` is false, return a log + generator instead of a string. Ignored if ``detach`` is true. + Default: ``False``. sysctls (dict): Kernel parameters to set in the container. tmpfs (dict): Temporary filesystems to mount, as a dictionary mapping a path inside the container to options for that path. @@ -696,6 +699,7 @@ def run(self, image, command=None, stdout=True, stderr=False, """ if isinstance(image, Image): image = image.id + stream = kwargs.pop('stream', False) detach = kwargs.pop("detach", False) if detach and remove: if version_gte(self.client.api._version, '1.25'): @@ -723,23 +727,28 @@ def run(self, image, command=None, stdout=True, stderr=False, if detach: return container - exit_status = container.wait() - if exit_status != 0: - stdout = False - stderr = True - logging_driver = container.attrs['HostConfig']['LogConfig']['Type'] + out = None if logging_driver == 'json-file' or logging_driver == 'journald': - out = container.logs(stdout=stdout, stderr=stderr) - else: - out = None + out = container.logs( + stdout=stdout, stderr=stderr, stream=True, follow=True + ) + + exit_status = container.wait() + if exit_status != 0: + out = container.logs(stdout=False, stderr=True) if remove: container.remove() if exit_status != 0: - raise ContainerError(container, exit_status, command, image, out) - return out + raise ContainerError( + container, exit_status, command, image, out + ) + + return out if stream or out is None else b''.join( + [line for line in out] + ) def create(self, image, command=None, **kwargs): """ diff --git a/tests/integration/models_containers_test.py b/tests/integration/models_containers_test.py index ce3349baa..7707ae265 100644 --- a/tests/integration/models_containers_test.py +++ b/tests/integration/models_containers_test.py @@ -1,7 +1,7 @@ import docker import tempfile from .base import BaseIntegrationTest, TEST_API_VERSION -from ..helpers import random_name +from ..helpers import random_name, requires_api_version class ContainerCollectionTest(BaseIntegrationTest): @@ -95,7 +95,7 @@ def test_run_with_none_driver(self): "alpine", "echo hello", log_config=dict(type='none') ) - self.assertEqual(out, None) + assert out is None def test_run_with_json_file_driver(self): client = docker.from_env(version=TEST_API_VERSION) @@ -104,7 +104,24 @@ def test_run_with_json_file_driver(self): "alpine", "echo hello", log_config=dict(type='json-file') ) - self.assertEqual(out, b'hello\n') + assert out == b'hello\n' + + @requires_api_version('1.25') + def test_run_with_auto_remove(self): + client = docker.from_env(version=TEST_API_VERSION) + out = client.containers.run( + 'alpine', 'echo hello', auto_remove=True + ) + assert out == b'hello\n' + + def test_run_with_streamed_logs(self): + client = docker.from_env(version=TEST_API_VERSION) + out = client.containers.run( + 'alpine', 'sh -c "echo hello && echo world"', stream=True + ) + logs = [line for line in out] + assert logs[0] == b'hello\n' + assert logs[1] == b'world\n' def test_get(self): client = docker.from_env(version=TEST_API_VERSION) diff --git a/tests/unit/fake_api_client.py b/tests/unit/fake_api_client.py index 47890ace9..f90835510 100644 --- a/tests/unit/fake_api_client.py +++ b/tests/unit/fake_api_client.py @@ -43,7 +43,7 @@ def make_fake_api_client(): fake_api.get_fake_inspect_container()[1], 'inspect_image.return_value': fake_api.get_fake_inspect_image()[1], 'inspect_network.return_value': fake_api.get_fake_network()[1], - 'logs.return_value': 'hello world\n', + 'logs.return_value': [b'hello world\n'], 'networks.return_value': fake_api.get_fake_network_list()[1], 'start.return_value': None, 'wait.return_value': 0, diff --git a/tests/unit/models_containers_test.py b/tests/unit/models_containers_test.py index 5eaa45ac6..a479e836e 100644 --- a/tests/unit/models_containers_test.py +++ b/tests/unit/models_containers_test.py @@ -12,7 +12,7 @@ def test_run(self): client = make_fake_client() out = client.containers.run("alpine", "echo hello world") - assert out == 'hello world\n' + assert out == b'hello world\n' client.api.create_container.assert_called_with( image="alpine", @@ -24,9 +24,8 @@ def test_run(self): client.api.start.assert_called_with(FAKE_CONTAINER_ID) client.api.wait.assert_called_with(FAKE_CONTAINER_ID) client.api.logs.assert_called_with( - FAKE_CONTAINER_ID, - stderr=False, - stdout=True + FAKE_CONTAINER_ID, stderr=False, stdout=True, stream=True, + follow=True ) def test_create_container_args(self): From b20f800db6f6521995268a5d7a4746c017fc6d9e Mon Sep 17 00:00:00 2001 From: Constantine Peresypkin Date: Sat, 2 Dec 2017 17:18:09 -0500 Subject: [PATCH 28/30] fixes create_api_error_from_http_exception() `create_api_error_from_http_exception()` is never tested in the original code and will fail miserably when fed with empty `HTTPError` object see fixes in requests for this behaviour: https://github.com/requests/requests/pull/3179 Signed-off-by: Constantine Peresypkin --- docker/errors.py | 2 +- tests/unit/errors_test.py | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/docker/errors.py b/docker/errors.py index 2a2f871e5..50423a268 100644 --- a/docker/errors.py +++ b/docker/errors.py @@ -18,7 +18,7 @@ def create_api_error_from_http_exception(e): try: explanation = response.json()['message'] except ValueError: - explanation = response.content.strip() + explanation = (response.content or '').strip() cls = APIError if response.status_code == 404: if explanation and ('No such image' in str(explanation) or diff --git a/tests/unit/errors_test.py b/tests/unit/errors_test.py index 9678669c3..e27a9b197 100644 --- a/tests/unit/errors_test.py +++ b/tests/unit/errors_test.py @@ -3,7 +3,8 @@ import requests from docker.errors import (APIError, ContainerError, DockerException, - create_unexpected_kwargs_error) + create_unexpected_kwargs_error, + create_api_error_from_http_exception) from .fake_api import FAKE_CONTAINER_ID, FAKE_IMAGE_ID from .fake_api_client import make_fake_client @@ -78,6 +79,19 @@ def test_is_client_error_400(self): err = APIError('', response=resp) assert err.is_client_error() is True + def test_create_error_from_exception(self): + resp = requests.Response() + resp.status_code = 500 + err = APIError('') + try: + resp.raise_for_status() + except requests.exceptions.HTTPError as e: + try: + create_api_error_from_http_exception(e) + except APIError as e: + err = e + assert err.is_server_error() is True + class ContainerErrorTest(unittest.TestCase): def test_container_without_stderr(self): From f10c008aa57c4c48cce1a718a0160a54a2b1a371 Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Mon, 18 Dec 2017 18:21:51 -0800 Subject: [PATCH 29/30] Bump 2.7.0 + changelog Signed-off-by: Joffrey F --- docker/version.py | 2 +- docs/change-log.md | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/docker/version.py b/docker/version.py index fd8224617..250218333 100644 --- a/docker/version.py +++ b/docker/version.py @@ -1,2 +1,2 @@ -version = "2.7.0-dev" +version = "2.7.0" version_info = tuple([int(d) for d in version.split("-")[0].split(".")]) diff --git a/docs/change-log.md b/docs/change-log.md index 57293f3e1..b8298a798 100644 --- a/docs/change-log.md +++ b/docs/change-log.md @@ -1,6 +1,41 @@ Change log ========== +2.7.0 +----- + +[List of PRs / issues for this release](https://github.com/docker/docker-py/milestone/41?closed=1) + +### Features + +* Added `unlock_swarm` and `get_unlock_key` methods to the `APIClient`. + * Added `unlock` and `get_unlock_key` to `DockerClient.swarm`. +* Added a `greedy` parameter to `DockerClient.networks.list`, yielding + additional details about the listed networks. +* Added `cpu_rt_runtime` and `cpu_rt_period` as parameters to + `APIClient.create_host_config` and `DockerClient.containers.run`. +* Added the `order` argument to `UpdateConfig`. +* Added `fetch_current_spec` to `APIClient.update_service` and `Service.update` + that will retrieve the current configuration of the service and merge it with + the provided parameters to determine the new configuration. + +### Bugfixes + +* Fixed a bug where the `build` method tried to include inaccessible files + in the context, leading to obscure errors during the build phase + (inaccessible files inside the context now raise an `IOError` instead). +* Fixed a bug where the `build` method would try to read from FIFOs present + inside the build context, causing it to hang. +* `APIClient.stop` will no longer override the `stop_timeout` value present + in the container's configuration. +* Fixed a bug preventing removal of networks with names containing a space. +* Fixed a bug where `DockerClient.containers.run` would crash if the + `auto_remove` parameter was set to `True`. +* Changed the default value of `listen_addr` in `join_swarm` to match the + one in `init_swarm`. +* Fixed a bug where handling HTTP errors with no body would cause an unexpected + exception to be thrown while generating an `APIError` object. + 2.6.1 ----- From 598f16771ca4673886c3ea9b86fa280d77829beb Mon Sep 17 00:00:00 2001 From: Joffrey F Date: Tue, 19 Dec 2017 13:21:56 -0800 Subject: [PATCH 30/30] Don't attempt to retrieve container's stderr if `auto_remove` was set Signed-off-by: Joffrey F --- docker/models/containers.py | 4 +++- tests/integration/models_containers_test.py | 14 +++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/docker/models/containers.py b/docker/models/containers.py index f16b7cd60..6ba308e49 100644 --- a/docker/models/containers.py +++ b/docker/models/containers.py @@ -737,7 +737,9 @@ def run(self, image, command=None, stdout=True, stderr=False, exit_status = container.wait() if exit_status != 0: - out = container.logs(stdout=False, stderr=True) + out = None + if not kwargs.get('auto_remove'): + out = container.logs(stdout=False, stderr=True) if remove: container.remove() diff --git a/tests/integration/models_containers_test.py b/tests/integration/models_containers_test.py index 7707ae265..d246189d0 100644 --- a/tests/integration/models_containers_test.py +++ b/tests/integration/models_containers_test.py @@ -1,5 +1,7 @@ -import docker import tempfile + +import docker +import pytest from .base import BaseIntegrationTest, TEST_API_VERSION from ..helpers import random_name, requires_api_version @@ -114,6 +116,16 @@ def test_run_with_auto_remove(self): ) assert out == b'hello\n' + @requires_api_version('1.25') + def test_run_with_auto_remove_error(self): + client = docker.from_env(version=TEST_API_VERSION) + with pytest.raises(docker.errors.ContainerError) as e: + client.containers.run( + 'alpine', 'sh -c ">&2 echo error && exit 1"', auto_remove=True + ) + assert e.value.exit_status == 1 + assert e.value.stderr is None + def test_run_with_streamed_logs(self): client = docker.from_env(version=TEST_API_VERSION) out = client.containers.run(