From f295a4b73ec45b6c867ebd8f8ea3d0ebf704485a Mon Sep 17 00:00:00 2001 From: Erwan Rouchet Date: Fri, 29 Nov 2019 09:54:44 +0100 Subject: [PATCH 1/6] Add DeviceRequest type Signed-off-by: Erwan Rouchet --- docker/types/__init__.py | 3 +- docker/types/containers.py | 98 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/docker/types/__init__.py b/docker/types/__init__.py index 5db330e28..91401bed4 100644 --- a/docker/types/__init__.py +++ b/docker/types/__init__.py @@ -1,5 +1,6 @@ # flake8: noqa -from .containers import ContainerConfig, HostConfig, LogConfig, Ulimit +from .containers import \ + ContainerConfig, HostConfig, LogConfig, Ulimit, DeviceRequest from .daemon import CancellableStream from .healthcheck import Healthcheck from .networks import EndpointConfig, IPAMConfig, IPAMPool, NetworkingConfig diff --git a/docker/types/containers.py b/docker/types/containers.py index fd8cab497..a1a5b177a 100644 --- a/docker/types/containers.py +++ b/docker/types/containers.py @@ -154,6 +154,104 @@ def hard(self, value): self['Hard'] = value +class DeviceRequest(DictType): + """ + Create a device request to be used with + :py:meth:`~docker.api.container.ContainerApiMixin.create_host_config`. + + Args: + + driver (str): Which driver to use for this device. Optional. + count (int): Number or devices to request. Optional. + Set to -1 to request all available devices. + device_ids (list): List of strings for device IDs. Optional. + Set either ``count`` or ``device_ids``. + capabilities (list): List of lists of strings to request + capabilities. Optional. The global list acts like an OR, + and the sub-lists are AND. The driver will try to satisfy + one of the sub-lists. + Available capabilities for the ``nvidia`` driver can be found + `here `_. + options (dict): Driver-specific options. Optional. + """ + + def __init__(self, **kwargs): + driver = kwargs.get('driver', kwargs.get('Driver')) + count = kwargs.get('count', kwargs.get('Count')) + device_ids = kwargs.get('device_ids', kwargs.get('DeviceIDs')) + capabilities = kwargs.get('capabilities', kwargs.get('Capabilities')) + options = kwargs.get('options', kwargs.get('Options')) + + if driver is None: + driver = '' + elif not isinstance(driver, six.string_types): + raise ValueError('DeviceRequest.driver must be a string') + if count is None: + count = 0 + elif not isinstance(count, int): + raise ValueError('DeviceRequest.count must be an integer') + if device_ids is None: + device_ids = [] + elif not isinstance(device_ids, list): + raise ValueError('DeviceRequest.device_ids must be a list') + if capabilities is None: + capabilities = [] + elif not isinstance(capabilities, list): + raise ValueError('DeviceRequest.capabilities must be a list') + if options is None: + options = {} + elif not isinstance(options, dict): + raise ValueError('DeviceRequest.options must be a dict') + + super(DeviceRequest, self).__init__({ + 'Driver': driver, + 'Count': count, + 'DeviceIDs': device_ids, + 'Capabilities': capabilities, + 'Options': options + }) + + @property + def driver(self): + return self['Driver'] + + @driver.setter + def driver(self, value): + self['Driver'] = value + + @property + def count(self): + return self['Count'] + + @count.setter + def count(self, value): + self['Count'] = value + + @property + def device_ids(self): + return self['DeviceIDs'] + + @device_ids.setter + def device_ids(self, value): + self['DeviceIDs'] = value + + @property + def capabilities(self): + return self['Capabilities'] + + @capabilities.setter + def capabilities(self, value): + self['Capabilities'] = value + + @property + def options(self): + return self['Options'] + + @options.setter + def options(self, value): + self['Options'] = value + + class HostConfig(dict): def __init__(self, version, binds=None, port_bindings=None, lxc_conf=None, publish_all_ports=False, links=None, From c54ea3066bb2730fb7af7fe903b28f61b85d878f Mon Sep 17 00:00:00 2001 From: Erwan Rouchet Date: Fri, 29 Nov 2019 09:55:18 +0100 Subject: [PATCH 2/6] Add device_requests kwarg in host config Signed-off-by: Erwan Rouchet --- docker/api/container.py | 3 +++ docker/models/containers.py | 4 ++++ docker/types/containers.py | 15 ++++++++++++++- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/docker/api/container.py b/docker/api/container.py index 45bd3528b..fc9bcd389 100644 --- a/docker/api/container.py +++ b/docker/api/container.py @@ -480,6 +480,9 @@ def create_host_config(self, *args, **kwargs): For example, ``/dev/sda:/dev/xvda:rwm`` allows the container to have read-write access to the host's ``/dev/sda`` via a node named ``/dev/xvda`` inside the container. + device_requests (:py:class:`list`): Expose host resources such as + GPUs to the container, as a list of + :py:class:`docker.types.DeviceRequest` instances. dns (:py:class:`list`): Set custom DNS servers. dns_opt (:py:class:`list`): Additional options to be added to the container's ``resolv.conf`` file diff --git a/docker/models/containers.py b/docker/models/containers.py index d1f275f74..e8082ba41 100644 --- a/docker/models/containers.py +++ b/docker/models/containers.py @@ -579,6 +579,9 @@ def run(self, image, command=None, stdout=True, stderr=False, For example, ``/dev/sda:/dev/xvda:rwm`` allows the container to have read-write access to the host's ``/dev/sda`` via a node named ``/dev/xvda`` inside the container. + device_requests (:py:class:`list`): Expose host resources such as + GPUs to the container, as a list of + :py:class:`docker.types.DeviceRequest` instances. dns (:py:class:`list`): Set custom DNS servers. dns_opt (:py:class:`list`): Additional options to be added to the container's ``resolv.conf`` file. @@ -998,6 +1001,7 @@ def prune(self, filters=None): 'device_write_bps', 'device_write_iops', 'devices', + 'device_requests', 'dns_opt', 'dns_search', 'dns', diff --git a/docker/types/containers.py b/docker/types/containers.py index a1a5b177a..149b85dfc 100644 --- a/docker/types/containers.py +++ b/docker/types/containers.py @@ -274,7 +274,7 @@ def __init__(self, version, binds=None, port_bindings=None, volume_driver=None, cpu_count=None, cpu_percent=None, nano_cpus=None, cpuset_mems=None, runtime=None, mounts=None, cpu_rt_period=None, cpu_rt_runtime=None, - device_cgroup_rules=None): + device_cgroup_rules=None, device_requests=None): if mem_limit is not None: self['Memory'] = parse_bytes(mem_limit) @@ -634,6 +634,19 @@ def __init__(self, version, binds=None, port_bindings=None, ) self['DeviceCgroupRules'] = device_cgroup_rules + if device_requests is not None: + if version_lt(version, '1.40'): + raise host_config_version_error('device_requests', '1.40') + if not isinstance(device_requests, list): + raise host_config_type_error( + 'device_requests', device_requests, 'list' + ) + self['DeviceRequests'] = [] + for req in device_requests: + if not isinstance(req, DeviceRequest): + req = DeviceRequest(**req) + self['DeviceRequests'].append(req) + def host_config_type_error(param, param_value, expected): error_msg = 'Invalid type for {0} param: expected {1} but found {2}' From 2266fe231ee00c8c9b84aa139d17075b7d8b70ce Mon Sep 17 00:00:00 2001 From: Erwan Rouchet Date: Fri, 29 Nov 2019 09:55:33 +0100 Subject: [PATCH 3/6] Add unit test for device requests Signed-off-by: Erwan Rouchet --- tests/unit/api_container_test.py | 56 ++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/unit/api_container_test.py b/tests/unit/api_container_test.py index a7e183c83..5bad2cbb3 100644 --- a/tests/unit/api_container_test.py +++ b/tests/unit/api_container_test.py @@ -767,6 +767,62 @@ def test_create_container_with_devices(self): assert args[1]['headers'] == {'Content-Type': 'application/json'} assert args[1]['timeout'] == DEFAULT_TIMEOUT_SECONDS + # @requires_api_version('1.40') + def test_create_container_with_device_requests(self): + self.client.create_container( + 'busybox', 'true', host_config=self.client.create_host_config( + device_requests=[ + { + 'device_ids': [ + '0', + 'GPU-3a23c669-1f69-c64e-cf85-44e9b07e7a2a' + ] + }, + { + 'driver': 'nvidia', + 'Count': -1, + 'capabilities': [ + ['gpu', 'utility'] + ], + 'options': { + 'key': 'value' + } + } + ] + ) + ) + + args = fake_request.call_args + assert args[0][1] == url_prefix + 'containers/create' + expected_payload = self.base_create_payload() + expected_payload['HostConfig'] = self.client.create_host_config() + expected_payload['HostConfig']['DeviceRequests'] = [ + { + 'Driver': '', + 'Count': 0, + 'DeviceIDs': [ + '0', + 'GPU-3a23c669-1f69-c64e-cf85-44e9b07e7a2a' + ], + 'Capabilities': [], + 'Options': {} + }, + { + 'Driver': 'nvidia', + 'Count': -1, + 'DeviceIDs': [], + 'Capabilities': [ + ['gpu', 'utility'] + ], + 'Options': { + 'key': 'value' + } + } + ] + assert json.loads(args[1]['data']) == expected_payload + assert args[1]['headers'] == {'Content-Type': 'application/json'} + assert args[1]['timeout'] == DEFAULT_TIMEOUT_SECONDS + def test_create_container_with_labels_dict(self): labels_dict = { six.text_type('foo'): six.text_type('1'), From 4459ea21385f25e43a9148d47049098ad14917c8 Mon Sep 17 00:00:00 2001 From: Erwan Rouchet Date: Fri, 29 Nov 2019 10:19:54 +0100 Subject: [PATCH 4/6] Fix unit test Signed-off-by: Erwan Rouchet --- tests/unit/api_container_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/api_container_test.py b/tests/unit/api_container_test.py index 5bad2cbb3..99db6d551 100644 --- a/tests/unit/api_container_test.py +++ b/tests/unit/api_container_test.py @@ -767,7 +767,7 @@ def test_create_container_with_devices(self): assert args[1]['headers'] == {'Content-Type': 'application/json'} assert args[1]['timeout'] == DEFAULT_TIMEOUT_SECONDS - # @requires_api_version('1.40') + @requires_api_version('1.40') def test_create_container_with_device_requests(self): self.client.create_container( 'busybox', 'true', host_config=self.client.create_host_config( From 32d2e3bf41d48200bea1ebd040152d936d8d1a69 Mon Sep 17 00:00:00 2001 From: Erwan Rouchet Date: Fri, 20 Dec 2019 10:10:03 +0100 Subject: [PATCH 5/6] Use parentheses for multiline import Signed-off-by: Erwan Rouchet --- docker/types/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker/types/__init__.py b/docker/types/__init__.py index 91401bed4..b425746e7 100644 --- a/docker/types/__init__.py +++ b/docker/types/__init__.py @@ -1,6 +1,7 @@ # flake8: noqa -from .containers import \ +from .containers import ( ContainerConfig, HostConfig, LogConfig, Ulimit, DeviceRequest +) from .daemon import CancellableStream from .healthcheck import Healthcheck from .networks import EndpointConfig, IPAMConfig, IPAMPool, NetworkingConfig From b9410ab77b7faffd939d89f79e74dea5ebbec0d7 Mon Sep 17 00:00:00 2001 From: Laurie O Date: Mon, 27 Apr 2020 11:43:19 +1000 Subject: [PATCH 6/6] Create 1.40 client for device-requests test Signed-off-by: Laurie O --- tests/unit/api_container_test.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/tests/unit/api_container_test.py b/tests/unit/api_container_test.py index 99db6d551..8a0577e78 100644 --- a/tests/unit/api_container_test.py +++ b/tests/unit/api_container_test.py @@ -5,6 +5,7 @@ import signal import docker +from docker.api import APIClient import pytest import six @@ -12,7 +13,7 @@ from ..helpers import requires_api_version from .api_test import ( BaseAPIClientTest, url_prefix, fake_request, DEFAULT_TIMEOUT_SECONDS, - fake_inspect_container + fake_inspect_container, url_base ) try: @@ -767,10 +768,14 @@ def test_create_container_with_devices(self): assert args[1]['headers'] == {'Content-Type': 'application/json'} assert args[1]['timeout'] == DEFAULT_TIMEOUT_SECONDS - @requires_api_version('1.40') def test_create_container_with_device_requests(self): - self.client.create_container( - 'busybox', 'true', host_config=self.client.create_host_config( + client = APIClient(version='1.40') + fake_api.fake_responses.setdefault( + '{0}/v1.40/containers/create'.format(fake_api.prefix), + fake_api.post_fake_create_container, + ) + client.create_container( + 'busybox', 'true', host_config=client.create_host_config( device_requests=[ { 'device_ids': [ @@ -793,9 +798,9 @@ def test_create_container_with_device_requests(self): ) args = fake_request.call_args - assert args[0][1] == url_prefix + 'containers/create' + assert args[0][1] == url_base + 'v1.40/' + 'containers/create' expected_payload = self.base_create_payload() - expected_payload['HostConfig'] = self.client.create_host_config() + expected_payload['HostConfig'] = client.create_host_config() expected_payload['HostConfig']['DeviceRequests'] = [ { 'Driver': '', @@ -820,7 +825,8 @@ def test_create_container_with_device_requests(self): } ] assert json.loads(args[1]['data']) == expected_payload - assert args[1]['headers'] == {'Content-Type': 'application/json'} + assert args[1]['headers']['Content-Type'] == 'application/json' + assert set(args[1]['headers']) <= {'Content-Type', 'User-Agent'} assert args[1]['timeout'] == DEFAULT_TIMEOUT_SECONDS def test_create_container_with_labels_dict(self):