diff --git a/MAINTAINERS b/MAINTAINERS index f0bc46b607..14f61963fc 100644 --- a/MAINTAINERS +++ b/MAINTAINERS @@ -1,3 +1,3 @@ -Joffrey F (@shin-) +Joffrey F (@shin-) Maxime Petazzoni (@mpetazzoni) - +Aanand Prasad (@aanand) diff --git a/docker/auth/auth.py b/docker/auth/auth.py index 373df565e2..1c29615546 100644 --- a/docker/auth/auth.py +++ b/docker/auth/auth.py @@ -23,7 +23,8 @@ from .. import errors INDEX_URL = 'https://index.docker.io/v1/' -DOCKER_CONFIG_FILENAME = '.dockercfg' +DOCKER_CONFIG_FILENAME = os.path.join('.docker', 'config.json') +LEGACY_DOCKER_CONFIG_FILENAME = '.dockercfg' def expand_registry_url(hostname, insecure=False): @@ -107,6 +108,29 @@ def encode_full_header(auth): return encode_header({'configs': auth}) +def parse_auth(entries): + """ + Parses authentication entries + + Args: + entries: Dict of authentication entries. + + Returns: + Authentication registry. + """ + + conf = {} + for registry, entry in six.iteritems(entries): + username, password = decode_auth(entry['auth']) + conf[registry] = { + 'username': username, + 'password': password, + 'email': entry['email'], + 'serveraddress': registry, + } + return conf + + def load_config(config_path=None): """ Loads authentication data from a Docker configuration file in the given @@ -115,26 +139,34 @@ def load_config(config_path=None): conf = {} data = None - config_file = config_path or os.path.join(os.environ.get('HOME', '.'), + # Prefer ~/.docker/config.json. + config_file = config_path or os.path.join(os.path.expanduser('~'), DOCKER_CONFIG_FILENAME) + if os.path.exists(config_file): + try: + with open(config_file) as f: + for section, data in six.iteritems(json.load(f)): + if section != 'auths': + continue + return parse_auth(data) + except (IOError, KeyError, ValueError): + # Likely missing new Docker config file or it's in an + # unknown format, continue to attempt to read old location + # and format. + pass + + config_file = config_path or os.path.join(os.path.expanduser('~'), + LEGACY_DOCKER_CONFIG_FILENAME) + # if config path doesn't exist return empty config if not os.path.exists(config_file): return {} - # First try as JSON + # Try reading legacy location as JSON. try: with open(config_file) as f: - conf = {} - for registry, entry in six.iteritems(json.load(f)): - username, password = decode_auth(entry['auth']) - conf[registry] = { - 'username': username, - 'password': password, - 'email': entry['email'], - 'serveraddress': registry, - } - return conf + return parse_auth(json.load(f)) except: pass diff --git a/docker/client.py b/docker/client.py index b66480676d..17b7da101c 100644 --- a/docker/client.py +++ b/docker/client.py @@ -12,247 +12,25 @@ # See the License for the specific language governing permissions and # limitations under the License. -import json import os import re import shlex -import struct import warnings from datetime import datetime -import requests -import requests.exceptions import six +from . import clientbase from . import constants from . import errors from .auth import auth -from .unixconn import unixconn -from .ssladapter import ssladapter from .utils import utils, check_resource -from .tls import TLSConfig -if not six.PY3: - import websocket - - -class Client(requests.Session): - def __init__(self, base_url=None, version=None, - timeout=constants.DEFAULT_TIMEOUT_SECONDS, tls=False): - super(Client, self).__init__() - - if tls and not base_url.startswith('https://'): - raise errors.TLSParameterError( - 'If using TLS, the base_url argument must begin with ' - '"https://".') - - self.base_url = base_url - self.timeout = timeout - - self._auth_configs = auth.load_config() - - base_url = utils.parse_host(base_url) - if base_url.startswith('http+unix://'): - unix_socket_adapter = unixconn.UnixAdapter(base_url, timeout) - self.mount('http+docker://', unix_socket_adapter) - self.base_url = 'http+docker://localunixsocket' - else: - # Use SSLAdapter for the ability to specify SSL version - if isinstance(tls, TLSConfig): - tls.configure_client(self) - elif tls: - self.mount('https://', ssladapter.SSLAdapter()) - self.base_url = base_url - - # version detection needs to be after unix adapter mounting - if version is None: - self._version = constants.DEFAULT_DOCKER_API_VERSION - elif isinstance(version, six.string_types): - if version.lower() == 'auto': - self._version = self._retrieve_server_version() - else: - self._version = version - else: - raise errors.DockerException( - 'Version parameter must be a string or None. Found {0}'.format( - type(version).__name__ - ) - ) - - def _retrieve_server_version(self): - try: - return self.version(api_version=False)["ApiVersion"] - except KeyError: - raise errors.DockerException( - 'Invalid response from docker daemon: key "ApiVersion"' - ' is missing.' - ) - except Exception as e: - raise errors.DockerException( - 'Error while fetching server API version: {0}'.format(e) - ) - - def _set_request_timeout(self, kwargs): - """Prepare the kwargs for an HTTP request by inserting the timeout - parameter, if not already present.""" - kwargs.setdefault('timeout', self.timeout) - return kwargs - - def _post(self, url, **kwargs): - return self.post(url, **self._set_request_timeout(kwargs)) - - def _get(self, url, **kwargs): - return self.get(url, **self._set_request_timeout(kwargs)) - - def _delete(self, url, **kwargs): - return self.delete(url, **self._set_request_timeout(kwargs)) - - def _url(self, path, versioned_api=True): - if versioned_api: - return '{0}/v{1}{2}'.format(self.base_url, self._version, path) - else: - return '{0}{1}'.format(self.base_url, path) - - def _raise_for_status(self, response, explanation=None): - """Raises stored :class:`APIError`, if one occurred.""" - try: - response.raise_for_status() - except requests.exceptions.HTTPError as e: - raise errors.APIError(e, response, explanation=explanation) - - def _result(self, response, json=False, binary=False): - assert not (json and binary) - self._raise_for_status(response) - - if json: - return response.json() - if binary: - return response.content - return response.text - - def _post_json(self, url, data, **kwargs): - # Go <1.1 can't unserialize null to a string - # so we do this disgusting thing here. - data2 = {} - if data is not None: - for k, v in six.iteritems(data): - if v is not None: - data2[k] = v - - if 'headers' not in kwargs: - kwargs['headers'] = {} - kwargs['headers']['Content-Type'] = 'application/json' - return self._post(url, data=json.dumps(data2), **kwargs) - - def _attach_params(self, override=None): - return override or { - 'stdout': 1, - 'stderr': 1, - 'stream': 1 - } - - @check_resource - def _attach_websocket(self, container, params=None): - if six.PY3: - raise NotImplementedError("This method is not currently supported " - "under python 3") - url = self._url("/containers/{0}/attach/ws".format(container)) - req = requests.Request("POST", url, params=self._attach_params(params)) - full_url = req.prepare().url - full_url = full_url.replace("http://", "ws://", 1) - full_url = full_url.replace("https://", "wss://", 1) - return self._create_websocket_connection(full_url) - - def _create_websocket_connection(self, url): - return websocket.create_connection(url) - - def _get_raw_response_socket(self, response): - self._raise_for_status(response) - if six.PY3: - sock = response.raw._fp.fp.raw - else: - sock = response.raw._fp.fp._sock - try: - # Keep a reference to the response to stop it being garbage - # collected. If the response is garbage collected, it will - # close TLS sockets. - sock._response = response - except AttributeError: - # UNIX sockets can't have attributes set on them, but that's - # fine because we won't be doing TLS over them - pass - - return sock - - def _stream_helper(self, response, decode=False): - """Generator for data coming from a chunked-encoded HTTP response.""" - if response.raw._fp.chunked: - reader = response.raw - while not reader.closed: - # this read call will block until we get a chunk - data = reader.read(1) - if not data: - break - if reader._fp.chunk_left: - data += reader.read(reader._fp.chunk_left) - if decode: - if six.PY3: - data = data.decode('utf-8') - data = json.loads(data) - yield data - else: - # Response isn't chunked, meaning we probably - # encountered an error immediately - yield self._result(response) - - def _multiplexed_buffer_helper(self, response): - """A generator of multiplexed data blocks read from a buffered - response.""" - buf = self._result(response, binary=True) - walker = 0 - while True: - if len(buf[walker:]) < 8: - break - _, length = struct.unpack_from('>BxxxL', buf[walker:]) - start = walker + constants.STREAM_HEADER_SIZE_BYTES - end = start + length - walker = end - yield buf[start:end] - - def _multiplexed_response_stream_helper(self, response): - """A generator of multiplexed data blocks coming from a response - stream.""" - - # Disable timeout on the underlying socket to prevent - # Read timed out(s) for long running processes - socket = self._get_raw_response_socket(response) - if six.PY3: - socket._sock.settimeout(None) - else: - socket.settimeout(None) - - while True: - header = response.raw.read(constants.STREAM_HEADER_SIZE_BYTES) - if not header: - break - _, length = struct.unpack('>BxxxL', header) - if not length: - break - data = response.raw.read(length) - if not data: - break - yield data - - @property - def api_version(self): - return self._version - +class Client(clientbase.ClientBase): @check_resource def attach(self, container, stdout=True, stderr=True, stream=False, logs=False): - if isinstance(container, dict): - container = container.get('Id') params = { 'logs': logs and 1 or 0, 'stdout': stdout and 1 or 0, @@ -262,28 +40,7 @@ def attach(self, container, stdout=True, stderr=True, u = self._url("/containers/{0}/attach".format(container)) response = self._post(u, params=params, stream=stream) - # Stream multi-plexing was only introduced in API v1.6. Anything before - # that needs old-style streaming. - if utils.compare_version('1.6', self._version) < 0: - def stream_result(): - self._raise_for_status(response) - for line in response.iter_lines(chunk_size=1, - decode_unicode=True): - # filter out keep-alive new lines - if line: - yield line - - return stream_result() if stream else \ - self._result(response, binary=True) - - sep = bytes() if six.PY3 else str() - - if stream: - return self._multiplexed_response_stream_helper(response) - else: - return sep.join( - [x for x in self._multiplexed_buffer_helper(response)] - ) + return self._get_result(container, stream, response) @check_resource def attach_socket(self, container, params=None, ws=False): @@ -297,17 +54,15 @@ def attach_socket(self, container, params=None, ws=False): if ws: return self._attach_websocket(container, params) - if isinstance(container, dict): - container = container.get('Id') - u = self._url("/containers/{0}/attach".format(container)) return self._get_raw_response_socket(self.post( u, None, params=self._attach_params(params), stream=True)) def build(self, path=None, tag=None, quiet=False, fileobj=None, nocache=False, rm=False, stream=False, timeout=None, - custom_context=False, encoding=None, pull=True, - forcerm=False, dockerfile=None, container_limits=None): + custom_context=False, encoding=None, pull=False, + forcerm=False, dockerfile=None, container_limits=None, + decode=False): remote = context = headers = None container_limits = container_limits or {} if path is None and fileobj is None: @@ -326,7 +81,7 @@ def build(self, path=None, tag=None, quiet=False, fileobj=None, elif fileobj is not None: context = utils.mkbuildcontext(fileobj) elif path.startswith(('http://', 'https://', - 'git://', 'github.com/')): + 'git://', 'github.com/', 'git@')): remote = path elif not os.path.isdir(path): raise TypeError("You must specify a directory to build in path") @@ -352,6 +107,9 @@ def build(self, path=None, tag=None, quiet=False, fileobj=None, 'dockerfile was only introduced in API version 1.17' ) + if utils.compare_version('1.19', self._version) < 0: + pull = 1 if pull else 0 + u = self._url('/build') params = { 't': tag, @@ -398,7 +156,7 @@ def build(self, path=None, tag=None, quiet=False, fileobj=None, context.close() if stream: - return self._stream_helper(response) + return self._stream_helper(response, decode=decode) else: output = self._result(response) srch = r'Successfully built ([0-9a-f]+)' @@ -410,8 +168,6 @@ def build(self, path=None, tag=None, quiet=False, fileobj=None, @check_resource def commit(self, container, repository=None, tag=None, message=None, author=None, conf=None): - if isinstance(container, dict): - container = container.get('Id') params = { 'container': container, 'repo': repository, @@ -448,8 +204,6 @@ def containers(self, quiet=False, all=False, trunc=False, latest=False, @check_resource def copy(self, container, resource): - if isinstance(container, dict): - container = container.get('Id') res = self._post_json( self._url("/containers/{0}/copy".format(container)), data={"Resource": resource}, @@ -460,12 +214,12 @@ def copy(self, container, resource): def create_container(self, image, command=None, hostname=None, user=None, detach=False, stdin_open=False, tty=False, - mem_limit=0, ports=None, environment=None, dns=None, - volumes=None, volumes_from=None, + mem_limit=None, ports=None, environment=None, + dns=None, volumes=None, volumes_from=None, network_disabled=False, name=None, entrypoint=None, cpu_shares=None, working_dir=None, domainname=None, - memswap_limit=0, cpuset=None, host_config=None, - mac_address=None, labels=None): + memswap_limit=None, cpuset=None, host_config=None, + mac_address=None, labels=None, volume_driver=None): if isinstance(volumes, six.string_types): volumes = [volumes, ] @@ -479,7 +233,8 @@ def create_container(self, image, command=None, hostname=None, user=None, self._version, image, command, hostname, user, detach, stdin_open, tty, mem_limit, ports, environment, dns, volumes, volumes_from, network_disabled, entrypoint, cpu_shares, working_dir, domainname, - memswap_limit, cpuset, host_config, mac_address, labels + memswap_limit, cpuset, host_config, mac_address, labels, + volume_driver ) return self.create_container_from_config(config, name) @@ -493,8 +248,6 @@ def create_container_from_config(self, config, name=None): @check_resource def diff(self, container): - if isinstance(container, dict): - container = container.get('Id') return self._result(self._get(self._url("/containers/{0}/changes". format(container))), True) @@ -539,8 +292,6 @@ def exec_create(self, container, cmd, stdout=True, stderr=True, tty=False, raise errors.InvalidVersion( 'Privileged exec is not supported in API < 1.19' ) - if isinstance(container, dict): - container = container.get('Id') if isinstance(cmd, six.string_types): cmd = shlex.split(str(cmd)) @@ -591,22 +342,10 @@ def exec_start(self, exec_id, detach=False, tty=False, stream=False): res = self._post_json(self._url('/exec/{0}/start'.format(exec_id)), data=data, stream=stream) - self._raise_for_status(res) - if stream: - return self._multiplexed_response_stream_helper(res) - elif six.PY3: - return bytes().join( - [x for x in self._multiplexed_buffer_helper(res)] - ) - else: - return str().join( - [x for x in self._multiplexed_buffer_helper(res)] - ) + return self._get_result_tty(stream, res, tty) @check_resource def export(self, container): - if isinstance(container, dict): - container = container.get('Id') res = self._get(self._url("/containers/{0}/export".format(container)), stream=True) self._raise_for_status(res) @@ -744,25 +483,21 @@ def insert(self, image, url, path): @check_resource def inspect_container(self, container): - if isinstance(container, dict): - container = container.get('Id') return self._result( self._get(self._url("/containers/{0}/json".format(container))), True) @check_resource def inspect_image(self, image): - if isinstance(image, dict): - image = image.get('Id') return self._result( - self._get(self._url("/images/{0}/json".format(image))), + self._get( + self._url("/images/{0}/json".format(image.replace('/', '%2F'))) + ), True ) @check_resource def kill(self, container, signal=None): - if isinstance(container, dict): - container = container.get('Id') url = self._url("/containers/{0}/kill".format(container)) params = {} if signal is not None: @@ -810,8 +545,6 @@ def login(self, username, password=None, email=None, registry=None, @check_resource def logs(self, container, stdout=True, stderr=True, stream=False, timestamps=False, tail='all'): - if isinstance(container, dict): - container = container.get('Id') if utils.compare_version('1.11', self._version) >= 0: params = {'stderr': stderr and 1 or 0, 'stdout': stdout and 1 or 0, @@ -824,16 +557,7 @@ def logs(self, container, stdout=True, stderr=True, stream=False, params['tail'] = tail url = self._url("/containers/{0}/logs".format(container)) res = self._get(url, params=params, stream=stream) - if stream: - return self._multiplexed_response_stream_helper(res) - elif six.PY3: - return bytes().join( - [x for x in self._multiplexed_buffer_helper(res)] - ) - else: - return str().join( - [x for x in self._multiplexed_buffer_helper(res)] - ) + return self._get_result(container, stream, res) return self.attach( container, stdout=stdout, @@ -844,8 +568,6 @@ def logs(self, container, stdout=True, stderr=True, stream=False, @check_resource def pause(self, container): - if isinstance(container, dict): - container = container.get('Id') url = self._url('/containers/{0}/pause'.format(container)) res = self._post(url) self._raise_for_status(res) @@ -855,8 +577,6 @@ def ping(self): @check_resource def port(self, container, private_port): - if isinstance(container, dict): - container = container.get('Id') res = self._get(self._url("/containers/{0}/json".format(container))) self._raise_for_status(res) json_ = res.json() @@ -910,13 +630,17 @@ def pull(self, repository, tag=None, stream=False, else: headers['X-Registry-Auth'] = auth.encode_header(auth_config) - response = self._post(self._url('/images/create'), params=params, - headers=headers, stream=stream, timeout=None) + response = self._post( + self._url('/images/create'), params=params, headers=headers, + stream=stream, timeout=None + ) + + self._raise_for_status(response) if stream: return self._stream_helper(response) - else: - return self._result(response) + + return self._result(response) def push(self, repository, tag=None, stream=False, insecure_registry=False): @@ -944,18 +668,19 @@ def push(self, repository, tag=None, stream=False, if authcfg: headers['X-Registry-Auth'] = auth.encode_header(authcfg) - response = self._post_json(u, None, headers=headers, - stream=stream, params=params) - else: - response = self._post_json(u, None, stream=stream, params=params) + response = self._post_json( + u, None, headers=headers, stream=stream, params=params + ) + + self._raise_for_status(response) + + if stream: + return self._stream_helper(response) - return stream and self._stream_helper(response) \ - or self._result(response) + return self._result(response) @check_resource def remove_container(self, container, v=False, link=False, force=False): - if isinstance(container, dict): - container = container.get('Id') params = {'v': v, 'link': link, 'force': force} res = self._delete(self._url("/containers/" + container), params=params) @@ -963,8 +688,6 @@ def remove_container(self, container, v=False, link=False, force=False): @check_resource def remove_image(self, image, force=False, noprune=False): - if isinstance(image, dict): - image = image.get('Id') params = {'force': force, 'noprune': noprune} res = self._delete(self._url("/images/" + image), params=params) self._raise_for_status(res) @@ -975,8 +698,6 @@ def rename(self, container, name): raise errors.InvalidVersion( 'rename was only introduced in API version 1.17' ) - if isinstance(container, dict): - container = container.get('Id') url = self._url("/containers/{0}/rename".format(container)) params = {'name': name} res = self._post(url, params=params) @@ -984,9 +705,6 @@ def rename(self, container, name): @check_resource def resize(self, container, height, width): - if isinstance(container, dict): - container = container.get('Id') - params = {'h': height, 'w': width} url = self._url("/containers/{0}/resize".format(container)) res = self._post(url, params=params) @@ -994,8 +712,6 @@ def resize(self, container, height, width): @check_resource def restart(self, container, timeout=10): - if isinstance(container, dict): - container = container.get('Id') params = {'t': timeout} url = self._url("/containers/{0}/restart".format(container)) res = self._post(url, params=params) @@ -1060,9 +776,6 @@ def start(self, container, binds=None, port_bindings=None, lxc_conf=None, ipc_mode=ipc_mode, security_opt=security_opt, ulimits=ulimits ) - if isinstance(container, dict): - container = container.get('Id') - url = self._url("/containers/{0}/start".format(container)) if not start_config: start_config = None @@ -1081,20 +794,16 @@ def stats(self, container, decode=None): raise errors.InvalidVersion( 'Stats retrieval is not supported in API < 1.17!') - if isinstance(container, dict): - container = container.get('Id') url = self._url("/containers/{0}/stats".format(container)) return self._stream_helper(self._get(url, stream=True), decode=decode) @check_resource def stop(self, container, timeout=10): - if isinstance(container, dict): - container = container.get('Id') params = {'t': timeout} url = self._url("/containers/{0}/stop".format(container)) res = self._post(url, params=params, - timeout=(timeout + self.timeout)) + timeout=(timeout + (self.timeout or 0))) self._raise_for_status(res) @check_resource @@ -1111,8 +820,6 @@ def tag(self, image, repository, tag=None, force=False): @check_resource def top(self, container): - if isinstance(container, dict): - container = container.get('Id') u = self._url("/containers/{0}/top".format(container)) return self._result(self._get(u), True) @@ -1122,16 +829,12 @@ def version(self, api_version=True): @check_resource def unpause(self, container): - if isinstance(container, dict): - container = container.get('Id') url = self._url('/containers/{0}/unpause'.format(container)) res = self._post(url) self._raise_for_status(res) @check_resource def wait(self, container, timeout=None): - if isinstance(container, dict): - container = container.get('Id') url = self._url("/containers/{0}/wait".format(container)) res = self._post(url, timeout=timeout) self._raise_for_status(res) diff --git a/docker/clientbase.py b/docker/clientbase.py new file mode 100644 index 0000000000..c1ae8137a2 --- /dev/null +++ b/docker/clientbase.py @@ -0,0 +1,275 @@ +import json +import struct + +import requests +import requests.exceptions +import six +import websocket + + +from . import constants +from . import errors +from .auth import auth +from .unixconn import unixconn +from .ssladapter import ssladapter +from .utils import utils, check_resource +from .tls import TLSConfig + + +class ClientBase(requests.Session): + def __init__(self, base_url=None, version=None, + timeout=constants.DEFAULT_TIMEOUT_SECONDS, tls=False): + super(ClientBase, self).__init__() + + if tls and not base_url.startswith('https://'): + raise errors.TLSParameterError( + 'If using TLS, the base_url argument must begin with ' + '"https://".') + + self.base_url = base_url + self.timeout = timeout + + self._auth_configs = auth.load_config() + + base_url = utils.parse_host(base_url) + if base_url.startswith('http+unix://'): + self._custom_adapter = unixconn.UnixAdapter(base_url, timeout) + self.mount('http+docker://', self._custom_adapter) + self.base_url = 'http+docker://localunixsocket' + else: + # Use SSLAdapter for the ability to specify SSL version + if isinstance(tls, TLSConfig): + tls.configure_client(self) + elif tls: + self._custom_adapter = ssladapter.SSLAdapter() + self.mount('https://', self._custom_adapter) + self.base_url = base_url + + # version detection needs to be after unix adapter mounting + if version is None: + self._version = constants.DEFAULT_DOCKER_API_VERSION + elif isinstance(version, six.string_types): + if version.lower() == 'auto': + self._version = self._retrieve_server_version() + else: + self._version = version + else: + raise errors.DockerException( + 'Version parameter must be a string or None. Found {0}'.format( + type(version).__name__ + ) + ) + + def _retrieve_server_version(self): + try: + return self.version(api_version=False)["ApiVersion"] + except KeyError: + raise errors.DockerException( + 'Invalid response from docker daemon: key "ApiVersion"' + ' is missing.' + ) + except Exception as e: + raise errors.DockerException( + 'Error while fetching server API version: {0}'.format(e) + ) + + def _set_request_timeout(self, kwargs): + """Prepare the kwargs for an HTTP request by inserting the timeout + parameter, if not already present.""" + kwargs.setdefault('timeout', self.timeout) + return kwargs + + def _post(self, url, **kwargs): + return self.post(url, **self._set_request_timeout(kwargs)) + + def _get(self, url, **kwargs): + return self.get(url, **self._set_request_timeout(kwargs)) + + def _delete(self, url, **kwargs): + return self.delete(url, **self._set_request_timeout(kwargs)) + + def _url(self, path, versioned_api=True): + if versioned_api: + return '{0}/v{1}{2}'.format(self.base_url, self._version, path) + else: + return '{0}{1}'.format(self.base_url, path) + + def _raise_for_status(self, response, explanation=None): + """Raises stored :class:`APIError`, if one occurred.""" + try: + response.raise_for_status() + except requests.exceptions.HTTPError as e: + raise errors.APIError(e, response, explanation=explanation) + + def _result(self, response, json=False, binary=False): + assert not (json and binary) + self._raise_for_status(response) + + if json: + return response.json() + if binary: + return response.content + return response.text + + def _post_json(self, url, data, **kwargs): + # Go <1.1 can't unserialize null to a string + # so we do this disgusting thing here. + data2 = {} + if data is not None: + for k, v in six.iteritems(data): + if v is not None: + data2[k] = v + + if 'headers' not in kwargs: + kwargs['headers'] = {} + kwargs['headers']['Content-Type'] = 'application/json' + return self._post(url, data=json.dumps(data2), **kwargs) + + def _attach_params(self, override=None): + return override or { + 'stdout': 1, + 'stderr': 1, + 'stream': 1 + } + + @check_resource + def _attach_websocket(self, container, params=None): + url = self._url("/containers/{0}/attach/ws".format(container)) + req = requests.Request("POST", url, params=self._attach_params(params)) + full_url = req.prepare().url + full_url = full_url.replace("http://", "ws://", 1) + full_url = full_url.replace("https://", "wss://", 1) + return self._create_websocket_connection(full_url) + + def _create_websocket_connection(self, url): + return websocket.create_connection(url) + + def _get_raw_response_socket(self, response): + self._raise_for_status(response) + if six.PY3: + sock = response.raw._fp.fp.raw + else: + sock = response.raw._fp.fp._sock + try: + # Keep a reference to the response to stop it being garbage + # collected. If the response is garbage collected, it will + # close TLS sockets. + sock._response = response + except AttributeError: + # UNIX sockets can't have attributes set on them, but that's + # fine because we won't be doing TLS over them + pass + + return sock + + def _stream_helper(self, response, decode=False): + """Generator for data coming from a chunked-encoded HTTP response.""" + if response.raw._fp.chunked: + reader = response.raw + while not reader.closed: + # this read call will block until we get a chunk + data = reader.read(1) + if not data: + break + if reader._fp.chunk_left: + data += reader.read(reader._fp.chunk_left) + if decode: + if six.PY3: + data = data.decode('utf-8') + data = json.loads(data) + yield data + else: + # Response isn't chunked, meaning we probably + # encountered an error immediately + yield self._result(response) + + def _multiplexed_buffer_helper(self, response): + """A generator of multiplexed data blocks read from a buffered + response.""" + buf = self._result(response, binary=True) + walker = 0 + while True: + if len(buf[walker:]) < 8: + break + _, length = struct.unpack_from('>BxxxL', buf[walker:]) + start = walker + constants.STREAM_HEADER_SIZE_BYTES + end = start + length + walker = end + yield buf[start:end] + + def _multiplexed_response_stream_helper(self, response): + """A generator of multiplexed data blocks coming from a response + stream.""" + + # Disable timeout on the underlying socket to prevent + # Read timed out(s) for long running processes + socket = self._get_raw_response_socket(response) + if six.PY3: + socket._sock.settimeout(None) + else: + socket.settimeout(None) + + while True: + header = response.raw.read(constants.STREAM_HEADER_SIZE_BYTES) + if not header: + break + _, length = struct.unpack('>BxxxL', header) + if not length: + break + data = response.raw.read(length) + if not data: + break + yield data + + def _stream_raw_result_old(self, response): + ''' Stream raw output for API versions below 1.6 ''' + self._raise_for_status(response) + for line in response.iter_lines(chunk_size=1, + decode_unicode=True): + # filter out keep-alive new lines + if line: + yield line + + def _stream_raw_result(self, response): + ''' Stream result for TTY-enabled container above API 1.6 ''' + self._raise_for_status(response) + for out in response.iter_content(chunk_size=1, decode_unicode=True): + yield out + + def _get_result(self, container, stream, res): + cont = self.inspect_container(container) + return self._get_result_tty(stream, res, cont['Config']['Tty']) + + def _get_result_tty(self, stream, res, is_tty): + # Stream multi-plexing was only introduced in API v1.6. Anything + # before that needs old-style streaming. + if utils.compare_version('1.6', self._version) < 0: + return self._stream_raw_result_old(res) + + # We should also use raw streaming (without keep-alives) + # if we're dealing with a tty-enabled container. + if is_tty: + return self._stream_raw_result(res) if stream else \ + self._result(res, binary=True) + + self._raise_for_status(res) + sep = six.binary_type() + if stream: + return self._multiplexed_response_stream_helper(res) + else: + return sep.join( + [x for x in self._multiplexed_buffer_helper(res)] + ) + + def get_adapter(self, url): + try: + return super(ClientBase, self).get_adapter(url) + except requests.exceptions.InvalidSchema as e: + if self._custom_adapter: + return self._custom_adapter + else: + raise e + + @property + def api_version(self): + return self._version diff --git a/docker/constants.py b/docker/constants.py index 233d9b1717..f99f19226e 100644 --- a/docker/constants.py +++ b/docker/constants.py @@ -1,4 +1,4 @@ -DEFAULT_DOCKER_API_VERSION = '1.18' +DEFAULT_DOCKER_API_VERSION = '1.19' DEFAULT_TIMEOUT_SECONDS = 60 STREAM_HEADER_SIZE_BYTES = 8 CONTAINER_LIMITS_KEYS = [ diff --git a/docker/utils/decorators.py b/docker/utils/decorators.py index 4771da21ee..3c42fe4b9f 100644 --- a/docker/utils/decorators.py +++ b/docker/utils/decorators.py @@ -1,13 +1,18 @@ +import functools + from .. import errors def check_resource(f): + @functools.wraps(f) def wrapped(self, resource_id=None, *args, **kwargs): if resource_id is None: if kwargs.get('container'): resource_id = kwargs.pop('container') elif kwargs.get('image'): resource_id = kwargs.pop('image') + if isinstance(resource_id, dict): + resource_id = resource_id.get('Id') if not resource_id: raise errors.NullResource( 'image or container param is undefined' diff --git a/docker/utils/utils.py b/docker/utils/utils.py index e4a3c9e648..175a7e0ff6 100644 --- a/docker/utils/utils.py +++ b/docker/utils/utils.py @@ -120,16 +120,21 @@ def compare_version(v1, v2): def ping_registry(url): - return ping(url + '/v2/') or ping(url + '/v1/_ping') + return ping(url + '/v2/', [401]) or ping(url + '/v1/_ping') -def ping(url): +def ping(url, valid_4xx_statuses=None): try: res = requests.get(url, timeout=3) except Exception: return False else: - return res.status_code < 400 + # We don't send yet auth headers + # and a v2 registry will respond with status 401 + return ( + res.status_code < 400 or + (valid_4xx_statuses and res.status_code in valid_4xx_statuses) + ) def _convert_port_binding(binding): @@ -174,11 +179,27 @@ def convert_port_bindings(port_bindings): def convert_volume_binds(binds): + if isinstance(binds, list): + return binds + result = [] for k, v in binds.items(): if isinstance(v, dict): + if 'ro' in v and 'mode' in v: + raise ValueError( + 'Binding cannot contain both "ro" and "mode": {}' + .format(repr(v)) + ) + + if 'ro' in v: + mode = 'ro' if v['ro'] else 'rw' + elif 'mode' in v: + mode = v['mode'] + else: + mode = 'rw' + result.append('{0}:{1}:{2}'.format( - k, v['bind'], 'ro' if v.get('ro', False) else 'rw' + k, v['bind'], mode )) else: result.append('{0}:{1}:rw'.format(k, v)) @@ -362,10 +383,21 @@ def create_host_config( dns=None, dns_search=None, volumes_from=None, network_mode=None, restart_policy=None, cap_add=None, cap_drop=None, devices=None, extra_hosts=None, read_only=None, pid_mode=None, ipc_mode=None, - security_opt=None, ulimits=None, log_config=None + security_opt=None, ulimits=None, log_config=None, mem_limit=None, + memswap_limit=None ): host_config = {} + if mem_limit is not None: + if isinstance(mem_limit, six.string_types): + mem_limit = parse_bytes(mem_limit) + host_config['Memory'] = mem_limit + + if memswap_limit is not None: + if isinstance(memswap_limit, six.string_types): + memswap_limit = parse_bytes(memswap_limit) + host_config['MemorySwap'] = memswap_limit + if pid_mode not in (None, 'host'): raise errors.DockerException( 'Invalid value for pid param: {0}'.format(pid_mode) @@ -434,7 +466,7 @@ def create_host_config( for k, v in sorted(six.iteritems(extra_hosts)) ] - host_config['ExtraHosts'] = extra_hosts + host_config['ExtraHosts'] = extra_hosts if links is not None: if isinstance(links, dict): @@ -482,11 +514,11 @@ def create_host_config( def create_container_config( version, image, command, hostname=None, user=None, detach=False, - stdin_open=False, tty=False, mem_limit=0, ports=None, environment=None, + stdin_open=False, tty=False, mem_limit=None, ports=None, environment=None, dns=None, volumes=None, volumes_from=None, network_disabled=False, entrypoint=None, cpu_shares=None, working_dir=None, domainname=None, - memswap_limit=0, cpuset=None, host_config=None, mac_address=None, - labels=None + memswap_limit=None, cpuset=None, host_config=None, mac_address=None, + labels=None, volume_driver=None ): if isinstance(command, six.string_types): command = shlex.split(str(command)) @@ -497,10 +529,29 @@ def create_container_config( ] if labels is not None and compare_version('1.18', version) < 0: - raise errors.DockerException( + raise errors.InvalidVersion( 'labels were only introduced in API version 1.18' ) + if compare_version('1.19', version) < 0: + if volume_driver is not None: + raise errors.InvalidVersion( + 'Volume drivers were only introduced in API version 1.19' + ) + mem_limit = mem_limit if mem_limit is not None else 0 + memswap_limit = memswap_limit if memswap_limit is not None else 0 + else: + if mem_limit is not None: + raise errors.InvalidVersion( + 'mem_limit has been moved to host_config in API version 1.19' + ) + + if memswap_limit is not None: + raise errors.InvalidVersion( + 'memswap_limit has been moved to host_config in API ' + 'version 1.19' + ) + if isinstance(labels, list): labels = dict((lbl, six.text_type('')) for lbl in labels) @@ -554,9 +605,9 @@ def create_container_config( message = ('{0!r} parameter has no effect on create_container().' ' It has been moved to start()') if dns is not None: - raise errors.DockerException(message.format('dns')) + raise errors.InvalidVersion(message.format('dns')) if volumes_from is not None: - raise errors.DockerException(message.format('volumes_from')) + raise errors.InvalidVersion(message.format('volumes_from')) return { 'Hostname': hostname, @@ -586,4 +637,5 @@ def create_container_config( 'HostConfig': host_config, 'MacAddress': mac_address, 'Labels': labels, + 'VolumeDriver': volume_driver, } diff --git a/docker/version.py b/docker/version.py index 7ff5b2a3f1..88859a6c00 100644 --- a/docker/version.py +++ b/docker/version.py @@ -1,2 +1,2 @@ -version = "1.2.3-dev" -version_info = tuple([int(d) for d in version.replace("-dev", "").split(".")]) +version = "1.3.0-dev" +version_info = tuple([int(d) for d in version.split("-")[0].split(".")]) diff --git a/docs/api.md b/docs/api.md index 26dfe60813..5a3b3222a6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -71,8 +71,10 @@ correct value (e.g `gzip`). - memswap (int): Total memory (memory + swap), -1 to disable swap - cpushares (int): CPU shares (relative weight) - cpusetcpus (str): CPUs in which to allow exection, e.g., `"0-3"`, `"0,1"` +* decode (bool): If set to `True`, the returned stream will be decoded into + dicts on the fly. Default `False`. -**Returns** (generator): A generator of the build output +**Returns** (generator): A generator for the build output ```python >>> from io import BytesIO @@ -182,7 +184,7 @@ information on how to create port bindings and volume mappings. The `mem_limit` variable accepts float values (which represent the memory limit of the created container in bytes) or a string with a units identification char -('100000b', 1000k', 128m', '1g'). If a string is specified without a units +('100000b', '1000k', '128m', '1g'). If a string is specified without a units character, bytes are assumed as an intended unit. `volumes_from` and `dns` arguments raise [TypeError]( @@ -219,6 +221,7 @@ from. Optionally a single string joining container id's with commas * host_config (dict): A [HostConfig](hostconfig.md) dictionary * mac_address (str): The Mac Address to assign the container * labels (dict or list): A dictionary of name-value labels (e.g. `{"label1": "value1", "label2": "value2"}`) or a list of names of labels to set with empty values (e.g. `["label1", "label2"]`) +* volume_driver (str): The name of a volume driver/plugin. **Returns** (dict): A dictionary with an image 'Id' key and a 'Warnings' key. diff --git a/docs/boot2docker.md b/docs/boot2docker.md index cb83b45335..43aa558d21 100644 --- a/docs/boot2docker.md +++ b/docs/boot2docker.md @@ -4,13 +4,10 @@ For usage with boot2docker, there is a helper function in the utils package name First run boot2docker in your shell: ```bash -$ $(boot2docker shellinit) +$ eval "$(boot2docker shellinit)" Writing /Users/you/.boot2docker/certs/boot2docker-vm/ca.pem Writing /Users/you/.boot2docker/certs/boot2docker-vm/cert.pem Writing /Users/you/.boot2docker/certs/boot2docker-vm/key.pem -export DOCKER_HOST=tcp://192.168.59.103:2376 -export DOCKER_CERT_PATH=/Users/you/.boot2docker/certs/boot2docker-vm -export DOCKER_TLS_VERIFY=1 ``` You can then instantiate `docker.Client` like this: diff --git a/docs/change_log.md b/docs/change_log.md index 5bbbc9385b..aac4acb1c5 100644 --- a/docs/change_log.md +++ b/docs/change_log.md @@ -1,6 +1,40 @@ Change Log ========== +1.2.3 +----- + +[List of PRs / issues for this release](https://github.com/docker/docker-py/issues?q=milestone%3A1.2.3+is%3Aclosed) + +### Deprecation warning + +* Passing host config in the `Client.start` method is now deprecated. Please use the + `host_config` in `Client.create_container` instead. + +### Features + +* Added support for `privileged` param in `Client.exec_create` + (only available in API >= 1.19) +* Volume binds can now also be specified as a list of strings. + +### Bugfixes + +* Fixed a bug where the `read_only` param in host_config wasn't handled + properly. +* Fixed a bug in `Client.execute` (this method is still deprecated). +* The `cpuset` param in `Client.create_container` is also passed as + the `CpusetCpus` param (`Cpuset` deprecated in recent versions of the API) +* Fixed an issue with integration tests being run inside a container + (`make integration-test`) +* Fixed a bug where an empty string would be considered a valid container ID + or image ID. +* Fixed a bug in `Client.insert` + + +### Documentation + +* Various fixes + 1.2.2 ----- diff --git a/docs/volumes.md b/docs/volumes.md index de28214005..16c3228e52 100644 --- a/docs/volumes.md +++ b/docs/volumes.md @@ -10,12 +10,25 @@ container_id = c.create_container( host_config=docker.utils.create_host_config(binds={ '/home/user1/': { 'bind': '/mnt/vol2', - 'ro': False + 'mode': 'rw', }, '/var/www': { 'bind': '/mnt/vol1', - 'ro': True + 'mode': 'ro', } }) ) ``` + +You can alternatively specify binds as a list. This code is equivalent to the +example above: + +```python +container_id = c.create_container( + 'busybox', 'ls', volumes=['/mnt/vol1', '/mnt/vol2'], + host_config=docker.utils.create_host_config(binds=[ + '/home/user1/:/mnt/vol2', + '/var/www:/mnt/vol1:ro', + ]) +) +``` diff --git a/mkdocs.yml b/mkdocs.yml index 6f70d1c0b8..8293cbc4b5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,17 +1,17 @@ site_name: docker-py Documentation site_description: An API client for Docker written in Python site_favicon: favicon_whale.png -site_url: docker-py.readthedocs.org +site_url: http://docker-py.readthedocs.org repo_url: https://github.com/docker/docker-py/ theme: readthedocs pages: -- [index.md, Home] -- [api.md, Client API] -- [port-bindings.md, Port Bindings] -- [volumes.md, Using Volumes] -- [tls.md, Using TLS] -- [host-devices.md, Host devices] -- [hostconfig.md, Host configuration] -- [boot2docker.md, Using with boot2docker] -- [change_log.md, Change Log] -- [contributing.md, Contributing] +- Home: index.md +- Client API: api.md +- Port Bindings: port-bindings.md +- Using Volumes: volumes.md +- Using TLS: tls.md +- Host devices: host-devices.md +- Host configuration: hostconfig.md +- Using with boot2docker: boot2docker.md +- Change Log: change_log.md +- Contributing: contributing.md diff --git a/requirements.txt b/requirements.txt index a1c3aa55d5..b23ea488ec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ requests==2.5.3 six>=1.3.0 -websocket-client==0.11.0 +websocket-client==0.32.0 diff --git a/requirements3.txt b/requirements3.txt deleted file mode 100644 index 9666476df4..0000000000 --- a/requirements3.txt +++ /dev/null @@ -1,2 +0,0 @@ -requests==2.5.3 -six>=1.3.0 diff --git a/setup.py b/setup.py index 6000e6fd6e..485d33c77a 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,7 @@ ] if sys.version_info[0] < 3: - requirements.append('websocket-client >= 0.11.0') + requirements.append('websocket-client >= 0.32.0') exec(open('docker/version.py').read()) diff --git a/tests/fake_api.py b/tests/fake_api.py index 2ee146e453..199b4f6498 100644 --- a/tests/fake_api.py +++ b/tests/fake_api.py @@ -14,7 +14,7 @@ import fake_stat -CURRENT_VERSION = 'v1.18' +CURRENT_VERSION = 'v1.19' FAKE_CONTAINER_ID = '3cc2351ab11b' FAKE_IMAGE_ID = 'e9aa60c60128' @@ -129,11 +129,11 @@ def post_fake_create_container(): return status_code, response -def get_fake_inspect_container(): +def get_fake_inspect_container(tty=False): status_code = 200 response = { 'Id': FAKE_CONTAINER_ID, - 'Config': {'Privileged': True}, + 'Config': {'Privileged': True, 'Tty': tty}, 'ID': FAKE_CONTAINER_ID, 'Image': 'busybox:latest', "State": { diff --git a/tests/integration_test.py b/tests/integration_test.py index 266090098d..ac4a871753 100644 --- a/tests/integration_test.py +++ b/tests/integration_test.py @@ -872,8 +872,8 @@ def runTest(self): id = container['Id'] self.client.start(id) self.tmp_containers.append(id) - socket = self.client.attach_socket(container, ws=False) - self.assertTrue(socket.fileno() > -1) + sock = self.client.attach_socket(container, ws=False) + self.assertTrue(sock.fileno() > -1) class TestPauseUnpauseContainer(BaseTestCase): @@ -1464,9 +1464,19 @@ def test_542(self): self.client.start( self.client.create_container('busybox', ['true']) ) - result = self.client.containers(trunc=True) + result = self.client.containers(all=True, trunc=True) self.assertEqual(len(result[0]['Id']), 12) + def test_647(self): + with self.assertRaises(docker.errors.APIError): + self.client.inspect_image('gensokyo.jp//kirisame') + + def test_649(self): + self.client.timeout = None + ctnr = self.client.create_container('busybox', ['sleep', '2']) + self.client.start(ctnr) + self.client.stop(ctnr) + if __name__ == '__main__': c = docker.Client(base_url=DEFAULT_BASE_URL) diff --git a/tests/test.py b/tests/test.py index e0a9e34525..f6535b2e23 100644 --- a/tests/test.py +++ b/tests/test.py @@ -69,6 +69,14 @@ def fake_resolve_authconfig(authconfig, registry=None): return None +def fake_inspect_container(self, container, tty=False): + return fake_api.get_fake_inspect_container(tty=tty)[1] + + +def fake_inspect_container_tty(self, container): + return fake_inspect_container(self, container, tty=True) + + def fake_resp(url, data=None, **kwargs): status_code, content = fake_api.fake_responses[url]() return response(status_code=status_code, content=content) @@ -124,11 +132,10 @@ def base_create_payload(self, img='busybox', cmd=None): if not cmd: cmd = ['true'] return {"Tty": False, "Image": img, "Cmd": cmd, - "AttachStdin": False, "Memory": 0, + "AttachStdin": False, "AttachStderr": True, "AttachStdout": True, "StdinOnce": False, "OpenStdin": False, "NetworkDisabled": False, - "MemorySwap": 0 } def test_ctor(self): @@ -337,11 +344,10 @@ def test_create_container(self): self.assertEqual(json.loads(args[1]['data']), json.loads(''' {"Tty": false, "Image": "busybox", "Cmd": ["true"], - "AttachStdin": false, "Memory": 0, + "AttachStdin": false, "AttachStderr": true, "AttachStdout": true, "StdinOnce": false, - "OpenStdin": false, "NetworkDisabled": false, - "MemorySwap": 0}''')) + "OpenStdin": false, "NetworkDisabled": false}''')) self.assertEqual(args[1]['headers'], {'Content-Type': 'application/json'}) @@ -361,12 +367,11 @@ def test_create_container_with_binds(self): json.loads(''' {"Tty": false, "Image": "busybox", "Cmd": ["ls", "/mnt"], "AttachStdin": false, - "Volumes": {"/mnt": {}}, "Memory": 0, + "Volumes": {"/mnt": {}}, "AttachStderr": true, "AttachStdout": true, "OpenStdin": false, "StdinOnce": false, - "NetworkDisabled": false, - "MemorySwap": 0}''')) + "NetworkDisabled": false}''')) self.assertEqual(args[1]['headers'], {'Content-Type': 'application/json'}) @@ -386,12 +391,11 @@ def test_create_container_with_volume_string(self): json.loads(''' {"Tty": false, "Image": "busybox", "Cmd": ["ls", "/mnt"], "AttachStdin": false, - "Volumes": {"/mnt": {}}, "Memory": 0, + "Volumes": {"/mnt": {}}, "AttachStderr": true, "AttachStdout": true, "OpenStdin": false, "StdinOnce": false, - "NetworkDisabled": false, - "MemorySwap": 0}''')) + "NetworkDisabled": false}''')) self.assertEqual(args[1]['headers'], {'Content-Type': 'application/json'}) @@ -409,7 +413,7 @@ def test_create_container_with_ports(self): json.loads(''' {"Tty": false, "Image": "busybox", "Cmd": ["ls"], "AttachStdin": false, - "Memory": 0, "ExposedPorts": { + "ExposedPorts": { "1111/tcp": {}, "2222/udp": {}, "3333/tcp": {} @@ -417,8 +421,7 @@ def test_create_container_with_ports(self): "AttachStderr": true, "AttachStdout": true, "OpenStdin": false, "StdinOnce": false, - "NetworkDisabled": false, - "MemorySwap": 0}''')) + "NetworkDisabled": false}''')) self.assertEqual(args[1]['headers'], {'Content-Type': 'application/json'}) @@ -436,13 +439,11 @@ def test_create_container_with_entrypoint(self): json.loads(''' {"Tty": false, "Image": "busybox", "Cmd": ["hello"], "AttachStdin": false, - "Memory": 0, "AttachStderr": true, "AttachStdout": true, "OpenStdin": false, "StdinOnce": false, "NetworkDisabled": false, - "Entrypoint": "cowsay", - "MemorySwap": 0}''')) + "Entrypoint": "cowsay"}''')) self.assertEqual(args[1]['headers'], {'Content-Type': 'application/json'}) @@ -460,13 +461,11 @@ def test_create_container_with_cpu_shares(self): json.loads(''' {"Tty": false, "Image": "busybox", "Cmd": ["ls"], "AttachStdin": false, - "Memory": 0, "AttachStderr": true, "AttachStdout": true, "OpenStdin": false, "StdinOnce": false, "NetworkDisabled": false, - "CpuShares": 5, - "MemorySwap": 0}''')) + "CpuShares": 5}''')) self.assertEqual(args[1]['headers'], {'Content-Type': 'application/json'}) @@ -484,14 +483,12 @@ def test_create_container_with_cpuset(self): json.loads(''' {"Tty": false, "Image": "busybox", "Cmd": ["ls"], "AttachStdin": false, - "Memory": 0, "AttachStderr": true, "AttachStdout": true, "OpenStdin": false, "StdinOnce": false, "NetworkDisabled": false, "Cpuset": "0,1", - "CpusetCpus": "0,1", - "MemorySwap": 0}''')) + "CpusetCpus": "0,1"}''')) self.assertEqual(args[1]['headers'], {'Content-Type': 'application/json'}) @@ -509,13 +506,11 @@ def test_create_container_with_working_dir(self): json.loads(''' {"Tty": false, "Image": "busybox", "Cmd": ["ls"], "AttachStdin": false, - "Memory": 0, "AttachStderr": true, "AttachStdout": true, "OpenStdin": false, "StdinOnce": false, "NetworkDisabled": false, - "WorkingDir": "/root", - "MemorySwap": 0}''')) + "WorkingDir": "/root"}''')) self.assertEqual(args[1]['headers'], {'Content-Type': 'application/json'}) @@ -531,11 +526,10 @@ def test_create_container_with_stdin_open(self): self.assertEqual(json.loads(args[1]['data']), json.loads(''' {"Tty": false, "Image": "busybox", "Cmd": ["true"], - "AttachStdin": true, "Memory": 0, + "AttachStdin": true, "AttachStderr": true, "AttachStdout": true, "StdinOnce": true, - "OpenStdin": true, "NetworkDisabled": false, - "MemorySwap": 0}''')) + "OpenStdin": true, "NetworkDisabled": false}''')) self.assertEqual(args[1]['headers'], {'Content-Type': 'application/json'}) @@ -581,78 +575,95 @@ def test_create_named_container(self): self.assertEqual(json.loads(args[1]['data']), json.loads(''' {"Tty": false, "Image": "busybox", "Cmd": ["true"], - "AttachStdin": false, "Memory": 0, + "AttachStdin": false, "AttachStderr": true, "AttachStdout": true, "StdinOnce": false, - "OpenStdin": false, "NetworkDisabled": false, - "MemorySwap": 0}''')) + "OpenStdin": false, "NetworkDisabled": false}''')) self.assertEqual(args[1]['headers'], {'Content-Type': 'application/json'}) self.assertEqual(args[1]['params'], {'name': 'marisa-kirisame'}) def test_create_container_with_mem_limit_as_int(self): try: - self.client.create_container('busybox', 'true', - mem_limit=128.0) + self.client.create_container( + 'busybox', 'true', host_config=create_host_config( + mem_limit=128.0 + ) + ) except Exception as e: self.fail('Command should not raise exception: {0}'.format(e)) args = fake_request.call_args data = json.loads(args[1]['data']) - self.assertEqual(data['Memory'], 128.0) + self.assertEqual(data['HostConfig']['Memory'], 128.0) def test_create_container_with_mem_limit_as_string(self): try: - self.client.create_container('busybox', 'true', - mem_limit='128') + self.client.create_container( + 'busybox', 'true', host_config=create_host_config( + mem_limit='128' + ) + ) except Exception as e: self.fail('Command should not raise exception: {0}'.format(e)) args = fake_request.call_args data = json.loads(args[1]['data']) - self.assertEqual(data['Memory'], 128.0) + self.assertEqual(data['HostConfig']['Memory'], 128.0) def test_create_container_with_mem_limit_as_string_with_k_unit(self): try: - self.client.create_container('busybox', 'true', - mem_limit='128k') + self.client.create_container( + 'busybox', 'true', host_config=create_host_config( + mem_limit='128k' + ) + ) except Exception as e: self.fail('Command should not raise exception: {0}'.format(e)) args = fake_request.call_args data = json.loads(args[1]['data']) - self.assertEqual(data['Memory'], 128.0 * 1024) + self.assertEqual(data['HostConfig']['Memory'], 128.0 * 1024) def test_create_container_with_mem_limit_as_string_with_m_unit(self): try: - self.client.create_container('busybox', 'true', - mem_limit='128m') + self.client.create_container( + 'busybox', 'true', host_config=create_host_config( + mem_limit='128m' + ) + ) + except Exception as e: self.fail('Command should not raise exception: {0}'.format(e)) args = fake_request.call_args data = json.loads(args[1]['data']) - self.assertEqual(data['Memory'], 128.0 * 1024 * 1024) + self.assertEqual(data['HostConfig']['Memory'], 128.0 * 1024 * 1024) def test_create_container_with_mem_limit_as_string_with_g_unit(self): try: - self.client.create_container('busybox', 'true', - mem_limit='128g') + self.client.create_container( + 'busybox', 'true', host_config=create_host_config( + mem_limit='128g' + ) + ) except Exception as e: self.fail('Command should not raise exception: {0}'.format(e)) args = fake_request.call_args data = json.loads(args[1]['data']) - self.assertEqual(data['Memory'], 128.0 * 1024 * 1024 * 1024) + self.assertEqual( + data['HostConfig']['Memory'], 128.0 * 1024 * 1024 * 1024 + ) def test_create_container_with_mem_limit_as_string_with_wrong_value(self): - self.assertRaises(docker.errors.DockerException, - self.client.create_container, - 'busybox', 'true', mem_limit='128p') + self.assertRaises( + docker.errors.DockerException, create_host_config, mem_limit='128p' + ) - self.assertRaises(docker.errors.DockerException, - self.client.create_container, - 'busybox', 'true', mem_limit='1f28') + self.assertRaises( + docker.errors.DockerException, create_host_config, mem_limit='1f28' + ) def test_start_container(self): try: @@ -808,6 +819,83 @@ def test_create_container_with_binds_rw(self): DEFAULT_TIMEOUT_SECONDS ) + def test_create_container_with_binds_mode(self): + try: + mount_dest = '/mnt' + mount_origin = '/tmp' + self.client.create_container( + 'busybox', 'true', host_config=create_host_config( + binds={mount_origin: { + "bind": mount_dest, + "mode": "z", + }} + ) + ) + except Exception as e: + self.fail('Command should not raise exception: {0}'.format(e)) + + args = fake_request.call_args + self.assertEqual(args[0][0], url_prefix + + 'containers/create') + expected_payload = self.base_create_payload() + expected_payload['HostConfig'] = create_host_config() + expected_payload['HostConfig']['Binds'] = ["/tmp:/mnt:z"] + self.assertEqual(json.loads(args[1]['data']), expected_payload) + self.assertEqual(args[1]['headers'], + {'Content-Type': 'application/json'}) + self.assertEqual( + args[1]['timeout'], + DEFAULT_TIMEOUT_SECONDS + ) + + def test_create_container_with_binds_mode_and_ro_error(self): + try: + mount_dest = '/mnt' + mount_origin = '/tmp' + self.client.create_container( + 'busybox', 'true', host_config=create_host_config( + binds={mount_origin: { + "bind": mount_dest, + "mode": "z", + "ro": True, + }} + ) + ) + except ValueError: + return + + self.fail('Command should raise ValueError') + + def test_create_container_with_binds_list(self): + try: + self.client.create_container( + 'busybox', 'true', host_config=create_host_config( + binds=[ + "/tmp:/mnt/1:ro", + "/tmp:/mnt/2", + ], + ) + ) + except Exception as e: + self.fail('Command should not raise exception: {0}'.format(e)) + + args = fake_request.call_args + self.assertEqual(args[0][0], url_prefix + + 'containers/create') + expected_payload = self.base_create_payload() + expected_payload['HostConfig'] = create_host_config() + expected_payload['HostConfig']['Binds'] = [ + "/tmp:/mnt/1:ro", + "/tmp:/mnt/2", + ] + self.assertEqual(json.loads(args[1]['data']), expected_payload) + self.assertEqual(args[1]['headers'], + {'Content-Type': 'application/json'}) + self.assertEqual( + args[1]['timeout'], + DEFAULT_TIMEOUT_SECONDS + ) + def test_create_container_with_port_binds(self): self.maxDiff = None try: @@ -1350,6 +1438,37 @@ def test_create_container_with_labels_list(self): args[1]['timeout'], DEFAULT_TIMEOUT_SECONDS ) + def test_create_container_with_named_volume(self): + try: + mount_dest = '/mnt' + volume_name = 'name' + self.client.create_container( + 'busybox', 'true', + host_config=create_host_config( + binds={volume_name: { + "bind": mount_dest, + "ro": False + }}), + volume_driver='foodriver', + ) + except Exception as e: + self.fail('Command should not raise exception: {0}'.format(e)) + + args = fake_request.call_args + self.assertEqual(args[0][0], url_prefix + + 'containers/create') + expected_payload = self.base_create_payload() + expected_payload['VolumeDriver'] = 'foodriver' + expected_payload['HostConfig'] = create_host_config() + expected_payload['HostConfig']['Binds'] = ["name:/mnt:rw"] + self.assertEqual(json.loads(args[1]['data']), expected_payload) + self.assertEqual(args[1]['headers'], + {'Content-Type': 'application/json'}) + self.assertEqual( + args[1]['timeout'], + DEFAULT_TIMEOUT_SECONDS + ) + def test_resize_container(self): try: self.client.resize( @@ -1435,7 +1554,9 @@ def test_url_compatibility_tcp(self): def test_logs(self): try: - logs = self.client.logs(fake_api.FAKE_CONTAINER_ID) + with mock.patch('docker.Client.inspect_container', + fake_inspect_container): + logs = self.client.logs(fake_api.FAKE_CONTAINER_ID) except Exception as e: self.fail('Command should not raise exception: {0}'.format(e)) @@ -1454,7 +1575,9 @@ def test_logs(self): def test_logs_with_dict_instead_of_id(self): try: - logs = self.client.logs({'Id': fake_api.FAKE_CONTAINER_ID}) + with mock.patch('docker.Client.inspect_container', + fake_inspect_container): + logs = self.client.logs({'Id': fake_api.FAKE_CONTAINER_ID}) except Exception as e: self.fail('Command should not raise exception: {0}'.format(e)) @@ -1473,7 +1596,9 @@ def test_logs_with_dict_instead_of_id(self): def test_log_streaming(self): try: - self.client.logs(fake_api.FAKE_CONTAINER_ID, stream=True) + with mock.patch('docker.Client.inspect_container', + fake_inspect_container): + self.client.logs(fake_api.FAKE_CONTAINER_ID, stream=True) except Exception as e: self.fail('Command should not raise exception: {0}'.format(e)) @@ -1487,7 +1612,10 @@ def test_log_streaming(self): def test_log_tail(self): try: - self.client.logs(fake_api.FAKE_CONTAINER_ID, stream=False, tail=10) + with mock.patch('docker.Client.inspect_container', + fake_inspect_container): + self.client.logs(fake_api.FAKE_CONTAINER_ID, stream=False, + tail=10) except Exception as e: self.fail('Command should not raise exception: {0}'.format(e)) @@ -1499,6 +1627,27 @@ def test_log_tail(self): stream=False ) + def test_log_tty(self): + try: + m = mock.Mock() + with mock.patch('docker.Client.inspect_container', + fake_inspect_container_tty): + with mock.patch('docker.Client._stream_raw_result', + m): + self.client.logs(fake_api.FAKE_CONTAINER_ID, + stream=True) + except Exception as e: + self.fail('Command should not raise exception: {0}'.format(e)) + + self.assertTrue(m.called) + fake_request.assert_called_with( + url_prefix + 'containers/3cc2351ab11b/logs', + params={'timestamps': 0, 'follow': 1, 'stderr': 1, 'stdout': 1, + 'tail': 'all'}, + timeout=DEFAULT_TIMEOUT_SECONDS, + stream=True + ) + def test_diff(self): try: self.client.diff(fake_api.FAKE_CONTAINER_ID) @@ -1786,15 +1935,16 @@ def test_inspect_container(self): timeout=DEFAULT_TIMEOUT_SECONDS ) - def test_inspect_container_empty_id(self): - try: - self.client.inspect_container('') - except docker.errors.NullResource as e: - self.assertEqual( - e.args[0], 'image or container param is undefined' - ) - else: - self.fail('Command expected NullResource exception') + def test_inspect_container_undefined_id(self): + for arg in None, '', {True: True}: + try: + self.client.inspect_container(arg) + except docker.errors.NullResource as e: + self.assertEqual( + e.args[0], 'image or container param is undefined' + ) + else: + self.fail('Command expected NullResource exception') def test_container_stats(self): try: @@ -1967,15 +2117,16 @@ def test_inspect_image(self): timeout=DEFAULT_TIMEOUT_SECONDS ) - def test_inspect_image_empty_id(self): - try: - self.client.inspect_image('') - except docker.errors.NullResource as e: - self.assertEqual( - e.args[0], 'image or container param is undefined' - ) - else: - self.fail('Command expected NullResource exception') + def test_inspect_image_undefined_id(self): + for arg in None, '', {True: True}: + try: + self.client.inspect_image(arg) + except docker.errors.NullResource as e: + self.assertEqual( + e.args[0], 'image or container param is undefined' + ) + else: + self.fail('Command expected NullResource exception') def test_insert_image(self): try: diff --git a/tox.ini b/tox.ini index 35b9bd6fd8..10b9df935e 100644 --- a/tox.ini +++ b/tox.ini @@ -2,24 +2,6 @@ envlist = py26, py27, py32, py33, py34, flake8 skipsdist=True -[testenv:py26] -usedevelop=True -commands = - {envbindir}/coverage run -p tests/test.py - {envbindir}/coverage run -p tests/utils_test.py -deps = - -r{toxinidir}/requirements.txt - -r{toxinidir}/test-requirements.txt - -[testenv:py27] -usedevelop=True -commands = - {envbindir}/coverage run -p tests/test.py - {envbindir}/coverage run -p tests/utils_test.py -deps = - -r{toxinidir}/requirements.txt - -r{toxinidir}/test-requirements.txt - [testenv] usedevelop=True commands = @@ -29,8 +11,8 @@ commands = {envbindir}/coverage report {envbindir}/coverage html deps = - -r{toxinidir}/requirements3.txt -r{toxinidir}/test-requirements.txt + -r{toxinidir}/requirements.txt [testenv:flake8] commands = flake8 docker tests