Skip to content

Commit dd0450a

Browse files
LucidiotEpicWinkLa0
authored
Add device requests (#2471)
* Add DeviceRequest type Signed-off-by: Erwan Rouchet <rouchet@teklia.com> * Add device_requests kwarg in host config Signed-off-by: Erwan Rouchet <rouchet@teklia.com> * Add unit test for device requests Signed-off-by: Erwan Rouchet <rouchet@teklia.com> * Fix unit test Signed-off-by: Erwan Rouchet <rouchet@teklia.com> * Use parentheses for multiline import Signed-off-by: Erwan Rouchet <rouchet@teklia.com> * Create 1.40 client for device-requests test Signed-off-by: Laurie O <laurie_opperman@hotmail.com> Co-authored-by: Laurie O <laurie_opperman@hotmail.com> Co-authored-by: Bastien Abadie <abadie@teklia.com>
1 parent 26d8045 commit dd0450a

File tree

5 files changed

+185
-3
lines changed

5 files changed

+185
-3
lines changed

docker/api/container.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,9 @@ def create_host_config(self, *args, **kwargs):
480480
For example, ``/dev/sda:/dev/xvda:rwm`` allows the container
481481
to have read-write access to the host's ``/dev/sda`` via a
482482
node named ``/dev/xvda`` inside the container.
483+
device_requests (:py:class:`list`): Expose host resources such as
484+
GPUs to the container, as a list of
485+
:py:class:`docker.types.DeviceRequest` instances.
483486
dns (:py:class:`list`): Set custom DNS servers.
484487
dns_opt (:py:class:`list`): Additional options to be added to the
485488
container's ``resolv.conf`` file

docker/models/containers.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,9 @@ def run(self, image, command=None, stdout=True, stderr=False,
579579
For example, ``/dev/sda:/dev/xvda:rwm`` allows the container
580580
to have read-write access to the host's ``/dev/sda`` via a
581581
node named ``/dev/xvda`` inside the container.
582+
device_requests (:py:class:`list`): Expose host resources such as
583+
GPUs to the container, as a list of
584+
:py:class:`docker.types.DeviceRequest` instances.
582585
dns (:py:class:`list`): Set custom DNS servers.
583586
dns_opt (:py:class:`list`): Additional options to be added to the
584587
container's ``resolv.conf`` file.
@@ -998,6 +1001,7 @@ def prune(self, filters=None):
9981001
'device_write_bps',
9991002
'device_write_iops',
10001003
'devices',
1004+
'device_requests',
10011005
'dns_opt',
10021006
'dns_search',
10031007
'dns',

docker/types/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# flake8: noqa
2-
from .containers import ContainerConfig, HostConfig, LogConfig, Ulimit
2+
from .containers import (
3+
ContainerConfig, HostConfig, LogConfig, Ulimit, DeviceRequest
4+
)
35
from .daemon import CancellableStream
46
from .healthcheck import Healthcheck
57
from .networks import EndpointConfig, IPAMConfig, IPAMPool, NetworkingConfig

docker/types/containers.py

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,104 @@ def hard(self, value):
154154
self['Hard'] = value
155155

156156

157+
class DeviceRequest(DictType):
158+
"""
159+
Create a device request to be used with
160+
:py:meth:`~docker.api.container.ContainerApiMixin.create_host_config`.
161+
162+
Args:
163+
164+
driver (str): Which driver to use for this device. Optional.
165+
count (int): Number or devices to request. Optional.
166+
Set to -1 to request all available devices.
167+
device_ids (list): List of strings for device IDs. Optional.
168+
Set either ``count`` or ``device_ids``.
169+
capabilities (list): List of lists of strings to request
170+
capabilities. Optional. The global list acts like an OR,
171+
and the sub-lists are AND. The driver will try to satisfy
172+
one of the sub-lists.
173+
Available capabilities for the ``nvidia`` driver can be found
174+
`here <https://github.com/NVIDIA/nvidia-container-runtime>`_.
175+
options (dict): Driver-specific options. Optional.
176+
"""
177+
178+
def __init__(self, **kwargs):
179+
driver = kwargs.get('driver', kwargs.get('Driver'))
180+
count = kwargs.get('count', kwargs.get('Count'))
181+
device_ids = kwargs.get('device_ids', kwargs.get('DeviceIDs'))
182+
capabilities = kwargs.get('capabilities', kwargs.get('Capabilities'))
183+
options = kwargs.get('options', kwargs.get('Options'))
184+
185+
if driver is None:
186+
driver = ''
187+
elif not isinstance(driver, six.string_types):
188+
raise ValueError('DeviceRequest.driver must be a string')
189+
if count is None:
190+
count = 0
191+
elif not isinstance(count, int):
192+
raise ValueError('DeviceRequest.count must be an integer')
193+
if device_ids is None:
194+
device_ids = []
195+
elif not isinstance(device_ids, list):
196+
raise ValueError('DeviceRequest.device_ids must be a list')
197+
if capabilities is None:
198+
capabilities = []
199+
elif not isinstance(capabilities, list):
200+
raise ValueError('DeviceRequest.capabilities must be a list')
201+
if options is None:
202+
options = {}
203+
elif not isinstance(options, dict):
204+
raise ValueError('DeviceRequest.options must be a dict')
205+
206+
super(DeviceRequest, self).__init__({
207+
'Driver': driver,
208+
'Count': count,
209+
'DeviceIDs': device_ids,
210+
'Capabilities': capabilities,
211+
'Options': options
212+
})
213+
214+
@property
215+
def driver(self):
216+
return self['Driver']
217+
218+
@driver.setter
219+
def driver(self, value):
220+
self['Driver'] = value
221+
222+
@property
223+
def count(self):
224+
return self['Count']
225+
226+
@count.setter
227+
def count(self, value):
228+
self['Count'] = value
229+
230+
@property
231+
def device_ids(self):
232+
return self['DeviceIDs']
233+
234+
@device_ids.setter
235+
def device_ids(self, value):
236+
self['DeviceIDs'] = value
237+
238+
@property
239+
def capabilities(self):
240+
return self['Capabilities']
241+
242+
@capabilities.setter
243+
def capabilities(self, value):
244+
self['Capabilities'] = value
245+
246+
@property
247+
def options(self):
248+
return self['Options']
249+
250+
@options.setter
251+
def options(self, value):
252+
self['Options'] = value
253+
254+
157255
class HostConfig(dict):
158256
def __init__(self, version, binds=None, port_bindings=None,
159257
lxc_conf=None, publish_all_ports=False, links=None,
@@ -176,7 +274,7 @@ def __init__(self, version, binds=None, port_bindings=None,
176274
volume_driver=None, cpu_count=None, cpu_percent=None,
177275
nano_cpus=None, cpuset_mems=None, runtime=None, mounts=None,
178276
cpu_rt_period=None, cpu_rt_runtime=None,
179-
device_cgroup_rules=None):
277+
device_cgroup_rules=None, device_requests=None):
180278

181279
if mem_limit is not None:
182280
self['Memory'] = parse_bytes(mem_limit)
@@ -536,6 +634,19 @@ def __init__(self, version, binds=None, port_bindings=None,
536634
)
537635
self['DeviceCgroupRules'] = device_cgroup_rules
538636

637+
if device_requests is not None:
638+
if version_lt(version, '1.40'):
639+
raise host_config_version_error('device_requests', '1.40')
640+
if not isinstance(device_requests, list):
641+
raise host_config_type_error(
642+
'device_requests', device_requests, 'list'
643+
)
644+
self['DeviceRequests'] = []
645+
for req in device_requests:
646+
if not isinstance(req, DeviceRequest):
647+
req = DeviceRequest(**req)
648+
self['DeviceRequests'].append(req)
649+
539650

540651
def host_config_type_error(param, param_value, expected):
541652
error_msg = 'Invalid type for {0} param: expected {1} but found {2}'

tests/unit/api_container_test.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@
55
import signal
66

77
import docker
8+
from docker.api import APIClient
89
import pytest
910
import six
1011

1112
from . import fake_api
1213
from ..helpers import requires_api_version
1314
from .api_test import (
1415
BaseAPIClientTest, url_prefix, fake_request, DEFAULT_TIMEOUT_SECONDS,
15-
fake_inspect_container
16+
fake_inspect_container, url_base
1617
)
1718

1819
try:
@@ -767,6 +768,67 @@ def test_create_container_with_devices(self):
767768
assert args[1]['headers'] == {'Content-Type': 'application/json'}
768769
assert args[1]['timeout'] == DEFAULT_TIMEOUT_SECONDS
769770

771+
def test_create_container_with_device_requests(self):
772+
client = APIClient(version='1.40')
773+
fake_api.fake_responses.setdefault(
774+
'{0}/v1.40/containers/create'.format(fake_api.prefix),
775+
fake_api.post_fake_create_container,
776+
)
777+
client.create_container(
778+
'busybox', 'true', host_config=client.create_host_config(
779+
device_requests=[
780+
{
781+
'device_ids': [
782+
'0',
783+
'GPU-3a23c669-1f69-c64e-cf85-44e9b07e7a2a'
784+
]
785+
},
786+
{
787+
'driver': 'nvidia',
788+
'Count': -1,
789+
'capabilities': [
790+
['gpu', 'utility']
791+
],
792+
'options': {
793+
'key': 'value'
794+
}
795+
}
796+
]
797+
)
798+
)
799+
800+
args = fake_request.call_args
801+
assert args[0][1] == url_base + 'v1.40/' + 'containers/create'
802+
expected_payload = self.base_create_payload()
803+
expected_payload['HostConfig'] = client.create_host_config()
804+
expected_payload['HostConfig']['DeviceRequests'] = [
805+
{
806+
'Driver': '',
807+
'Count': 0,
808+
'DeviceIDs': [
809+
'0',
810+
'GPU-3a23c669-1f69-c64e-cf85-44e9b07e7a2a'
811+
],
812+
'Capabilities': [],
813+
'Options': {}
814+
},
815+
{
816+
'Driver': 'nvidia',
817+
'Count': -1,
818+
'DeviceIDs': [],
819+
'Capabilities': [
820+
['gpu', 'utility']
821+
],
822+
'Options': {
823+
'key': 'value'
824+
}
825+
}
826+
]
827+
assert json.loads(args[1]['data']) == expected_payload
828+
assert args[1]['headers']['Content-Type'] == 'application/json'
829+
assert set(args[1]['headers']) <= {'Content-Type', 'User-Agent'}
830+
assert args[1]['timeout'] == DEFAULT_TIMEOUT_SECONDS
831+
770832
def test_create_container_with_labels_dict(self):
771833
labels_dict = {
772834
six.text_type('foo'): six.text_type('1'),

0 commit comments

Comments
 (0)