diff options
author | Tianon Gravi <tianon@debian.org> | 2015-11-08 10:31:39 -0800 |
---|---|---|
committer | Tianon Gravi <tianon@debian.org> | 2015-11-08 10:31:39 -0800 |
commit | 0db60a1e341b25c36bebb5903f0ce87126d7eee4 (patch) | |
tree | 601d639621dd6d0a0301861c1279f8fcc866528c /docker | |
parent | 90baa554e7b2f26eb2b43bb528d82b170b505903 (diff) |
Import python-docker_1.5.0.orig.tar.gz
Diffstat (limited to 'docker')
-rw-r--r-- | docker/api/__init__.py | 8 | ||||
-rw-r--r-- | docker/api/build.py | 132 | ||||
-rw-r--r-- | docker/api/container.py | 382 | ||||
-rw-r--r-- | docker/api/daemon.py | 78 | ||||
-rw-r--r-- | docker/api/exec_api.py | 70 | ||||
-rw-r--r-- | docker/api/image.py | 271 | ||||
-rw-r--r-- | docker/api/network.py | 55 | ||||
-rw-r--r-- | docker/api/volume.py | 36 | ||||
-rw-r--r-- | docker/auth/auth.py | 37 | ||||
-rw-r--r-- | docker/client.py | 1070 | ||||
-rw-r--r-- | docker/clientbase.py | 277 | ||||
-rw-r--r-- | docker/constants.py | 2 | ||||
-rw-r--r-- | docker/utils/__init__.py | 7 | ||||
-rw-r--r-- | docker/utils/decorators.py | 16 | ||||
-rw-r--r-- | docker/utils/ports/ports.py | 20 | ||||
-rw-r--r-- | docker/utils/types.py | 22 | ||||
-rw-r--r-- | docker/utils/utils.py | 225 | ||||
-rw-r--r-- | docker/version.py | 2 |
18 files changed, 1565 insertions, 1145 deletions
diff --git a/docker/api/__init__.py b/docker/api/__init__.py new file mode 100644 index 0000000..9e74428 --- /dev/null +++ b/docker/api/__init__.py @@ -0,0 +1,8 @@ +# flake8: noqa +from .build import BuildApiMixin +from .container import ContainerApiMixin +from .daemon import DaemonApiMixin +from .exec_api import ExecApiMixin +from .image import ImageApiMixin +from .volume import VolumeApiMixin +from .network import NetworkApiMixin diff --git a/docker/api/build.py b/docker/api/build.py new file mode 100644 index 0000000..b303ba6 --- /dev/null +++ b/docker/api/build.py @@ -0,0 +1,132 @@ +import logging +import os +import re + +from .. import constants +from .. import errors +from .. import auth +from .. import utils + + +log = logging.getLogger(__name__) + + +class BuildApiMixin(object): + 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=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: + raise TypeError("Either path or fileobj needs to be provided.") + + for key in container_limits.keys(): + if key not in constants.CONTAINER_LIMITS_KEYS: + raise errors.DockerException( + 'Invalid container_limits key {0}'.format(key) + ) + + if custom_context: + if not fileobj: + raise TypeError("You must specify fileobj with custom_context") + context = fileobj + elif fileobj is not None: + context = utils.mkbuildcontext(fileobj) + elif path.startswith(('http://', 'https://', + 'git://', 'github.com/', 'git@')): + remote = path + elif not os.path.isdir(path): + raise TypeError("You must specify a directory to build in path") + else: + dockerignore = os.path.join(path, '.dockerignore') + exclude = None + if os.path.exists(dockerignore): + with open(dockerignore, 'r') as f: + exclude = list(filter(bool, f.read().splitlines())) + context = utils.tar(path, exclude=exclude, dockerfile=dockerfile) + + if utils.compare_version('1.8', self._version) >= 0: + stream = True + + if dockerfile and utils.compare_version('1.17', self._version) < 0: + raise errors.InvalidVersion( + '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, + 'remote': remote, + 'q': quiet, + 'nocache': nocache, + 'rm': rm, + 'forcerm': forcerm, + 'pull': pull, + 'dockerfile': dockerfile, + } + params.update(container_limits) + + if context is not None: + headers = {'Content-Type': 'application/tar'} + if encoding: + headers['Content-Encoding'] = encoding + + if utils.compare_version('1.9', self._version) >= 0: + self._set_auth_headers(headers) + + response = self._post( + u, + data=context, + params=params, + headers=headers, + stream=stream, + timeout=timeout, + ) + + if context is not None and not custom_context: + context.close() + + if stream: + return self._stream_helper(response, decode=decode) + else: + output = self._result(response) + srch = r'Successfully built ([0-9a-f]+)' + match = re.search(srch, output) + if not match: + return None, output + return match.group(1), output + + def _set_auth_headers(self, headers): + log.debug('Looking for auth config') + + # If we don't have any auth data so far, try reloading the config + # file one more time in case anything showed up in there. + if not self._auth_configs: + log.debug("No auth config in memory - loading from filesystem") + self._auth_configs = auth.load_config() + + # Send the full auth configuration (if any exists), since the build + # could use any (or all) of the registries. + if self._auth_configs: + log.debug( + 'Sending auth config ({0})'.format( + ', '.join(repr(k) for k in self._auth_configs.keys()) + ) + ) + if headers is None: + headers = {} + if utils.compare_version('1.19', self._version) >= 0: + headers['X-Registry-Config'] = auth.encode_header( + self._auth_configs + ) + else: + headers['X-Registry-Config'] = auth.encode_header({ + 'configs': self._auth_configs + }) + else: + log.debug('No auth config found') diff --git a/docker/api/container.py b/docker/api/container.py new file mode 100644 index 0000000..142bd0f --- /dev/null +++ b/docker/api/container.py @@ -0,0 +1,382 @@ +import six +import warnings + +from .. import errors +from .. import utils + + +class ContainerApiMixin(object): + @utils.check_resource + def attach(self, container, stdout=True, stderr=True, + stream=False, logs=False): + params = { + 'logs': logs and 1 or 0, + 'stdout': stdout and 1 or 0, + 'stderr': stderr and 1 or 0, + 'stream': stream and 1 or 0, + } + u = self._url("/containers/{0}/attach", container) + response = self._post(u, params=params, stream=stream) + + return self._get_result(container, stream, response) + + @utils.check_resource + def attach_socket(self, container, params=None, ws=False): + if params is None: + params = { + 'stdout': 1, + 'stderr': 1, + 'stream': 1 + } + + if ws: + return self._attach_websocket(container, params) + + u = self._url("/containers/{0}/attach", container) + return self._get_raw_response_socket(self.post( + u, None, params=self._attach_params(params), stream=True)) + + @utils.check_resource + def commit(self, container, repository=None, tag=None, message=None, + author=None, conf=None): + params = { + 'container': container, + 'repo': repository, + 'tag': tag, + 'comment': message, + 'author': author + } + u = self._url("/commit") + return self._result(self._post_json(u, data=conf, params=params), + json=True) + + def containers(self, quiet=False, all=False, trunc=False, latest=False, + since=None, before=None, limit=-1, size=False, + filters=None): + params = { + 'limit': 1 if latest else limit, + 'all': 1 if all else 0, + 'size': 1 if size else 0, + 'trunc_cmd': 1 if trunc else 0, + 'since': since, + 'before': before + } + if filters: + params['filters'] = utils.convert_filters(filters) + u = self._url("/containers/json") + res = self._result(self._get(u, params=params), True) + + if quiet: + return [{'Id': x['Id']} for x in res] + if trunc: + for x in res: + x['Id'] = x['Id'][:12] + return res + + @utils.check_resource + def copy(self, container, resource): + if utils.version_gte(self._version, '1.20'): + warnings.warn( + 'Client.copy() is deprecated for API version >= 1.20, ' + 'please use get_archive() instead', + DeprecationWarning + ) + res = self._post_json( + self._url("/containers/{0}/copy".format(container)), + data={"Resource": resource}, + stream=True + ) + self._raise_for_status(res) + return res.raw + + def create_container(self, image, command=None, hostname=None, user=None, + detach=False, stdin_open=False, tty=False, + 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=None, cpuset=None, host_config=None, + mac_address=None, labels=None, volume_driver=None): + + if isinstance(volumes, six.string_types): + volumes = [volumes, ] + + if host_config and utils.compare_version('1.15', self._version) < 0: + raise errors.InvalidVersion( + 'host_config is not supported in API < 1.15' + ) + + config = self.create_container_config( + 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, + volume_driver + ) + return self.create_container_from_config(config, name) + + def create_container_config(self, *args, **kwargs): + return utils.create_container_config(self._version, *args, **kwargs) + + def create_container_from_config(self, config, name=None): + u = self._url("/containers/create") + params = { + 'name': name + } + res = self._post_json(u, data=config, params=params) + return self._result(res, True) + + def create_host_config(self, *args, **kwargs): + if not kwargs: + kwargs = {} + if 'version' in kwargs: + raise TypeError( + "create_host_config() got an unexpected " + "keyword argument 'version'" + ) + kwargs['version'] = self._version + return utils.create_host_config(*args, **kwargs) + + @utils.check_resource + def diff(self, container): + return self._result( + self._get(self._url("/containers/{0}/changes", container)), True + ) + + @utils.check_resource + def export(self, container): + res = self._get( + self._url("/containers/{0}/export", container), stream=True + ) + self._raise_for_status(res) + return res.raw + + @utils.check_resource + @utils.minimum_version('1.20') + def get_archive(self, container, path): + params = { + 'path': path + } + url = self._url('/containers/{0}/archive', container) + res = self._get(url, params=params, stream=True) + self._raise_for_status(res) + encoded_stat = res.headers.get('x-docker-container-path-stat') + return ( + res.raw, + utils.decode_json_header(encoded_stat) if encoded_stat else None + ) + + @utils.check_resource + def inspect_container(self, container): + return self._result( + self._get(self._url("/containers/{0}/json", container)), True + ) + + @utils.check_resource + def kill(self, container, signal=None): + url = self._url("/containers/{0}/kill", container) + params = {} + if signal is not None: + params['signal'] = signal + res = self._post(url, params=params) + + self._raise_for_status(res) + + @utils.check_resource + def logs(self, container, stdout=True, stderr=True, stream=False, + timestamps=False, tail='all'): + if utils.compare_version('1.11', self._version) >= 0: + params = {'stderr': stderr and 1 or 0, + 'stdout': stdout and 1 or 0, + 'timestamps': timestamps and 1 or 0, + 'follow': stream and 1 or 0, + } + if utils.compare_version('1.13', self._version) >= 0: + if tail != 'all' and (not isinstance(tail, int) or tail <= 0): + tail = 'all' + params['tail'] = tail + url = self._url("/containers/{0}/logs", container) + res = self._get(url, params=params, stream=stream) + return self._get_result(container, stream, res) + return self.attach( + container, + stdout=stdout, + stderr=stderr, + stream=stream, + logs=True + ) + + @utils.check_resource + def pause(self, container): + url = self._url('/containers/{0}/pause', container) + res = self._post(url) + self._raise_for_status(res) + + @utils.check_resource + def port(self, container, private_port): + res = self._get(self._url("/containers/{0}/json", container)) + self._raise_for_status(res) + json_ = res.json() + private_port = str(private_port) + h_ports = None + + # Port settings is None when the container is running with + # network_mode=host. + port_settings = json_.get('NetworkSettings', {}).get('Ports') + if port_settings is None: + return None + + if '/' in private_port: + return port_settings.get(private_port) + + h_ports = port_settings.get(private_port + '/tcp') + if h_ports is None: + h_ports = port_settings.get(private_port + '/udp') + + return h_ports + + @utils.check_resource + @utils.minimum_version('1.20') + def put_archive(self, container, path, data): + params = {'path': path} + url = self._url('/containers/{0}/archive', container) + res = self._put(url, params=params, data=data) + self._raise_for_status(res) + return res.status_code == 200 + + @utils.check_resource + def remove_container(self, container, v=False, link=False, force=False): + params = {'v': v, 'link': link, 'force': force} + res = self._delete( + self._url("/containers/{0}", container), params=params + ) + self._raise_for_status(res) + + @utils.minimum_version('1.17') + @utils.check_resource + def rename(self, container, name): + url = self._url("/containers/{0}/rename", container) + params = {'name': name} + res = self._post(url, params=params) + self._raise_for_status(res) + + @utils.check_resource + def resize(self, container, height, width): + params = {'h': height, 'w': width} + url = self._url("/containers/{0}/resize", container) + res = self._post(url, params=params) + self._raise_for_status(res) + + @utils.check_resource + def restart(self, container, timeout=10): + params = {'t': timeout} + url = self._url("/containers/{0}/restart", container) + res = self._post(url, params=params) + self._raise_for_status(res) + + @utils.check_resource + def start(self, container, binds=None, port_bindings=None, lxc_conf=None, + publish_all_ports=None, links=None, privileged=None, + 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): + + if utils.compare_version('1.10', self._version) < 0: + if dns is not None: + raise errors.InvalidVersion( + 'dns is only supported for API version >= 1.10' + ) + if volumes_from is not None: + raise errors.InvalidVersion( + 'volumes_from is only supported for API version >= 1.10' + ) + + if utils.compare_version('1.15', self._version) < 0: + if security_opt is not None: + raise errors.InvalidVersion( + 'security_opt is only supported for API version >= 1.15' + ) + if ipc_mode: + raise errors.InvalidVersion( + 'ipc_mode is only supported for API version >= 1.15' + ) + + if utils.compare_version('1.17', self._version) < 0: + if read_only is not None: + raise errors.InvalidVersion( + 'read_only is only supported for API version >= 1.17' + ) + if pid_mode is not None: + raise errors.InvalidVersion( + 'pid_mode is only supported for API version >= 1.17' + ) + + if utils.compare_version('1.18', self._version) < 0: + if ulimits is not None: + raise errors.InvalidVersion( + 'ulimits is only supported for API version >= 1.18' + ) + + start_config_kwargs = dict( + binds=binds, port_bindings=port_bindings, lxc_conf=lxc_conf, + publish_all_ports=publish_all_ports, links=links, dns=dns, + privileged=privileged, dns_search=dns_search, cap_add=cap_add, + cap_drop=cap_drop, volumes_from=volumes_from, devices=devices, + network_mode=network_mode, restart_policy=restart_policy, + extra_hosts=extra_hosts, read_only=read_only, pid_mode=pid_mode, + ipc_mode=ipc_mode, security_opt=security_opt, ulimits=ulimits + ) + start_config = None + + if any(v is not None for v in start_config_kwargs.values()): + if utils.compare_version('1.15', self._version) > 0: + warnings.warn( + 'Passing host config parameters in start() is deprecated. ' + 'Please use host_config in create_container instead!', + DeprecationWarning + ) + start_config = self.create_host_config(**start_config_kwargs) + + url = self._url("/containers/{0}/start", container) + res = self._post_json(url, data=start_config) + self._raise_for_status(res) + + @utils.minimum_version('1.17') + @utils.check_resource + def stats(self, container, decode=None): + url = self._url("/containers/{0}/stats", container) + return self._stream_helper(self._get(url, stream=True), decode=decode) + + @utils.check_resource + def stop(self, container, timeout=10): + params = {'t': timeout} + url = self._url("/containers/{0}/stop", container) + + res = self._post(url, params=params, + timeout=(timeout + (self.timeout or 0))) + self._raise_for_status(res) + + @utils.check_resource + def top(self, container, ps_args=None): + u = self._url("/containers/{0}/top", container) + params = {} + if ps_args is not None: + params['ps_args'] = ps_args + return self._result(self._get(u, params=params), True) + + @utils.check_resource + def unpause(self, container): + url = self._url('/containers/{0}/unpause', container) + res = self._post(url) + self._raise_for_status(res) + + @utils.check_resource + def wait(self, container, timeout=None): + url = self._url("/containers/{0}/wait", container) + res = self._post(url, timeout=timeout) + self._raise_for_status(res) + json_ = res.json() + if 'StatusCode' in json_: + return json_['StatusCode'] + return -1 diff --git a/docker/api/daemon.py b/docker/api/daemon.py new file mode 100644 index 0000000..a149e5e --- /dev/null +++ b/docker/api/daemon.py @@ -0,0 +1,78 @@ +import os +import warnings +from datetime import datetime + +from ..auth import auth +from ..constants import INSECURE_REGISTRY_DEPRECATION_WARNING +from ..utils import utils + + +class DaemonApiMixin(object): + def events(self, since=None, until=None, filters=None, decode=None): + if isinstance(since, datetime): + since = utils.datetime_to_timestamp(since) + + if isinstance(until, datetime): + until = utils.datetime_to_timestamp(until) + + if filters: + filters = utils.convert_filters(filters) + + params = { + 'since': since, + 'until': until, + 'filters': filters + } + + return self._stream_helper( + self.get(self._url('/events'), params=params, stream=True), + decode=decode + ) + + def info(self): + return self._result(self._get(self._url("/info")), True) + + def login(self, username, password=None, email=None, registry=None, + reauth=False, insecure_registry=False, dockercfg_path=None): + if insecure_registry: + warnings.warn( + INSECURE_REGISTRY_DEPRECATION_WARNING.format('login()'), + DeprecationWarning + ) + + # If we don't have any auth data so far, try reloading the config file + # one more time in case anything showed up in there. + # If dockercfg_path is passed check to see if the config file exists, + # if so load that config. + if dockercfg_path and os.path.exists(dockercfg_path): + self._auth_configs = auth.load_config(dockercfg_path) + elif not self._auth_configs: + self._auth_configs = auth.load_config() + + registry = registry or auth.INDEX_URL + + authcfg = auth.resolve_authconfig(self._auth_configs, registry) + # If we found an existing auth config for this registry and username + # combination, we can return it immediately unless reauth is requested. + if authcfg and authcfg.get('username', None) == username \ + and not reauth: + return authcfg + + req_data = { + 'username': username, + 'password': password, + 'email': email, + 'serveraddress': registry, + } + + response = self._post_json(self._url('/auth'), data=req_data) + if response.status_code == 200: + self._auth_configs[registry] = req_data + return self._result(response, json=True) + + def ping(self): + return self._result(self._get(self._url('/_ping'))) + + def version(self, api_version=True): + url = self._url("/version", versioned_api=api_version) + return self._result(self._get(url), json=True) diff --git a/docker/api/exec_api.py b/docker/api/exec_api.py new file mode 100644 index 0000000..c66b9dd --- /dev/null +++ b/docker/api/exec_api.py @@ -0,0 +1,70 @@ +import shlex + +import six + +from .. import errors +from .. import utils + + +class ExecApiMixin(object): + @utils.minimum_version('1.15') + @utils.check_resource + def exec_create(self, container, cmd, stdout=True, stderr=True, tty=False, + privileged=False, user=''): + if privileged and utils.compare_version('1.19', self._version) < 0: + raise errors.InvalidVersion( + 'Privileged exec is not supported in API < 1.19' + ) + if user and utils.compare_version('1.19', self._version) < 0: + raise errors.InvalidVersion( + 'User-specific exec is not supported in API < 1.19' + ) + if isinstance(cmd, six.string_types): + cmd = shlex.split(str(cmd)) + + data = { + 'Container': container, + 'User': user, + 'Privileged': privileged, + 'Tty': tty, + 'AttachStdin': False, + 'AttachStdout': stdout, + 'AttachStderr': stderr, + 'Cmd': cmd + } + + url = self._url('/containers/{0}/exec', container) + res = self._post_json(url, data=data) + return self._result(res, True) + + @utils.minimum_version('1.16') + def exec_inspect(self, exec_id): + if isinstance(exec_id, dict): + exec_id = exec_id.get('Id') + res = self._get(self._url("/exec/{0}/json", exec_id)) + return self._result(res, True) + + @utils.minimum_version('1.15') + def exec_resize(self, exec_id, height=None, width=None): + if isinstance(exec_id, dict): + exec_id = exec_id.get('Id') + + params = {'h': height, 'w': width} + url = self._url("/exec/{0}/resize", exec_id) + res = self._post(url, params=params) + self._raise_for_status(res) + + @utils.minimum_version('1.15') + def exec_start(self, exec_id, detach=False, tty=False, stream=False): + if isinstance(exec_id, dict): + exec_id = exec_id.get('Id') + + data = { + 'Tty': tty, + 'Detach': detach + } + + res = self._post_json( + self._url('/exec/{0}/start', exec_id), data=data, stream=stream + ) + return self._get_result_tty(stream, res, tty) diff --git a/docker/api/image.py b/docker/api/image.py new file mode 100644 index 0000000..f891e21 --- /dev/null +++ b/docker/api/image.py @@ -0,0 +1,271 @@ +import logging +import six +import warnings + +from ..auth import auth +from ..constants import INSECURE_REGISTRY_DEPRECATION_WARNING +from .. import utils +from .. import errors + +log = logging.getLogger(__name__) + + +class ImageApiMixin(object): + + @utils.check_resource + def get_image(self, image): + res = self._get(self._url("/images/{0}/get", image), stream=True) + self._raise_for_status(res) + return res.raw + + @utils.check_resource + def history(self, image): + res = self._get(self._url("/images/{0}/history", image)) + return self._result(res, True) + + def images(self, name=None, quiet=False, all=False, viz=False, + filters=None): + if viz: + if utils.compare_version('1.7', self._version) >= 0: + raise Exception('Viz output is not supported in API >= 1.7!') + return self._result(self._get(self._url("images/viz"))) + params = { + 'filter': name, + 'only_ids': 1 if quiet else 0, + 'all': 1 if all else 0, + } + if filters: + params['filters'] = utils.convert_filters(filters) + res = self._result(self._get(self._url("/images/json"), params=params), + True) + if quiet: + return [x['Id'] for x in res] + return res + + def import_image(self, src=None, repository=None, tag=None, image=None): + if src: + if isinstance(src, six.string_types): + try: + result = self.import_image_from_file( + src, repository=repository, tag=tag) + except IOError: + result = self.import_image_from_url( + src, repository=repository, tag=tag) + else: + result = self.import_image_from_data( + src, repository=repository, tag=tag) + elif image: + result = self.import_image_from_image( + image, repository=repository, tag=tag) + else: + raise Exception("Must specify a src or image") + + return result + + def import_image_from_data(self, data, repository=None, tag=None): + u = self._url("/images/create") + params = { + 'fromSrc': '-', + 'repo': repository, + 'tag': tag + } + headers = { + 'Content-Type': 'application/tar', + } + return self._result( + self._post(u, data=data, params=params, headers=headers)) + + def import_image_from_file(self, filename, repository=None, tag=None): + u = self._url("/images/create") + params = { + 'fromSrc': '-', + 'repo': repository, + 'tag': tag + } + headers = { + 'Content-Type': 'application/tar', + } + with open(filename, 'rb') as f: + return self._result( + self._post(u, data=f, params=params, headers=headers, + timeout=None)) + + def import_image_from_stream(self, stream, repository=None, tag=None): + u = self._url("/images/create") + params = { + 'fromSrc': '-', + 'repo': repository, + 'tag': tag + } + headers = { + 'Content-Type': 'application/tar', + 'Transfer-Encoding': 'chunked', + } + return self._result( + self._post(u, data=stream, params=params, headers=headers)) + + def import_image_from_url(self, url, repository=None, tag=None): + u = self._url("/images/create") + params = { + 'fromSrc': url, + 'repo': repository, + 'tag': tag + } + return self._result( + self._post(u, data=None, params=params)) + + def import_image_from_image(self, image, repository=None, tag=None): + u = self._url("/images/create") + params = { + 'fromImage': image, + 'repo': repository, + 'tag': tag + } + return self._result( + self._post(u, data=None, params=params)) + + @utils.check_resource + def insert(self, image, url, path): + if utils.compare_version('1.12', self._version) >= 0: + raise errors.DeprecatedMethod( + 'insert is not available for API version >=1.12' + ) + api_url = self._url("/images/{0}/insert", image) + params = { + 'url': url, + 'path': path + } + return self._result(self._post(api_url, params=params)) + + @utils.check_resource + def inspect_image(self, image): + return self._result( + self._get(self._url("/images/{0}/json", image)), True + ) + + def load_image(self, data): + res = self._post(self._url("/images/load"), data=data) + self._raise_for_status(res) + + def pull(self, repository, tag=None, stream=False, + insecure_registry=False, auth_config=None): + if insecure_registry: + warnings.warn( + INSECURE_REGISTRY_DEPRECATION_WARNING.format('pull()'), + DeprecationWarning + ) + + if not tag: + repository, tag = utils.parse_repository_tag(repository) + registry, repo_name = auth.resolve_repository_name(repository) + if repo_name.count(":") == 1: + repository, tag = repository.rsplit(":", 1) + + params = { + 'tag': tag, + 'fromImage': repository + } + headers = {} + + if utils.compare_version('1.5', self._version) >= 0: + # If we don't have any auth data so far, try reloading the config + # file one more time in case anything showed up in there. + if auth_config is None: + log.debug('Looking for auth config') + if not self._auth_configs: + log.debug( + "No auth config in memory - loading from filesystem") + self._auth_configs = auth.load_config() + authcfg = auth.resolve_authconfig(self._auth_configs, registry) + # Do not fail here if no authentication exists for this + # specific registry as we can have a readonly pull. Just + # put the header if we can. + if authcfg: + log.debug('Found auth config') + # auth_config needs to be a dict in the format used by + # auth.py username , password, serveraddress, email + headers['X-Registry-Auth'] = auth.encode_header( + authcfg + ) + else: + log.debug('No auth config found') + else: + log.debug('Sending supplied auth config') + headers['X-Registry-Auth'] = auth.encode_header(auth_config) + + 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) + + return self._result(response) + + def push(self, repository, tag=None, stream=False, + insecure_registry=False): + if insecure_registry: + warnings.warn( + INSECURE_REGISTRY_DEPRECATION_WARNING.format('push()'), + DeprecationWarning + ) + + if not tag: + repository, tag = utils.parse_repository_tag(repository) + registry, repo_name = auth.resolve_repository_name(repository) + u = self._url("/images/{0}/push", repository) + params = { + 'tag': tag + } + headers = {} + + if utils.compare_version('1.5', self._version) >= 0: + # If we don't have any auth data so far, try reloading the config + # file one more time in case anything showed up in there. + if not self._auth_configs: + self._auth_configs = auth.load_config() + authcfg = auth.resolve_authconfig(self._auth_configs, registry) + + # Do not fail here if no authentication exists for this specific + # registry as we can have a readonly pull. Just put the header if + # we can. + if authcfg: + headers['X-Registry-Auth'] = auth.encode_header(authcfg) + + 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 self._result(response) + + @utils.check_resource + def remove_image(self, image, force=False, noprune=False): + params = {'force': force, 'noprune': noprune} + res = self._delete(self._url("/images/{0}", image), params=params) + self._raise_for_status(res) + + def search(self, term): + return self._result( + self._get(self._url("/images/search"), params={'term': term}), + True + ) + + @utils.check_resource + def tag(self, image, repository, tag=None, force=False): + params = { + 'tag': tag, + 'repo': repository, + 'force': 1 if force else 0 + } + url = self._url("/images/{0}/tag", image) + res = self._post(url, params=params) + self._raise_for_status(res) + return res.status_code == 201 diff --git a/docker/api/network.py b/docker/api/network.py new file mode 100644 index 0000000..2dea679 --- /dev/null +++ b/docker/api/network.py @@ -0,0 +1,55 @@ +import json + +from ..utils import check_resource, minimum_version + + +class NetworkApiMixin(object): + @minimum_version('1.21') + def networks(self, names=None, ids=None): + filters = {} + if names: + filters['name'] = names + if ids: + filters['id'] = ids + + params = {'filters': json.dumps(filters)} + + url = self._url("/networks") + res = self._get(url, params=params) + return self._result(res, json=True) + + @minimum_version('1.21') + def create_network(self, name, driver=None): + data = { + 'name': name, + 'driver': driver, + } + url = self._url("/networks/create") + res = self._post_json(url, data=data) + return self._result(res, json=True) + + @minimum_version('1.21') + def remove_network(self, net_id): + url = self._url("/networks/{0}", net_id) + res = self._delete(url) + self._raise_for_status(res) + + @minimum_version('1.21') + def inspect_network(self, net_id): + url = self._url("/networks/{0}", net_id) + res = self._get(url) + return self._result(res, json=True) + + @check_resource + @minimum_version('1.21') + def connect_container_to_network(self, container, net_id): + data = {"container": container} + url = self._url("/networks/{0}/connect", net_id) + self._post_json(url, data=data) + + @check_resource + @minimum_version('1.21') + def disconnect_container_from_network(self, container, net_id): + data = {"container": container} + url = self._url("/networks/{0}/disconnect", net_id) + self._post_json(url, data=data) diff --git a/docker/api/volume.py b/docker/api/volume.py new file mode 100644 index 0000000..e9e7127 --- /dev/null +++ b/docker/api/volume.py @@ -0,0 +1,36 @@ +from .. import utils + + +class VolumeApiMixin(object): + @utils.minimum_version('1.21') + def volumes(self, filters=None): + params = { + 'filter': utils.convert_filters(filters) if filters else None + } + url = self._url('/volumes') + return self._result(self._get(url, params=params), True) + + @utils.minimum_version('1.21') + def create_volume(self, name, driver=None, driver_opts=None): + url = self._url('/volumes') + if driver_opts is not None and not isinstance(driver_opts, dict): + raise TypeError('driver_opts must be a dictionary') + + data = { + 'Name': name, + 'Driver': driver, + 'DriverOpts': driver_opts, + } + return self._result(self._post_json(url, data=data), True) + + @utils.minimum_version('1.21') + def inspect_volume(self, name): + url = self._url('/volumes/{0}', name) + return self._result(self._get(url), True) + + @utils.minimum_version('1.21') + def remove_volume(self, name): + url = self._url('/volumes/{0}', name) + resp = self._delete(url) + self._raise_for_status(resp) + return True diff --git a/docker/auth/auth.py b/docker/auth/auth.py index 4af741e..1ee9f81 100644 --- a/docker/auth/auth.py +++ b/docker/auth/auth.py @@ -15,6 +15,7 @@ import base64 import fileinput import json +import logging import os import warnings @@ -28,6 +29,8 @@ INDEX_URL = 'https://{0}/v1/'.format(INDEX_NAME) DOCKER_CONFIG_FILENAME = os.path.join('.docker', 'config.json') LEGACY_DOCKER_CONFIG_FILENAME = '.dockercfg' +log = logging.getLogger(__name__) + def resolve_repository_name(repo_name, insecure=False): if insecure: @@ -65,14 +68,18 @@ def resolve_authconfig(authconfig, registry=None): """ # Default to the public index server registry = convert_to_hostname(registry) if registry else INDEX_NAME + log.debug("Looking for auth entry for {0}".format(repr(registry))) if registry in authconfig: + log.debug("Found {0}".format(repr(registry))) return authconfig[registry] for key, config in six.iteritems(authconfig): if convert_to_hostname(key) == registry: + log.debug("Found {0}".format(repr(key))) return config + log.debug("No entry found") return None @@ -95,7 +102,7 @@ def decode_auth(auth): def encode_header(auth): auth_json = json.dumps(auth).encode('ascii') - return base64.b64encode(auth_json) + return base64.urlsafe_b64encode(auth_json) def parse_auth(entries): @@ -112,6 +119,10 @@ def parse_auth(entries): conf = {} for registry, entry in six.iteritems(entries): username, password = decode_auth(entry['auth']) + log.debug( + 'Found entry (registry={0}, username={1})' + .format(repr(registry), repr(username)) + ) conf[registry] = { 'username': username, 'password': password, @@ -133,31 +144,41 @@ def load_config(config_path=None): config_file = config_path or os.path.join(os.path.expanduser('~'), DOCKER_CONFIG_FILENAME) + log.debug("Trying {0}".format(config_file)) + 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 + log.debug("Found 'auths' section") return parse_auth(data) - except (IOError, KeyError, ValueError): + log.debug("Couldn't find 'auths' section") + except (IOError, KeyError, ValueError) as e: # Likely missing new Docker config file or it's in an # unknown format, continue to attempt to read old location # and format. + log.debug(e) pass + else: + log.debug("File doesn't exist") config_file = config_path or os.path.join(os.path.expanduser('~'), LEGACY_DOCKER_CONFIG_FILENAME) - # if config path doesn't exist return empty config + log.debug("Trying {0}".format(config_file)) + if not os.path.exists(config_file): + log.debug("File doesn't exist - returning empty config") return {} - # Try reading legacy location as JSON. + log.debug("Attempting to parse as JSON") try: with open(config_file) as f: return parse_auth(json.load(f)) - except: + except Exception as e: + log.debug(e) pass # If that fails, we assume the configuration file contains a single @@ -165,6 +186,7 @@ def load_config(config_path=None): # # auth = AUTH_TOKEN # email = email@domain.com + log.debug("Attempting to parse legacy auth file format") try: data = [] for line in fileinput.input(config_file): @@ -182,8 +204,9 @@ def load_config(config_path=None): 'serveraddress': INDEX_URL, } return conf - except: + except Exception as e: + log.debug(e) pass - # If all fails, return an empty config + log.debug("All parsing attempts failed - returning empty config") return {} diff --git a/docker/client.py b/docker/client.py index e4712c2..d219472 100644 --- a/docker/client.py +++ b/docker/client.py @@ -12,845 +12,307 @@ # See the License for the specific language governing permissions and # limitations under the License. -import os -import re -import shlex -import warnings -from datetime import datetime +import json +import struct +import sys +import requests +import requests.exceptions import six +import websocket -from . import clientbase + +from . import api 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 .constants import INSECURE_REGISTRY_DEPRECATION_WARNING - - -class Client(clientbase.ClientBase): - @check_resource - def attach(self, container, stdout=True, stderr=True, - stream=False, logs=False): - params = { - 'logs': logs and 1 or 0, - 'stdout': stdout and 1 or 0, - 'stderr': stderr and 1 or 0, - 'stream': stream and 1 or 0, - } - u = self._url("/containers/{0}/attach".format(container)) - response = self._post(u, params=params, stream=stream) - - return self._get_result(container, stream, response) - - @check_resource - def attach_socket(self, container, params=None, ws=False): - if params is None: - params = { - 'stdout': 1, - 'stderr': 1, - 'stream': 1 - } - - if ws: - return self._attach_websocket(container, params) - - 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=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: - raise TypeError("Either path or fileobj needs to be provided.") - - for key in container_limits.keys(): - if key not in constants.CONTAINER_LIMITS_KEYS: - raise errors.DockerException( - 'Invalid container_limits key {0}'.format(key) - ) - - if custom_context: - if not fileobj: - raise TypeError("You must specify fileobj with custom_context") - context = fileobj - elif fileobj is not None: - context = utils.mkbuildcontext(fileobj) - elif path.startswith(('http://', 'https://', - 'git://', 'github.com/', 'git@')): - remote = path - elif not os.path.isdir(path): - raise TypeError("You must specify a directory to build in path") +from .tls import TLSConfig + + +class Client( + requests.Session, + api.BuildApiMixin, + api.ContainerApiMixin, + api.DaemonApiMixin, + api.ExecApiMixin, + api.ImageApiMixin, + api.VolumeApiMixin, + api.NetworkApiMixin): + 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, sys.platform) + 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: - dockerignore = os.path.join(path, '.dockerignore') - exclude = None - if os.path.exists(dockerignore): - with open(dockerignore, 'r') as f: - exclude = list(filter(bool, f.read().splitlines())) - # These are handled by the docker daemon and should not be - # excluded on the client - if 'Dockerfile' in exclude: - exclude.remove('Dockerfile') - if '.dockerignore' in exclude: - exclude.remove(".dockerignore") - context = utils.tar(path, exclude=exclude) - - if utils.compare_version('1.8', self._version) >= 0: - stream = True - - if dockerfile and utils.compare_version('1.17', self._version) < 0: - raise errors.InvalidVersion( - '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, - 'remote': remote, - 'q': quiet, - 'nocache': nocache, - 'rm': rm, - 'forcerm': forcerm, - 'pull': pull, - 'dockerfile': dockerfile, - } - params.update(container_limits) - - if context is not None: - headers = {'Content-Type': 'application/tar'} - if encoding: - headers['Content-Encoding'] = encoding - - if utils.compare_version('1.9', self._version) >= 0: - # If we don't have any auth data so far, try reloading the config - # file one more time in case anything showed up in there. - if not self._auth_configs: - self._auth_configs = auth.load_config() - - # Send the full auth configuration (if any exists), since the build - # could use any (or all) of the registries. - if self._auth_configs: - if headers is None: - headers = {} - if utils.compare_version('1.19', self._version) >= 0: - headers['X-Registry-Config'] = auth.encode_header( - self._auth_configs - ) - else: - headers['X-Registry-Config'] = auth.encode_header({ - 'configs': self._auth_configs - }) - - response = self._post( - u, - data=context, - params=params, - headers=headers, - stream=stream, - timeout=timeout, - ) - - if context is not None and not custom_context: - context.close() - - if stream: - return self._stream_helper(response, decode=decode) + # 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: - output = self._result(response) - srch = r'Successfully built ([0-9a-f]+)' - match = re.search(srch, output) - if not match: - return None, output - return match.group(1), output - - @check_resource - def commit(self, container, repository=None, tag=None, message=None, - author=None, conf=None): - params = { - 'container': container, - 'repo': repository, - 'tag': tag, - 'comment': message, - 'author': author - } - u = self._url("/commit") - return self._result(self._post_json(u, data=conf, params=params), - json=True) - - def containers(self, quiet=False, all=False, trunc=False, latest=False, - since=None, before=None, limit=-1, size=False, - filters=None): - params = { - 'limit': 1 if latest else limit, - 'all': 1 if all else 0, - 'size': 1 if size else 0, - 'trunc_cmd': 1 if trunc else 0, - 'since': since, - 'before': before - } - if filters: - params['filters'] = utils.convert_filters(filters) - u = self._url("/containers/json") - res = self._result(self._get(u, params=params), True) - - if quiet: - return [{'Id': x['Id']} for x in res] - if trunc: - for x in res: - x['Id'] = x['Id'][:12] - return res - - @check_resource - def copy(self, container, resource): - res = self._post_json( - self._url("/containers/{0}/copy".format(container)), - data={"Resource": resource}, - stream=True - ) - self._raise_for_status(res) - return res.raw - - def create_container(self, image, command=None, hostname=None, user=None, - detach=False, stdin_open=False, tty=False, - 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=None, cpuset=None, host_config=None, - mac_address=None, labels=None, volume_driver=None): - - if isinstance(volumes, six.string_types): - volumes = [volumes, ] - - if host_config and utils.compare_version('1.15', self._version) < 0: - raise errors.InvalidVersion( - 'host_config is not supported in API < 1.15' + raise errors.DockerException( + 'Version parameter must be a string or None. Found {0}'.format( + type(version).__name__ + ) ) - config = utils.create_container_config( - 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, - volume_driver - ) - return self.create_container_from_config(config, name) - - def create_container_from_config(self, config, name=None): - u = self._url("/containers/create") - params = { - 'name': name - } - res = self._post_json(u, data=config, params=params) - return self._result(res, True) - - @check_resource - def diff(self, container): - return self._result(self._get(self._url("/containers/{0}/changes". - format(container))), True) - - def events(self, since=None, until=None, filters=None, decode=None): - if isinstance(since, datetime): - since = utils.datetime_to_timestamp(since) - - if isinstance(until, datetime): - until = utils.datetime_to_timestamp(until) - - if filters: - filters = utils.convert_filters(filters) - - params = { - 'since': since, - 'until': until, - 'filters': filters - } - - return self._stream_helper( - self.get(self._url('/events'), params=params, stream=True), - decode=decode - ) - - @check_resource - def exec_create(self, container, cmd, stdout=True, stderr=True, tty=False, - privileged=False): - if utils.compare_version('1.15', self._version) < 0: - raise errors.InvalidVersion('Exec is not supported in API < 1.15') - if privileged and utils.compare_version('1.19', self._version) < 0: - raise errors.InvalidVersion( - 'Privileged exec is not supported in API < 1.19' + 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.' ) - if isinstance(cmd, six.string_types): - cmd = shlex.split(str(cmd)) - - data = { - 'Container': container, - 'User': '', - 'Privileged': privileged, - 'Tty': tty, - 'AttachStdin': False, - 'AttachStdout': stdout, - 'AttachStderr': stderr, - 'Cmd': cmd - } - - url = self._url('/containers/{0}/exec'.format(container)) - res = self._post_json(url, data=data) - return self._result(res, True) - - def exec_inspect(self, exec_id): - if utils.compare_version('1.15', self._version) < 0: - raise errors.InvalidVersion('Exec is not supported in API < 1.15') - if isinstance(exec_id, dict): - exec_id = exec_id.get('Id') - res = self._get(self._url("/exec/{0}/json".format(exec_id))) - return self._result(res, True) - - def exec_resize(self, exec_id, height=None, width=None): - if utils.compare_version('1.15', self._version) < 0: - raise errors.InvalidVersion('Exec is not supported in API < 1.15') - if isinstance(exec_id, dict): - exec_id = exec_id.get('Id') - - params = {'h': height, 'w': width} - url = self._url("/exec/{0}/resize".format(exec_id)) - res = self._post(url, params=params) - self._raise_for_status(res) - - def exec_start(self, exec_id, detach=False, tty=False, stream=False): - if utils.compare_version('1.15', self._version) < 0: - raise errors.InvalidVersion('Exec is not supported in API < 1.15') - if isinstance(exec_id, dict): - exec_id = exec_id.get('Id') - - data = { - 'Tty': tty, - 'Detach': detach - } - - res = self._post_json(self._url('/exec/{0}/start'.format(exec_id)), - data=data, stream=stream) - return self._get_result_tty(stream, res, tty) - - @check_resource - def export(self, container): - res = self._get(self._url("/containers/{0}/export".format(container)), - stream=True) - self._raise_for_status(res) - return res.raw - - @check_resource - def get_image(self, image): - res = self._get(self._url("/images/{0}/get".format(image)), - stream=True) - self._raise_for_status(res) - return res.raw - - @check_resource - def history(self, image): - res = self._get(self._url("/images/{0}/history".format(image))) - return self._result(res, True) - - def images(self, name=None, quiet=False, all=False, viz=False, - filters=None): - if viz: - if utils.compare_version('1.7', self._version) >= 0: - raise Exception('Viz output is not supported in API >= 1.7!') - return self._result(self._get(self._url("images/viz"))) - params = { - 'filter': name, - 'only_ids': 1 if quiet else 0, - 'all': 1 if all else 0, - } - if filters: - params['filters'] = utils.convert_filters(filters) - res = self._result(self._get(self._url("/images/json"), params=params), - True) - if quiet: - return [x['Id'] for x in res] - return res - - def import_image(self, src=None, repository=None, tag=None, image=None): - if src: - if isinstance(src, six.string_types): - try: - result = self.import_image_from_file( - src, repository=repository, tag=tag) - except IOError: - result = self.import_image_from_url( - src, repository=repository, tag=tag) - else: - result = self.import_image_from_data( - src, repository=repository, tag=tag) - elif image: - result = self.import_image_from_image( - image, repository=repository, tag=tag) - else: - raise Exception("Must specify a src or image") - - return result - - def import_image_from_data(self, data, repository=None, tag=None): - u = self._url("/images/create") - params = { - 'fromSrc': '-', - 'repo': repository, - 'tag': tag - } - headers = { - 'Content-Type': 'application/tar', - } - return self._result( - self._post(u, data=data, params=params, headers=headers)) - - def import_image_from_file(self, filename, repository=None, tag=None): - u = self._url("/images/create") - params = { - 'fromSrc': '-', - 'repo': repository, - 'tag': tag - } - headers = { - 'Content-Type': 'application/tar', - } - with open(filename, 'rb') as f: - return self._result( - self._post(u, data=f, params=params, headers=headers, - timeout=None)) - - def import_image_from_stream(self, stream, repository=None, tag=None): - u = self._url("/images/create") - params = { - 'fromSrc': '-', - 'repo': repository, - 'tag': tag - } - headers = { - 'Content-Type': 'application/tar', - 'Transfer-Encoding': 'chunked', - } - return self._result( - self._post(u, data=stream, params=params, headers=headers)) - - def import_image_from_url(self, url, repository=None, tag=None): - u = self._url("/images/create") - params = { - 'fromSrc': url, - 'repo': repository, - 'tag': tag - } - return self._result( - self._post(u, data=None, params=params)) - - def import_image_from_image(self, image, repository=None, tag=None): - u = self._url("/images/create") - params = { - 'fromImage': image, - 'repo': repository, - 'tag': tag - } - return self._result( - self._post(u, data=None, params=params)) - - def info(self): - return self._result(self._get(self._url("/info")), - True) - - @check_resource - def insert(self, image, url, path): - if utils.compare_version('1.12', self._version) >= 0: - raise errors.DeprecatedMethod( - 'insert is not available for API version >=1.12' + except Exception as e: + raise errors.DockerException( + 'Error while fetching server API version: {0}'.format(e) ) - api_url = self._url("/images/{0}/insert".format(image)) - params = { - 'url': url, - 'path': path - } - return self._result(self._post(api_url, params=params)) - @check_resource - def inspect_container(self, container): - return self._result( - self._get(self._url("/containers/{0}/json".format(container))), - True) - - @check_resource - def inspect_image(self, image): - return self._result( - self._get( - self._url("/images/{0}/json".format(image.replace('/', '%2F'))) - ), - True - ) - - @check_resource - def kill(self, container, signal=None): - url = self._url("/containers/{0}/kill".format(container)) - params = {} - if signal is not None: - params['signal'] = signal - res = self._post(url, params=params) - - self._raise_for_status(res) - - def load_image(self, data): - res = self._post(self._url("/images/load"), data=data) - self._raise_for_status(res) + 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 login(self, username, password=None, email=None, registry=None, - reauth=False, insecure_registry=False, dockercfg_path=None): - if insecure_registry: - warnings.warn( - INSECURE_REGISTRY_DEPRECATION_WARNING.format('login()'), - DeprecationWarning - ) + def _post(self, url, **kwargs): + return self.post(url, **self._set_request_timeout(kwargs)) - # If we don't have any auth data so far, try reloading the config file - # one more time in case anything showed up in there. - # If dockercfg_path is passed check to see if the config file exists, - # if so load that config. - if dockercfg_path and os.path.exists(dockercfg_path): - self._auth_configs = auth.load_config(dockercfg_path) - elif not self._auth_configs: - self._auth_configs = auth.load_config() - - registry = registry or auth.INDEX_URL - - authcfg = auth.resolve_authconfig(self._auth_configs, registry) - # If we found an existing auth config for this registry and username - # combination, we can return it immediately unless reauth is requested. - if authcfg and authcfg.get('username', None) == username \ - and not reauth: - return authcfg - - req_data = { - 'username': username, - 'password': password, - 'email': email, - 'serveraddress': registry, - } + def _get(self, url, **kwargs): + return self.get(url, **self._set_request_timeout(kwargs)) - response = self._post_json(self._url('/auth'), data=req_data) - if response.status_code == 200: - self._auth_configs[registry] = req_data - return self._result(response, json=True) + def _put(self, url, **kwargs): + return self.put(url, **self._set_request_timeout(kwargs)) - @check_resource - def logs(self, container, stdout=True, stderr=True, stream=False, - timestamps=False, tail='all'): - if utils.compare_version('1.11', self._version) >= 0: - params = {'stderr': stderr and 1 or 0, - 'stdout': stdout and 1 or 0, - 'timestamps': timestamps and 1 or 0, - 'follow': stream and 1 or 0, - } - if utils.compare_version('1.13', self._version) >= 0: - if tail != 'all' and (not isinstance(tail, int) or tail <= 0): - tail = 'all' - params['tail'] = tail - url = self._url("/containers/{0}/logs".format(container)) - res = self._get(url, params=params, stream=stream) - return self._get_result(container, stream, res) - return self.attach( - container, - stdout=stdout, - stderr=stderr, - stream=stream, - logs=True - ) + def _delete(self, url, **kwargs): + return self.delete(url, **self._set_request_timeout(kwargs)) - @check_resource - def pause(self, container): - url = self._url('/containers/{0}/pause'.format(container)) - res = self._post(url) - self._raise_for_status(res) + def _url(self, pathfmt, *args, **kwargs): + for arg in args: + if not isinstance(arg, six.string_types): + raise ValueError( + 'Expected a string but found {0} ({1}) ' + 'instead'.format(arg, type(arg)) + ) - def ping(self): - return self._result(self._get(self._url('/_ping'))) + args = map(six.moves.urllib.parse.quote_plus, args) - @check_resource - def port(self, container, private_port): - res = self._get(self._url("/containers/{0}/json".format(container))) - self._raise_for_status(res) - json_ = res.json() - s_port = str(private_port) - h_ports = None - - # Port settings is None when the container is running with - # network_mode=host. - port_settings = json_.get('NetworkSettings', {}).get('Ports') - if port_settings is None: - return None - - h_ports = port_settings.get(s_port + '/udp') - if h_ports is None: - h_ports = port_settings.get(s_port + '/tcp') - - return h_ports - - def pull(self, repository, tag=None, stream=False, - insecure_registry=False, auth_config=None): - if insecure_registry: - warnings.warn( - INSECURE_REGISTRY_DEPRECATION_WARNING.format('pull()'), - DeprecationWarning + if kwargs.get('versioned_api', True): + return '{0}/v{1}{2}'.format( + self.base_url, self._version, pathfmt.format(*args) ) - - if not tag: - repository, tag = utils.parse_repository_tag(repository) - registry, repo_name = auth.resolve_repository_name(repository) - if repo_name.count(":") == 1: - repository, tag = repository.rsplit(":", 1) - - params = { - 'tag': tag, - 'fromImage': repository - } - headers = {} - - if utils.compare_version('1.5', self._version) >= 0: - # If we don't have any auth data so far, try reloading the config - # file one more time in case anything showed up in there. - if auth_config is None: - if not self._auth_configs: - self._auth_configs = auth.load_config() - authcfg = auth.resolve_authconfig(self._auth_configs, registry) - # Do not fail here if no authentication exists for this - # specific registry as we can have a readonly pull. Just - # put the header if we can. - if authcfg: - # auth_config needs to be a dict in the format used by - # auth.py username , password, serveraddress, email - headers['X-Registry-Auth'] = auth.encode_header( - authcfg - ) - 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 - ) - + else: + return '{0}{1}'.format(self.base_url, pathfmt.format(*args)) + + 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: + if e.response.status_code == 404: + raise errors.NotFound(e, response, explanation=explanation) + 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 stream: - return self._stream_helper(response) - - return self._result(response) - - def push(self, repository, tag=None, stream=False, - insecure_registry=False): - if insecure_registry: - warnings.warn( - INSECURE_REGISTRY_DEPRECATION_WARNING.format('push()'), - DeprecationWarning - ) - - if not tag: - repository, tag = utils.parse_repository_tag(repository) - registry, repo_name = auth.resolve_repository_name(repository) - u = self._url("/images/{0}/push".format(repository)) - params = { - 'tag': tag + 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 } - headers = {} - - if utils.compare_version('1.5', self._version) >= 0: - # If we don't have any auth data so far, try reloading the config - # file one more time in case anything showed up in there. - if not self._auth_configs: - self._auth_configs = auth.load_config() - authcfg = auth.resolve_authconfig(self._auth_configs, registry) - - # Do not fail here if no authentication exists for this specific - # registry as we can have a readonly pull. Just put the header if - # we can. - if authcfg: - headers['X-Registry-Auth'] = auth.encode_header(authcfg) - - response = self._post_json( - u, None, headers=headers, stream=stream, params=params - ) + @check_resource + def _attach_websocket(self, container, params=None): + url = self._url("/containers/{0}/attach/ws", 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: + continue + 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 - if stream: - return self._stream_helper(response) - - return self._result(response) - - @check_resource - def remove_container(self, container, v=False, link=False, force=False): - params = {'v': v, 'link': link, 'force': force} - res = self._delete(self._url("/containers/" + container), - params=params) - self._raise_for_status(res) + def _get_result(self, container, stream, res): + cont = self.inspect_container(container) + return self._get_result_tty(stream, res, cont['Config']['Tty']) - @check_resource - def remove_image(self, image, force=False, noprune=False): - params = {'force': force, 'noprune': noprune} - res = self._delete(self._url("/images/" + image), params=params) - self._raise_for_status(res) + 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) - @check_resource - def rename(self, container, name): - if utils.compare_version('1.17', self._version) < 0: - raise errors.InvalidVersion( - 'rename was only introduced in API version 1.17' - ) - url = self._url("/containers/{0}/rename".format(container)) - params = {'name': name} - res = self._post(url, params=params) - self._raise_for_status(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) - @check_resource - def resize(self, container, height, width): - params = {'h': height, 'w': width} - url = self._url("/containers/{0}/resize".format(container)) - res = self._post(url, params=params) self._raise_for_status(res) - - @check_resource - def restart(self, container, timeout=10): - params = {'t': timeout} - url = self._url("/containers/{0}/restart".format(container)) - res = self._post(url, params=params) - self._raise_for_status(res) - - def search(self, term): - return self._result(self._get(self._url("/images/search"), - params={'term': term}), - True) - - @check_resource - def start(self, container, binds=None, port_bindings=None, lxc_conf=None, - publish_all_ports=False, links=None, privileged=False, - 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): - - if utils.compare_version('1.10', self._version) < 0: - if dns is not None: - raise errors.InvalidVersion( - 'dns is only supported for API version >= 1.10' - ) - if volumes_from is not None: - raise errors.InvalidVersion( - 'volumes_from is only supported for API version >= 1.10' - ) - - if utils.compare_version('1.15', self._version) < 0: - if security_opt is not None: - raise errors.InvalidVersion( - 'security_opt is only supported for API version >= 1.15' - ) - if ipc_mode: - raise errors.InvalidVersion( - 'ipc_mode is only supported for API version >= 1.15' - ) - - if utils.compare_version('1.17', self._version) < 0: - if read_only is not None: - raise errors.InvalidVersion( - 'read_only is only supported for API version >= 1.17' - ) - if pid_mode is not None: - raise errors.InvalidVersion( - 'pid_mode is only supported for API version >= 1.17' - ) - - if utils.compare_version('1.18', self._version) < 0: - if ulimits is not None: - raise errors.InvalidVersion( - 'ulimits is only supported for API version >= 1.18' - ) - - start_config = utils.create_host_config( - binds=binds, port_bindings=port_bindings, lxc_conf=lxc_conf, - publish_all_ports=publish_all_ports, links=links, dns=dns, - privileged=privileged, dns_search=dns_search, cap_add=cap_add, - cap_drop=cap_drop, volumes_from=volumes_from, devices=devices, - network_mode=network_mode, restart_policy=restart_policy, - extra_hosts=extra_hosts, read_only=read_only, pid_mode=pid_mode, - ipc_mode=ipc_mode, security_opt=security_opt, ulimits=ulimits - ) - - url = self._url("/containers/{0}/start".format(container)) - if not start_config: - start_config = None - elif utils.compare_version('1.15', self._version) > 0: - warnings.warn( - 'Passing host config parameters in start() is deprecated. ' - 'Please use host_config in create_container instead!', - DeprecationWarning + 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)] ) - res = self._post_json(url, data=start_config) - self._raise_for_status(res) - @check_resource - def stats(self, container, decode=None): - if utils.compare_version('1.17', self._version) < 0: - raise errors.InvalidVersion( - 'Stats retrieval is not supported in API < 1.17!') - - 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): - params = {'t': timeout} - url = self._url("/containers/{0}/stop".format(container)) - - res = self._post(url, params=params, - timeout=(timeout + (self.timeout or 0))) - self._raise_for_status(res) - - @check_resource - def tag(self, image, repository, tag=None, force=False): - params = { - 'tag': tag, - 'repo': repository, - 'force': 1 if force else 0 - } - url = self._url("/images/{0}/tag".format(image)) - res = self._post(url, params=params) - self._raise_for_status(res) - return res.status_code == 201 - - @check_resource - def top(self, container): - u = self._url("/containers/{0}/top".format(container)) - return self._result(self._get(u), True) - - def version(self, api_version=True): - url = self._url("/version", versioned_api=api_version) - return self._result(self._get(url), json=True) - - @check_resource - def unpause(self, container): - url = self._url('/containers/{0}/unpause'.format(container)) - res = self._post(url) - self._raise_for_status(res) + def get_adapter(self, url): + try: + return super(Client, self).get_adapter(url) + except requests.exceptions.InvalidSchema as e: + if self._custom_adapter: + return self._custom_adapter + else: + raise e - @check_resource - def wait(self, container, timeout=None): - url = self._url("/containers/{0}/wait".format(container)) - res = self._post(url, timeout=timeout) - self._raise_for_status(res) - json_ = res.json() - if 'StatusCode' in json_: - return json_['StatusCode'] - return -1 + @property + def api_version(self): + return self._version class AutoVersionClient(Client): diff --git a/docker/clientbase.py b/docker/clientbase.py deleted file mode 100644 index ce52ffa..0000000 --- a/docker/clientbase.py +++ /dev/null @@ -1,277 +0,0 @@ -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: - if e.response.status_code == 404: - raise errors.NotFound(e, response, explanation=explanation) - 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: - continue - 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 10a2fee..3647a3b 100644 --- a/docker/constants.py +++ b/docker/constants.py @@ -1,4 +1,4 @@ -DEFAULT_DOCKER_API_VERSION = '1.19' +DEFAULT_DOCKER_API_VERSION = '1.20' DEFAULT_TIMEOUT_SECONDS = 60 STREAM_HEADER_SIZE_BYTES = 8 CONTAINER_LIMITS_KEYS = [ diff --git a/docker/utils/__init__.py b/docker/utils/__init__.py index 81cc8a6..92e03e9 100644 --- a/docker/utils/__init__.py +++ b/docker/utils/__init__.py @@ -1,9 +1,10 @@ from .utils import ( compare_version, convert_port_bindings, convert_volume_binds, - mkbuildcontext, tar, parse_repository_tag, parse_host, + mkbuildcontext, tar, exclude_paths, parse_repository_tag, parse_host, kwargs_from_env, convert_filters, create_host_config, - create_container_config, parse_bytes, ping_registry + create_container_config, parse_bytes, ping_registry, parse_env_file, + version_lt, version_gte, decode_json_header ) # flake8: noqa from .types import Ulimit, LogConfig # flake8: noqa -from .decorators import check_resource #flake8: noqa +from .decorators import check_resource, minimum_version #flake8: noqa diff --git a/docker/utils/decorators.py b/docker/utils/decorators.py index 3c42fe4..7d3b01a 100644 --- a/docker/utils/decorators.py +++ b/docker/utils/decorators.py @@ -1,6 +1,7 @@ import functools from .. import errors +from . import utils def check_resource(f): @@ -19,3 +20,18 @@ def check_resource(f): ) return f(self, resource_id, *args, **kwargs) return wrapped + + +def minimum_version(version): + def decorator(f): + @functools.wraps(f) + def wrapper(self, *args, **kwargs): + if utils.version_lt(self._version, version): + raise errors.InvalidVersion( + '{0} is not available for version < {1}'.format( + f.__name__, version + ) + ) + return f(self, *args, **kwargs) + return wrapper + return decorator diff --git a/docker/utils/ports/ports.py b/docker/utils/ports/ports.py index 6a0a862..326ef94 100644 --- a/docker/utils/ports/ports.py +++ b/docker/utils/ports/ports.py @@ -1,5 +1,4 @@ - def add_port_mapping(port_bindings, internal_port, external): if internal_port in port_bindings: port_bindings[internal_port].append(external) @@ -33,9 +32,8 @@ def to_port_range(port): if "/" in port: parts = port.split("/") if len(parts) != 2: - raise ValueError('Invalid port "%s", should be ' - '[[remote_ip:]remote_port[-remote_port]:]' - 'port[/protocol]' % port) + _raise_invalid_port(port) + port, protocol = parts protocol = "/" + protocol @@ -52,11 +50,17 @@ def to_port_range(port): 'port or startport-endport' % port) +def _raise_invalid_port(port): + raise ValueError('Invalid port "%s", should be ' + '[[remote_ip:]remote_port[-remote_port]:]' + 'port[/protocol]' % port) + + def split_port(port): parts = str(port).split(':') + if not 1 <= len(parts) <= 3: - raise ValueError('Invalid port "%s", should be ' - '[[remote_ip:]remote_port:]port[/protocol]' % port) + _raise_invalid_port(port) if len(parts) == 1: internal_port, = parts @@ -66,6 +70,10 @@ def split_port(port): internal_range = to_port_range(internal_port) external_range = to_port_range(external_port) + + if internal_range is None or external_range is None: + _raise_invalid_port(port) + if len(internal_range) != len(external_range): raise ValueError('Port ranges don\'t match in length') diff --git a/docker/utils/types.py b/docker/utils/types.py index d742fd0..ea9f06d 100644 --- a/docker/utils/types.py +++ b/docker/utils/types.py @@ -5,9 +5,12 @@ class LogConfigTypesEnum(object): _values = ( 'json-file', 'syslog', + 'journald', + 'gelf', + 'fluentd', 'none' ) - JSON, SYSLOG, NONE = _values + JSON, SYSLOG, JOURNALD, GELF, FLUENTD, NONE = _values class DictType(dict): @@ -20,18 +23,15 @@ class LogConfig(DictType): types = LogConfigTypesEnum def __init__(self, **kwargs): - type_ = kwargs.get('type', kwargs.get('Type')) - config = kwargs.get('config', kwargs.get('Config')) - if type_ not in self.types._values: - raise ValueError("LogConfig.type must be one of ({0})".format( - ', '.join(self.types._values) - )) + log_driver_type = kwargs.get('type', kwargs.get('Type')) + config = kwargs.get('config', kwargs.get('Config')) or {} + if config and not isinstance(config, dict): raise ValueError("LogConfig.config must be a dictionary") super(LogConfig, self).__init__({ - 'Type': type_, - 'Config': config or {} + 'Type': log_driver_type, + 'Config': config }) @property @@ -40,10 +40,6 @@ class LogConfig(DictType): @type.setter def type(self, value): - if value not in self.types._values: - raise ValueError("LogConfig.type must be one of {0}".format( - ', '.join(self.types._values) - )) self['Type'] = value @property diff --git a/docker/utils/utils.py b/docker/utils/utils.py index a714c97..89837b7 100644 --- a/docker/utils/utils.py +++ b/docker/utils/utils.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import base64 import io import os import os.path @@ -27,6 +28,7 @@ from datetime import datetime import requests import six +from .. import constants from .. import errors from .. import tls from .types import Ulimit, LogConfig @@ -65,39 +67,89 @@ def mkbuildcontext(dockerfile): return f -def fnmatch_any(relpath, patterns): - return any([fnmatch(relpath, pattern) for pattern in patterns]) +def decode_json_header(header): + data = base64.b64decode(header) + if six.PY3: + data = data.decode('utf-8') + return json.loads(data) -def tar(path, exclude=None): +def tar(path, exclude=None, dockerfile=None): f = tempfile.NamedTemporaryFile() t = tarfile.open(mode='w', fileobj=f) - for dirpath, dirnames, filenames in os.walk(path): - relpath = os.path.relpath(dirpath, path) - if relpath == '.': - relpath = '' - if exclude is None: - fnames = filenames - else: - dirnames[:] = [d for d in dirnames - if not fnmatch_any(os.path.join(relpath, d), - exclude)] - fnames = [name for name in filenames - if not fnmatch_any(os.path.join(relpath, name), - exclude)] - dirnames.sort() - for name in sorted(fnames): - arcname = os.path.join(relpath, name) - t.add(os.path.join(path, arcname), arcname=arcname) - for name in dirnames: - arcname = os.path.join(relpath, name) - t.add(os.path.join(path, arcname), - arcname=arcname, recursive=False) + + root = os.path.abspath(path) + exclude = exclude or [] + + for path in sorted(exclude_paths(root, exclude, dockerfile=dockerfile)): + t.add(os.path.join(root, path), arcname=path, recursive=False) + t.close() f.seek(0) return f +def exclude_paths(root, patterns, dockerfile=None): + """ + Given a root directory path and a list of .dockerignore patterns, return + an iterator of all paths (both regular files and directories) in the root + directory that do *not* match any of the patterns. + + All paths returned are relative to the root. + """ + if dockerfile is None: + dockerfile = 'Dockerfile' + + exceptions = [p for p in patterns if p.startswith('!')] + + include_patterns = [p[1:] for p in exceptions] + include_patterns += [dockerfile, '.dockerignore'] + + exclude_patterns = list(set(patterns) - set(exceptions)) + + all_paths = get_paths(root) + + # Remove all paths that are matched by any exclusion pattern + paths = [ + p for p in all_paths + if not any(match_path(p, pattern) for pattern in exclude_patterns) + ] + + # Add back the set of paths that are matched by any inclusion pattern. + # Include parent dirs - if we add back 'foo/bar', add 'foo' as well + for p in all_paths: + if any(match_path(p, pattern) for pattern in include_patterns): + components = p.split('/') + paths += [ + '/'.join(components[:end]) + for end in range(1, len(components) + 1) + ] + + return set(paths) + + +def get_paths(root): + paths = [] + + for parent, dirs, files in os.walk(root, followlinks=False): + parent = os.path.relpath(parent, root) + if parent == '.': + parent = '' + for path in dirs: + paths.append(os.path.join(parent, path)) + for path in files: + paths.append(os.path.join(parent, path)) + + return paths + + +def match_path(path, pattern): + pattern = pattern.rstrip('/') + pattern_components = pattern.split('/') + path_components = path.split('/')[:len(pattern_components)] + return fnmatch('/'.join(path_components), pattern) + + def compare_version(v1, v2): """Compare docker versions @@ -120,6 +172,14 @@ def compare_version(v1, v2): return 1 +def version_lt(v1, v2): + return compare_version(v1, v2) > 0 + + +def version_gte(v1, v2): + return not version_lt(v1, v2) + + def ping_registry(url): warnings.warn( 'The `ping_registry` method is deprecated and will be removed.', @@ -190,6 +250,9 @@ def convert_volume_binds(binds): result = [] for k, v in binds.items(): + if isinstance(k, six.binary_type): + k = k.decode('utf-8') + if isinstance(v, dict): if 'ro' in v and 'mode' in v: raise ValueError( @@ -197,6 +260,10 @@ def convert_volume_binds(binds): .format(repr(v)) ) + bind = v['bind'] + if isinstance(bind, six.binary_type): + bind = bind.decode('utf-8') + if 'ro' in v: mode = 'ro' if v['ro'] else 'rw' elif 'mode' in v: @@ -204,11 +271,15 @@ def convert_volume_binds(binds): else: mode = 'rw' - result.append('{0}:{1}:{2}'.format( - k, v['bind'], mode - )) + result.append( + six.text_type('{0}:{1}:{2}').format(k, bind, mode) + ) else: - result.append('{0}:{1}:rw'.format(k, v)) + if isinstance(v, six.binary_type): + v = v.decode('utf-8') + result.append( + six.text_type('{0}:{1}:rw').format(k, v) + ) return result @@ -228,10 +299,15 @@ def parse_repository_tag(repo): # fd:// protocol unsupported (for obvious reasons) # Added support for http and https # Protocol translation: tcp -> http, unix -> http+unix -def parse_host(addr): +def parse_host(addr, platform=None): proto = "http+unix" host = DEFAULT_HTTP_HOST port = None + path = '' + + if not addr and platform == 'win32': + addr = '{0}:{1}'.format(DEFAULT_HTTP_HOST, 2375) + if not addr or addr.strip() == 'unix://': return DEFAULT_UNIX_SOCKET @@ -270,8 +346,12 @@ def parse_host(addr): if host_parts[0]: host = host_parts[0] + port = host_parts[1] + if '/' in port: + port, path = port.split('/', 1) + path = '/{0}'.format(path) try: - port = int(host_parts[1]) + port = int(port) except Exception: raise errors.DockerException( "Invalid port: %s", addr @@ -285,7 +365,7 @@ def parse_host(addr): if proto == "http+unix": return "{0}://{1}".format(proto, host) - return "{0}://{1}:{2}".format(proto, host, port) + return "{0}://{1}:{2}{3}".format(proto, host, port, path) def parse_devices(devices): @@ -314,9 +394,14 @@ def kwargs_from_env(ssl_version=None, assert_hostname=None): tls_verify = os.environ.get('DOCKER_TLS_VERIFY') params = {} + if host: params['base_url'] = (host.replace('tcp://', 'https://') if tls_verify else host) + + if tls_verify and not cert_path: + cert_path = os.path.join(os.path.expanduser('~'), '.docker') + if tls_verify and cert_path: params['tls'] = tls.TLSConfig( client_cert=(os.path.join(cert_path, 'cert.pem'), @@ -325,6 +410,7 @@ def kwargs_from_env(ssl_version=None, assert_hostname=None): verify=True, ssl_version=ssl_version, assert_hostname=assert_hostname) + return params @@ -390,10 +476,18 @@ def create_host_config( 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, mem_limit=None, - memswap_limit=None + memswap_limit=None, cgroup_parent=None, group_add=None, cpu_quota=None, + cpu_period=None, version=None ): host_config = {} + if not version: + warnings.warn( + 'docker.utils.create_host_config() is deprecated. Please use ' + 'Client.create_host_config() instead.' + ) + version = constants.DEFAULT_DOCKER_API_VERSION + if mem_limit is not None: if isinstance(mem_limit, six.string_types): mem_limit = parse_bytes(mem_limit) @@ -428,6 +522,8 @@ def create_host_config( if network_mode: host_config['NetworkMode'] = network_mode + elif network_mode is None and compare_version('1.19', version) > 0: + host_config['NetworkMode'] = 'default' if restart_policy: host_config['RestartPolicy'] = restart_policy @@ -441,6 +537,13 @@ def create_host_config( if devices: host_config['Devices'] = parse_devices(devices) + if group_add: + if version_lt(version, '1.20'): + raise errors.InvalidVersion( + 'group_add param not supported for API version < 1.20' + ) + host_config['GroupAdd'] = [six.text_type(grp) for grp in group_add] + if dns is not None: host_config['Dns'] = dns @@ -493,6 +596,9 @@ def create_host_config( if lxc_conf is not None: host_config['LxcConf'] = lxc_conf + if cgroup_parent is not None: + host_config['CgroupParent'] = cgroup_parent + if ulimits is not None: if not isinstance(ulimits, list): raise errors.DockerException( @@ -515,9 +621,58 @@ def create_host_config( log_config = LogConfig(**log_config) host_config['LogConfig'] = log_config + if cpu_quota: + if not isinstance(cpu_quota, int): + raise TypeError( + 'Invalid type for cpu_quota param: expected int but' + ' found {0}'.format(type(cpu_quota)) + ) + if version_lt(version, '1.19'): + raise errors.InvalidVersion( + 'cpu_quota param not supported for API version < 1.19' + ) + host_config['CpuQuota'] = cpu_quota + + if cpu_period: + if not isinstance(cpu_period, int): + raise TypeError( + 'Invalid type for cpu_period param: expected int but' + ' found {0}'.format(type(cpu_period)) + ) + if version_lt(version, '1.19'): + raise errors.InvalidVersion( + 'cpu_period param not supported for API version < 1.19' + ) + host_config['CpuPeriod'] = cpu_period + return host_config +def parse_env_file(env_file): + """ + Reads a line-separated environment file. + The format of each line should be "key=value". + """ + environment = {} + + with open(env_file, 'r') as f: + for line in f: + + if line[0] == '#': + continue + + parse_line = line.strip().split('=') + if len(parse_line) == 2: + k, v = parse_line + environment[k] = v + else: + raise errors.DockerException( + 'Invalid line in environment file {0}:\n{1}'.format( + env_file, line)) + + return environment + + def create_container_config( version, image, command, hostname=None, user=None, detach=False, stdin_open=False, tty=False, mem_limit=None, ports=None, environment=None, @@ -528,6 +683,10 @@ def create_container_config( ): if isinstance(command, six.string_types): command = shlex.split(str(command)) + + if isinstance(entrypoint, six.string_types): + entrypoint = shlex.split(str(entrypoint)) + if isinstance(environment, dict): environment = [ six.text_type('{0}={1}').format(k, v) @@ -619,7 +778,7 @@ def create_container_config( 'Hostname': hostname, 'Domainname': domainname, 'ExposedPorts': ports, - 'User': user, + 'User': six.text_type(user) if user else None, 'Tty': tty, 'OpenStdin': stdin_open, 'StdinOnce': stdin_once, diff --git a/docker/version.py b/docker/version.py index bc778b9..4ebafdd 100644 --- a/docker/version.py +++ b/docker/version.py @@ -1,2 +1,2 @@ -version = "1.3.1" +version = "1.5.0" version_info = tuple([int(d) for d in version.split("-")[0].split(".")]) |