diff --git a/docker/api/client.py b/docker/api/client.py index a2cb459de..20f8a2af7 100644 --- a/docker/api/client.py +++ b/docker/api/client.py @@ -5,7 +5,6 @@ import requests import requests.exceptions -import websocket from .. import auth from ..constants import (DEFAULT_NUM_POOLS, DEFAULT_NUM_POOLS_SSH, @@ -309,7 +308,16 @@ def _attach_websocket(self, container, params=None): return self._create_websocket_connection(full_url) def _create_websocket_connection(self, url): - return websocket.create_connection(url) + try: + import websocket + return websocket.create_connection(url) + except ImportError as ie: + raise DockerException( + 'The `websocket-client` library is required ' + 'for using websocket connections. ' + 'You can install the `docker` library ' + 'with the [websocket] extra to install it.' + ) from ie def _get_raw_response_socket(self, response): self._raise_for_status(response) diff --git a/docker/models/containers.py b/docker/models/containers.py index fb5fc4366..f8aeb39b1 100644 --- a/docker/models/containers.py +++ b/docker/models/containers.py @@ -2,16 +2,16 @@ import ntpath from collections import namedtuple +from .images import Image +from .resource import Collection, Model from ..api import APIClient from ..constants import DEFAULT_DATA_CHUNK_SIZE from ..errors import ( ContainerError, DockerException, ImageNotFound, NotFound, create_unexpected_kwargs_error ) -from ..types import HostConfig +from ..types import HostConfig, NetworkingConfig from ..utils import version_gte -from .images import Image -from .resource import Collection, Model class Container(Model): @@ -21,6 +21,7 @@ class Container(Model): query the Docker daemon for the current properties, causing :py:attr:`attrs` to be refreshed. """ + @property def name(self): """ @@ -688,10 +689,14 @@ def run(self, image, command=None, stdout=True, stderr=False, This mode is incompatible with ``ports``. Incompatible with ``network``. - network_driver_opt (dict): A dictionary of options to provide - to the network driver. Defaults to ``None``. Used in - conjuction with ``network``. Incompatible - with ``network_mode``. + networking_config (Dict[str, EndpointConfig]): + Dictionary of EndpointConfig objects for each container network. + The key is the name of the network. + Defaults to ``None``. + + Used in conjuction with ``network``. + + Incompatible with ``network_mode``. oom_kill_disable (bool): Whether to disable OOM killer. oom_score_adj (int): An integer value containing the score given to the container in order to tune OOM killer preferences. @@ -856,9 +861,9 @@ def run(self, image, command=None, stdout=True, stderr=False, 'together.' ) - if kwargs.get('network_driver_opt') and not kwargs.get('network'): + if kwargs.get('networking_config') and not kwargs.get('network'): raise RuntimeError( - 'The options "network_driver_opt" can not be used ' + 'The option "networking_config" can not be used ' 'without "network".' ) @@ -1014,6 +1019,7 @@ def list(self, all=False, before=None, filters=None, limit=-1, since=None, def prune(self, filters=None): return self.client.api.prune_containers(filters=filters) + prune.__doc__ = APIClient.prune_containers.__doc__ @@ -1132,12 +1138,17 @@ def _create_container_args(kwargs): host_config_kwargs['binds'] = volumes network = kwargs.pop('network', None) - network_driver_opt = kwargs.pop('network_driver_opt', None) + networking_config = kwargs.pop('networking_config', None) if network: - network_configuration = {'driver_opt': network_driver_opt} \ - if network_driver_opt else None - - create_kwargs['networking_config'] = {network: network_configuration} + if networking_config: + # Sanity check: check if the network is defined in the + # networking config dict, otherwise switch to None + if network not in networking_config: + networking_config = None + + create_kwargs['networking_config'] = NetworkingConfig( + networking_config + ) if networking_config else {network: None} host_config_kwargs['network_mode'] = network # All kwargs should have been consumed by this point, so raise diff --git a/setup.py b/setup.py index 866aa23c8..79bf3bdb6 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,6 @@ 'packaging >= 14.0', 'requests >= 2.26.0', 'urllib3 >= 1.26.0', - 'websocket-client >= 0.32.0', ] extras_require = { @@ -27,6 +26,9 @@ # Only required when connecting using the ssh:// protocol 'ssh': ['paramiko>=2.4.3'], + + # Only required when using websockets + 'websockets': ['websocket-client >= 1.3.0'], } with open('./test-requirements.txt') as test_reqs_txt: diff --git a/tests/integration/models_containers_test.py b/tests/integration/models_containers_test.py index 4d33e622e..219b9a4cb 100644 --- a/tests/integration/models_containers_test.py +++ b/tests/integration/models_containers_test.py @@ -5,10 +5,10 @@ import pytest import docker -from ..helpers import random_name -from ..helpers import requires_api_version from .base import BaseIntegrationTest from .base import TEST_API_VERSION +from ..helpers import random_name +from ..helpers import requires_api_version class ContainerCollectionTest(BaseIntegrationTest): @@ -104,6 +104,96 @@ def test_run_with_network(self): assert 'Networks' in attrs['NetworkSettings'] assert list(attrs['NetworkSettings']['Networks'].keys()) == [net_name] + def test_run_with_networking_config(self): + net_name = random_name() + client = docker.from_env(version=TEST_API_VERSION) + client.networks.create(net_name) + self.tmp_networks.append(net_name) + + test_aliases = ['hello'] + test_driver_opt = {'key1': 'a'} + + networking_config = { + net_name: client.api.create_endpoint_config( + aliases=test_aliases, + driver_opt=test_driver_opt + ) + } + + container = client.containers.run( + 'alpine', 'echo hello world', network=net_name, + networking_config=networking_config, + detach=True + ) + self.tmp_containers.append(container.id) + + attrs = container.attrs + + assert 'NetworkSettings' in attrs + assert 'Networks' in attrs['NetworkSettings'] + assert list(attrs['NetworkSettings']['Networks'].keys()) == [net_name] + assert attrs['NetworkSettings']['Networks'][net_name]['Aliases'] == \ + test_aliases + assert attrs['NetworkSettings']['Networks'][net_name]['DriverOpts'] \ + == test_driver_opt + + def test_run_with_networking_config_with_undeclared_network(self): + net_name = random_name() + client = docker.from_env(version=TEST_API_VERSION) + client.networks.create(net_name) + self.tmp_networks.append(net_name) + + test_aliases = ['hello'] + test_driver_opt = {'key1': 'a'} + + networking_config = { + net_name: client.api.create_endpoint_config( + aliases=test_aliases, + driver_opt=test_driver_opt + ), + 'bar': client.api.create_endpoint_config( + aliases=['test'], + driver_opt={'key2': 'b'} + ), + } + + with pytest.raises(docker.errors.APIError): + container = client.containers.run( + 'alpine', 'echo hello world', network=net_name, + networking_config=networking_config, + detach=True + ) + self.tmp_containers.append(container.id) + + def test_run_with_networking_config_only_undeclared_network(self): + net_name = random_name() + client = docker.from_env(version=TEST_API_VERSION) + client.networks.create(net_name) + self.tmp_networks.append(net_name) + + networking_config = { + 'bar': client.api.create_endpoint_config( + aliases=['hello'], + driver_opt={'key1': 'a'} + ), + } + + container = client.containers.run( + 'alpine', 'echo hello world', network=net_name, + networking_config=networking_config, + detach=True + ) + self.tmp_containers.append(container.id) + + attrs = container.attrs + + assert 'NetworkSettings' in attrs + assert 'Networks' in attrs['NetworkSettings'] + assert list(attrs['NetworkSettings']['Networks'].keys()) == [net_name] + assert attrs['NetworkSettings']['Networks'][net_name]['Aliases'] is None + assert (attrs['NetworkSettings']['Networks'][net_name]['DriverOpts'] + is None) + def test_run_with_none_driver(self): client = docker.from_env(version=TEST_API_VERSION) @@ -187,7 +277,7 @@ def test_get(self): container = client.containers.run("alpine", "sleep 300", detach=True) self.tmp_containers.append(container.id) assert client.containers.get(container.id).attrs[ - 'Config']['Image'] == "alpine" + 'Config']['Image'] == "alpine" def test_list(self): client = docker.from_env(version=TEST_API_VERSION) diff --git a/tests/unit/models_containers_test.py b/tests/unit/models_containers_test.py index 2eabd2685..05005815f 100644 --- a/tests/unit/models_containers_test.py +++ b/tests/unit/models_containers_test.py @@ -1,12 +1,15 @@ +import unittest + +import pytest + import docker -from docker.constants import DEFAULT_DATA_CHUNK_SIZE +from docker.constants import DEFAULT_DATA_CHUNK_SIZE, \ + DEFAULT_DOCKER_API_VERSION from docker.models.containers import Container, _create_container_args from docker.models.images import Image -import unittest - +from docker.types import EndpointConfig from .fake_api import FAKE_CONTAINER_ID, FAKE_IMAGE_ID, FAKE_EXEC_ID from .fake_api_client import make_fake_client -import pytest class ContainerCollectionTest(unittest.TestCase): @@ -31,77 +34,80 @@ def test_run(self): ) def test_create_container_args(self): + networking_config = { + 'foo': EndpointConfig( + DEFAULT_DOCKER_API_VERSION, aliases=['test'], + driver_opt={'key1': 'a'} + ) + } + create_kwargs = _create_container_args({ - "image": 'alpine', - "command": 'echo hello world', - "blkio_weight_device": [{'Path': 'foo', 'Weight': 3}], - "blkio_weight": 2, - "cap_add": ['foo'], - "cap_drop": ['bar'], - "cgroup_parent": 'foobar', - "cgroupns": 'host', - "cpu_period": 1, - "cpu_quota": 2, - "cpu_shares": 5, - "cpuset_cpus": '0-3', - "detach": False, - "device_read_bps": [{'Path': 'foo', 'Rate': 3}], - "device_read_iops": [{'Path': 'foo', 'Rate': 3}], - "device_write_bps": [{'Path': 'foo', 'Rate': 3}], - "device_write_iops": [{'Path': 'foo', 'Rate': 3}], - "devices": ['/dev/sda:/dev/xvda:rwm'], - "dns": ['8.8.8.8'], - "domainname": 'example.com', - "dns_opt": ['foo'], - "dns_search": ['example.com'], - "entrypoint": '/bin/sh', - "environment": {'FOO': 'BAR'}, - "extra_hosts": {'foo': '1.2.3.4'}, - "group_add": ['blah'], - "ipc_mode": 'foo', - "kernel_memory": 123, - "labels": {'key': 'value'}, - "links": {'foo': 'bar'}, - "log_config": {'Type': 'json-file', 'Config': {}}, - "lxc_conf": {'foo': 'bar'}, - "healthcheck": {'test': 'true'}, - "hostname": 'somehost', - "mac_address": 'abc123', - "mem_limit": 123, - "mem_reservation": 123, - "mem_swappiness": 2, - "memswap_limit": 456, - "name": 'somename', - "network_disabled": False, - "network": 'foo', - "network_driver_opt": {'key1': 'a'}, - "oom_kill_disable": True, - "oom_score_adj": 5, - "pid_mode": 'host', - "pids_limit": 500, - "platform": 'linux', - "ports": { - 1111: 4567, - 2222: None - }, - "privileged": True, - "publish_all_ports": True, - "read_only": True, - "restart_policy": {'Name': 'always'}, - "security_opt": ['blah'], - "shm_size": 123, - "stdin_open": True, - "stop_signal": 9, - "sysctls": {'foo': 'bar'}, - "tmpfs": {'/blah': ''}, - "tty": True, - "ulimits": [{"Name": "nofile", "Soft": 1024, "Hard": 2048}], - "user": 'bob', - "userns_mode": 'host', - "uts_mode": 'host', - "version": '1.23', - "volume_driver": 'some_driver', - "volumes": [ + 'image': 'alpine', + 'command': 'echo hello world', + 'blkio_weight_device': [{'Path': 'foo', 'Weight': 3}], + 'blkio_weight': 2, + 'cap_add': ['foo'], + 'cap_drop': ['bar'], + 'cgroup_parent': 'foobar', + 'cgroupns': 'host', + 'cpu_period': 1, + 'cpu_quota': 2, + 'cpu_shares': 5, + 'cpuset_cpus': '0-3', + 'detach': False, + 'device_read_bps': [{'Path': 'foo', 'Rate': 3}], + 'device_read_iops': [{'Path': 'foo', 'Rate': 3}], + 'device_write_bps': [{'Path': 'foo', 'Rate': 3}], + 'device_write_iops': [{'Path': 'foo', 'Rate': 3}], + 'devices': ['/dev/sda:/dev/xvda:rwm'], + 'dns': ['8.8.8.8'], + 'domainname': 'example.com', + 'dns_opt': ['foo'], + 'dns_search': ['example.com'], + 'entrypoint': '/bin/sh', + 'environment': {'FOO': 'BAR'}, + 'extra_hosts': {'foo': '1.2.3.4'}, + 'group_add': ['blah'], + 'ipc_mode': 'foo', + 'kernel_memory': 123, + 'labels': {'key': 'value'}, + 'links': {'foo': 'bar'}, + 'log_config': {'Type': 'json-file', 'Config': {}}, + 'lxc_conf': {'foo': 'bar'}, + 'healthcheck': {'test': 'true'}, + 'hostname': 'somehost', + 'mac_address': 'abc123', + 'mem_limit': 123, + 'mem_reservation': 123, + 'mem_swappiness': 2, + 'memswap_limit': 456, + 'name': 'somename', + 'network_disabled': False, + 'network': 'foo', + 'networking_config': networking_config, + 'oom_kill_disable': True, + 'oom_score_adj': 5, + 'pid_mode': 'host', + 'pids_limit': 500, + 'platform': 'linux', + 'ports': {1111: 4567, 2222: None}, + 'privileged': True, + 'publish_all_ports': True, + 'read_only': True, + 'restart_policy': {'Name': 'always'}, + 'security_opt': ['blah'], + 'shm_size': 123, + 'stdin_open': True, + 'stop_signal': 9, + 'sysctls': {'foo': 'bar'}, + 'tmpfs': {'/blah': ''}, + 'tty': True, + 'ulimits': [{"Name": "nofile", "Soft": 1024, "Hard": 2048}], + 'user': 'bob', + 'userns_mode': 'host', + 'uts_mode': 'host', + 'version': DEFAULT_DOCKER_API_VERSION, + 'volume_driver': 'some_driver', 'volumes': [ '/home/user1/:/mnt/vol2', '/var/www:/mnt/vol1:ro', 'volumename:/mnt/vol3r', @@ -109,18 +115,18 @@ def test_create_container_args(self): '/anothervolumewithnohostpath:ro', 'C:\\windows\\path:D:\\hello\\world:rw' ], - "volumes_from": ['container'], - "working_dir": '/code' + 'volumes_from': ['container'], + 'working_dir': '/code', }) expected = { - "image": 'alpine', - "command": 'echo hello world', - "domainname": 'example.com', - "detach": False, - "entrypoint": '/bin/sh', - "environment": {'FOO': 'BAR'}, - "host_config": { + 'image': 'alpine', + 'command': 'echo hello world', + 'domainname': 'example.com', + 'detach': False, + 'entrypoint': '/bin/sh', + 'environment': {'FOO': 'BAR'}, + 'host_config': { 'Binds': [ '/home/user1/:/mnt/vol2', '/var/www:/mnt/vol1:ro', @@ -143,9 +149,13 @@ def test_create_container_args(self): 'CpuQuota': 2, 'CpuShares': 5, 'CpusetCpus': '0-3', - 'Devices': [{'PathOnHost': '/dev/sda', - 'CgroupPermissions': 'rwm', - 'PathInContainer': '/dev/xvda'}], + 'Devices': [ + { + 'PathOnHost': '/dev/sda', + 'CgroupPermissions': 'rwm', + 'PathInContainer': '/dev/xvda', + }, + ], 'Dns': ['8.8.8.8'], 'DnsOptions': ['foo'], 'DnsSearch': ['example.com'], @@ -177,26 +187,35 @@ def test_create_container_args(self): 'ShmSize': 123, 'Sysctls': {'foo': 'bar'}, 'Tmpfs': {'/blah': ''}, - 'Ulimits': [{"Name": "nofile", "Soft": 1024, "Hard": 2048}], + 'Ulimits': [ + {"Name": "nofile", "Soft": 1024, "Hard": 2048}, + ], 'UsernsMode': 'host', 'UTSMode': 'host', 'VolumeDriver': 'some_driver', 'VolumesFrom': ['container'], }, - "healthcheck": {'test': 'true'}, - "hostname": 'somehost', - "labels": {'key': 'value'}, - "mac_address": 'abc123', - "name": 'somename', - "network_disabled": False, - "networking_config": {'foo': {'driver_opt': {'key1': 'a'}}}, - "platform": 'linux', - "ports": [('1111', 'tcp'), ('2222', 'tcp')], - "stdin_open": True, - "stop_signal": 9, - "tty": True, - "user": 'bob', - "volumes": [ + 'healthcheck': {'test': 'true'}, + 'hostname': 'somehost', + 'labels': {'key': 'value'}, + 'mac_address': 'abc123', + 'name': 'somename', + 'network_disabled': False, + 'networking_config': { + 'EndpointsConfig': { + 'foo': { + 'Aliases': ['test'], + 'DriverOpts': {'key1': 'a'}, + }, + } + }, + 'platform': 'linux', + 'ports': [('1111', 'tcp'), ('2222', 'tcp')], + 'stdin_open': True, + 'stop_signal': 9, + 'tty': True, + 'user': 'bob', + 'volumes': [ '/mnt/vol2', '/mnt/vol1', '/mnt/vol3r', @@ -204,7 +223,7 @@ def test_create_container_args(self): '/anothervolumewithnohostpath', 'D:\\hello\\world' ], - "working_dir": '/code' + 'working_dir': '/code', } assert create_kwargs == expected @@ -346,39 +365,105 @@ def test_run_platform(self): host_config={'NetworkMode': 'default'}, ) - def test_run_network_driver_opts_without_network(self): + def test_run_networking_config_without_network(self): client = make_fake_client() with pytest.raises(RuntimeError): client.containers.run( image='alpine', - network_driver_opt={'key1': 'a'} + networking_config={'aliases': ['test'], + 'driver_opt': {'key1': 'a'}} ) - def test_run_network_driver_opts_with_network_mode(self): + def test_run_networking_config_with_network_mode(self): client = make_fake_client() with pytest.raises(RuntimeError): client.containers.run( image='alpine', network_mode='none', - network_driver_opt={'key1': 'a'} + networking_config={'aliases': ['test'], + 'driver_opt': {'key1': 'a'}} ) - def test_run_network_driver_opts(self): + def test_run_networking_config(self): client = make_fake_client() + networking_config = { + 'foo': EndpointConfig( + DEFAULT_DOCKER_API_VERSION, aliases=['test'], + driver_opt={'key1': 'a'} + ) + } + client.containers.run( image='alpine', network='foo', - network_driver_opt={'key1': 'a'} + networking_config=networking_config ) client.api.create_container.assert_called_with( detach=False, image='alpine', command=None, - networking_config={'foo': {'driver_opt': {'key1': 'a'}}}, + networking_config={'EndpointsConfig': { + 'foo': {'Aliases': ['test'], 'DriverOpts': {'key1': 'a'}}} + }, + host_config={'NetworkMode': 'foo'} + ) + + def test_run_networking_config_with_undeclared_network(self): + client = make_fake_client() + + networking_config = { + 'foo': EndpointConfig( + DEFAULT_DOCKER_API_VERSION, aliases=['test_foo'], + driver_opt={'key2': 'b'} + ), + 'bar': EndpointConfig( + DEFAULT_DOCKER_API_VERSION, aliases=['test'], + driver_opt={'key1': 'a'} + ) + } + + client.containers.run( + image='alpine', + network='foo', + networking_config=networking_config + ) + + client.api.create_container.assert_called_with( + detach=False, + image='alpine', + command=None, + networking_config={'EndpointsConfig': { + 'foo': {'Aliases': ['test_foo'], 'DriverOpts': {'key2': 'b'}}, + 'bar': {'Aliases': ['test'], 'DriverOpts': {'key1': 'a'}}, + }}, + host_config={'NetworkMode': 'foo'} + ) + + def test_run_networking_config_only_undeclared_network(self): + client = make_fake_client() + + networking_config = { + 'bar': EndpointConfig( + DEFAULT_DOCKER_API_VERSION, aliases=['test'], + driver_opt={'key1': 'a'} + ) + } + + client.containers.run( + image='alpine', + network='foo', + networking_config=networking_config + ) + + client.api.create_container.assert_called_with( + detach=False, + image='alpine', + command=None, + networking_config={'foo': None}, host_config={'NetworkMode': 'foo'} ) @@ -409,12 +494,13 @@ def test_create_with_image_object(self): host_config={'NetworkMode': 'default'} ) - def test_create_network_driver_opts_without_network(self): + def test_create_networking_config_without_network(self): client = make_fake_client() client.containers.create( image='alpine', - network_driver_opt={'key1': 'a'} + networking_config={'aliases': ['test'], + 'driver_opt': {'key1': 'a'}} ) client.api.create_container.assert_called_with( @@ -423,13 +509,14 @@ def test_create_network_driver_opts_without_network(self): host_config={'NetworkMode': 'default'} ) - def test_create_network_driver_opts_with_network_mode(self): + def test_create_networking_config_with_network_mode(self): client = make_fake_client() client.containers.create( image='alpine', network_mode='none', - network_driver_opt={'key1': 'a'} + networking_config={'aliases': ['test'], + 'driver_opt': {'key1': 'a'}} ) client.api.create_container.assert_called_with( @@ -438,19 +525,81 @@ def test_create_network_driver_opts_with_network_mode(self): host_config={'NetworkMode': 'none'} ) - def test_create_network_driver_opts(self): + def test_create_networking_config(self): + client = make_fake_client() + + networking_config = { + 'foo': EndpointConfig( + DEFAULT_DOCKER_API_VERSION, aliases=['test'], + driver_opt={'key1': 'a'} + ) + } + + client.containers.create( + image='alpine', + network='foo', + networking_config=networking_config + ) + + client.api.create_container.assert_called_with( + image='alpine', + command=None, + networking_config={'EndpointsConfig': { + 'foo': {'Aliases': ['test'], 'DriverOpts': {'key1': 'a'}}} + }, + host_config={'NetworkMode': 'foo'} + ) + + def test_create_networking_config_with_undeclared_network(self): + client = make_fake_client() + + networking_config = { + 'foo': EndpointConfig( + DEFAULT_DOCKER_API_VERSION, aliases=['test_foo'], + driver_opt={'key2': 'b'} + ), + 'bar': EndpointConfig( + DEFAULT_DOCKER_API_VERSION, aliases=['test'], + driver_opt={'key1': 'a'} + ) + } + + client.containers.create( + image='alpine', + network='foo', + networking_config=networking_config + ) + + client.api.create_container.assert_called_with( + image='alpine', + command=None, + networking_config={'EndpointsConfig': { + 'foo': {'Aliases': ['test_foo'], 'DriverOpts': {'key2': 'b'}}, + 'bar': {'Aliases': ['test'], 'DriverOpts': {'key1': 'a'}}, + }}, + host_config={'NetworkMode': 'foo'} + ) + + def test_create_networking_config_only_undeclared_network(self): client = make_fake_client() + networking_config = { + 'bar': EndpointConfig( + DEFAULT_DOCKER_API_VERSION, aliases=['test'], + driver_opt={'key1': 'a'} + ) + } + client.containers.create( image='alpine', network='foo', - network_driver_opt={'key1': 'a'} + networking_config=networking_config ) client.api.create_container.assert_called_with( image='alpine', command=None, - networking_config={'foo': {'driver_opt': {'key1': 'a'}}}, + networking_config={'foo': None}, host_config={'NetworkMode': 'foo'} ) @@ -479,6 +628,7 @@ def test_list(self): def test_list_ignore_removed(self): def side_effect(*args, **kwargs): raise docker.errors.NotFound('Container not found') + client = make_fake_client({ 'inspect_container.side_effect': side_effect })