# # Copyright 2016 Red Hat | Ansible # # This file is part of Ansible # # Ansible is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Ansible is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Ansible. If not, see . import os import re import json import sys import copy from distutils.version import LooseVersion from ansible.module_utils.basic import AnsibleModule, env_fallback from ansible.module_utils.common._collections_compat import Mapping, Sequence from ansible.module_utils.six.moves.urllib.parse import urlparse from ansible.module_utils.parsing.convert_bool import BOOLEANS_TRUE, BOOLEANS_FALSE HAS_DOCKER_PY = True HAS_DOCKER_PY_2 = False HAS_DOCKER_PY_3 = False HAS_DOCKER_ERROR = None try: from requests.exceptions import SSLError from docker import __version__ as docker_version from docker.errors import APIError, NotFound, TLSParameterError from docker.tls import TLSConfig from docker.constants import DEFAULT_DOCKER_API_VERSION from docker import auth if LooseVersion(docker_version) >= LooseVersion('3.0.0'): HAS_DOCKER_PY_3 = True from docker import APIClient as Client from docker.types import Ulimit, LogConfig elif LooseVersion(docker_version) >= LooseVersion('2.0.0'): HAS_DOCKER_PY_2 = True from docker import APIClient as Client from docker.types import Ulimit, LogConfig else: from docker import Client from docker.utils.types import Ulimit, LogConfig except ImportError as exc: HAS_DOCKER_ERROR = str(exc) HAS_DOCKER_PY = False # The next 2 imports ``docker.models`` and ``docker.ssladapter`` are used # to ensure the user does not have both ``docker`` and ``docker-py`` modules # installed, as they utilize the same namespace are are incompatible try: # docker import docker.models HAS_DOCKER_MODELS = True except ImportError: HAS_DOCKER_MODELS = False try: # docker-py import docker.ssladapter HAS_DOCKER_SSLADAPTER = True except ImportError: HAS_DOCKER_SSLADAPTER = False DEFAULT_DOCKER_HOST = 'unix://var/run/docker.sock' DEFAULT_TLS = False DEFAULT_TLS_VERIFY = False DEFAULT_TLS_HOSTNAME = 'localhost' MIN_DOCKER_VERSION = "1.7.0" DEFAULT_TIMEOUT_SECONDS = 60 DOCKER_COMMON_ARGS = dict( docker_host=dict(type='str', aliases=['docker_url'], default=DEFAULT_DOCKER_HOST, fallback=(env_fallback, ['DOCKER_HOST'])), tls_hostname=dict(type='str', default=DEFAULT_TLS_HOSTNAME, fallback=(env_fallback, ['DOCKER_TLS_HOSTNAME'])), api_version=dict(type='str', aliases=['docker_api_version'], default='auto', fallback=(env_fallback, ['DOCKER_API_VERSION'])), timeout=dict(type='int', default=DEFAULT_TIMEOUT_SECONDS, fallback=(env_fallback, ['DOCKER_TIMEOUT'])), cacert_path=dict(type='str', aliases=['tls_ca_cert']), cert_path=dict(type='str', aliases=['tls_client_cert']), key_path=dict(type='str', aliases=['tls_client_key']), ssl_version=dict(type='str', fallback=(env_fallback, ['DOCKER_SSL_VERSION'])), tls=dict(type='bool', default=DEFAULT_TLS, fallback=(env_fallback, ['DOCKER_TLS'])), tls_verify=dict(type='bool', default=DEFAULT_TLS_VERIFY, fallback=(env_fallback, ['DOCKER_TLS_VERIFY'])), debug=dict(type='bool', default=False) ) DOCKER_MUTUALLY_EXCLUSIVE = [ ['tls', 'tls_verify'] ] DOCKER_REQUIRED_TOGETHER = [ ['cert_path', 'key_path'] ] DEFAULT_DOCKER_REGISTRY = 'https://index.docker.io/v1/' EMAIL_REGEX = r'[^@]+@[^@]+\.[^@]+' BYTE_SUFFIXES = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] if not HAS_DOCKER_PY: docker_version = None # No docker-py. Create a place holder client to allow # instantiation of AnsibleModule and proper error handing class Client(object): def __init__(self, **kwargs): pass class APIError(Exception): pass class NotFound(Exception): # noqa: F811 pass def sanitize_result(data): """Sanitize data object for return to Ansible. When the data object contains types such as docker.types.containers.HostConfig, Ansible will fail when these are returned via exit_json or fail_json. HostConfig is derived from dict, but its constructor requires additional arguments. This function sanitizes data structures by recursively converting everything derived from dict to dict and everything derived from list (and tuple) to a list. """ if isinstance(data, dict): return dict((k, sanitize_result(v)) for k, v in data.items()) elif isinstance(data, (list, tuple)): return [sanitize_result(v) for v in data] else: return data class DockerBaseClass(object): def __init__(self): self.debug = False def log(self, msg, pretty_print=False): pass # if self.debug: # log_file = open('docker.log', 'a') # if pretty_print: # log_file.write(json.dumps(msg, sort_keys=True, indent=4, separators=(',', ': '))) # log_file.write(u'\n') # else: # log_file.write(msg + u'\n') class AnsibleDockerClient(Client): def __init__(self, argument_spec=None, supports_check_mode=False, mutually_exclusive=None, required_together=None, required_if=None, min_docker_version=MIN_DOCKER_VERSION, min_docker_api_version=None, option_minimal_versions=None, option_minimal_versions_ignore_params=None): merged_arg_spec = dict() merged_arg_spec.update(DOCKER_COMMON_ARGS) if argument_spec: merged_arg_spec.update(argument_spec) self.arg_spec = merged_arg_spec mutually_exclusive_params = [] mutually_exclusive_params += DOCKER_MUTUALLY_EXCLUSIVE if mutually_exclusive: mutually_exclusive_params += mutually_exclusive required_together_params = [] required_together_params += DOCKER_REQUIRED_TOGETHER if required_together: required_together_params += required_together self.module = AnsibleModule( argument_spec=merged_arg_spec, supports_check_mode=supports_check_mode, mutually_exclusive=mutually_exclusive_params, required_together=required_together_params, required_if=required_if) NEEDS_DOCKER_PY2 = (LooseVersion(min_docker_version) >= LooseVersion('2.0.0')) self.docker_py_version = LooseVersion(docker_version) if HAS_DOCKER_MODELS and HAS_DOCKER_SSLADAPTER: self.fail("Cannot have both the docker-py and docker python modules installed together as they use the same namespace and " "cause a corrupt installation. Please uninstall both packages, and re-install only the docker-py or docker python " "module. It is recommended to install the docker module if no support for Python 2.6 is required. " "Please note that simply uninstalling one of the modules can leave the other module in a broken state.") if not HAS_DOCKER_PY: if NEEDS_DOCKER_PY2: msg = "Failed to import docker - %s. Try `pip install docker`" else: msg = "Failed to import docker or docker-py - %s. Try `pip install docker` or `pip install docker-py` (Python 2.6)" self.fail(msg % HAS_DOCKER_ERROR) if self.docker_py_version < LooseVersion(min_docker_version): if NEEDS_DOCKER_PY2: if docker_version < LooseVersion('2.0'): msg = "Error: docker-py version is %s, while this module requires docker %s. Try `pip uninstall docker-py` and then `pip install docker`" else: msg = "Error: docker version is %s. Minimum version required is %s. Use `pip install --upgrade docker` to upgrade." else: # The minimal required version is < 2.0 (and the current version as well). # Advertise docker (instead of docker-py) for non-Python-2.6 users. msg = ("Error: docker / docker-py version is %s. Minimum version required is %s. " "Hint: if you do not need Python 2.6 support, try `pip uninstall docker-py` followed by `pip install docker`") self.fail(msg % (docker_version, min_docker_version)) self.debug = self.module.params.get('debug') self.check_mode = self.module.check_mode self._connect_params = self._get_connect_params() try: super(AnsibleDockerClient, self).__init__(**self._connect_params) except APIError as exc: self.fail("Docker API error: %s" % exc) except Exception as exc: self.fail("Error connecting: %s" % exc) if min_docker_api_version is not None: self.docker_api_version_str = self.version()['ApiVersion'] self.docker_api_version = LooseVersion(self.docker_api_version_str) if self.docker_api_version < LooseVersion(min_docker_api_version): self.fail('docker API version is %s. Minimum version required is %s.' % (self.docker_api_version_str, min_docker_api_version)) if option_minimal_versions is not None: self._get_minimal_versions(option_minimal_versions, option_minimal_versions_ignore_params) def log(self, msg, pretty_print=False): pass # if self.debug: # log_file = open('docker.log', 'a') # if pretty_print: # log_file.write(json.dumps(msg, sort_keys=True, indent=4, separators=(',', ': '))) # log_file.write(u'\n') # else: # log_file.write(msg + u'\n') def fail(self, msg): self.module.fail_json(msg=msg) @staticmethod def _get_value(param_name, param_value, env_variable, default_value): if param_value is not None: # take module parameter value if param_value in BOOLEANS_TRUE: return True if param_value in BOOLEANS_FALSE: return False return param_value if env_variable is not None: env_value = os.environ.get(env_variable) if env_value is not None: # take the env variable value if param_name == 'cert_path': return os.path.join(env_value, 'cert.pem') if param_name == 'cacert_path': return os.path.join(env_value, 'ca.pem') if param_name == 'key_path': return os.path.join(env_value, 'key.pem') if env_value in BOOLEANS_TRUE: return True if env_value in BOOLEANS_FALSE: return False return env_value # take the default return default_value @property def auth_params(self): # Get authentication credentials. # Precedence: module parameters-> environment variables-> defaults. self.log('Getting credentials') params = dict() for key in DOCKER_COMMON_ARGS: params[key] = self.module.params.get(key) if self.module.params.get('use_tls'): # support use_tls option in docker_image.py. This will be deprecated. use_tls = self.module.params.get('use_tls') if use_tls == 'encrypt': params['tls'] = True if use_tls == 'verify': params['tls_verify'] = True result = dict( docker_host=self._get_value('docker_host', params['docker_host'], 'DOCKER_HOST', DEFAULT_DOCKER_HOST), tls_hostname=self._get_value('tls_hostname', params['tls_hostname'], 'DOCKER_TLS_HOSTNAME', DEFAULT_TLS_HOSTNAME), api_version=self._get_value('api_version', params['api_version'], 'DOCKER_API_VERSION', 'auto'), cacert_path=self._get_value('cacert_path', params['cacert_path'], 'DOCKER_CERT_PATH', None), cert_path=self._get_value('cert_path', params['cert_path'], 'DOCKER_CERT_PATH', None), key_path=self._get_value('key_path', params['key_path'], 'DOCKER_CERT_PATH', None), ssl_version=self._get_value('ssl_version', params['ssl_version'], 'DOCKER_SSL_VERSION', None), tls=self._get_value('tls', params['tls'], 'DOCKER_TLS', DEFAULT_TLS), tls_verify=self._get_value('tls_verfy', params['tls_verify'], 'DOCKER_TLS_VERIFY', DEFAULT_TLS_VERIFY), timeout=self._get_value('timeout', params['timeout'], 'DOCKER_TIMEOUT', DEFAULT_TIMEOUT_SECONDS), ) if result['tls_hostname'] is None: # get default machine name from the url parsed_url = urlparse(result['docker_host']) if ':' in parsed_url.netloc: result['tls_hostname'] = parsed_url.netloc[:parsed_url.netloc.rindex(':')] else: result['tls_hostname'] = parsed_url return result def _get_tls_config(self, **kwargs): self.log("get_tls_config:") for key in kwargs: self.log(" %s: %s" % (key, kwargs[key])) try: tls_config = TLSConfig(**kwargs) return tls_config except TLSParameterError as exc: self.fail("TLS config error: %s" % exc) def _get_connect_params(self): auth = self.auth_params self.log("connection params:") for key in auth: self.log(" %s: %s" % (key, auth[key])) if auth['tls'] or auth['tls_verify']: auth['docker_host'] = auth['docker_host'].replace('tcp://', 'https://') if auth['tls'] and auth['cert_path'] and auth['key_path']: # TLS with certs and no host verification tls_config = self._get_tls_config(client_cert=(auth['cert_path'], auth['key_path']), verify=False, ssl_version=auth['ssl_version']) return dict(base_url=auth['docker_host'], tls=tls_config, version=auth['api_version'], timeout=auth['timeout']) if auth['tls']: # TLS with no certs and not host verification tls_config = self._get_tls_config(verify=False, ssl_version=auth['ssl_version']) return dict(base_url=auth['docker_host'], tls=tls_config, version=auth['api_version'], timeout=auth['timeout']) if auth['tls_verify'] and auth['cert_path'] and auth['key_path']: # TLS with certs and host verification if auth['cacert_path']: tls_config = self._get_tls_config(client_cert=(auth['cert_path'], auth['key_path']), ca_cert=auth['cacert_path'], verify=True, assert_hostname=auth['tls_hostname'], ssl_version=auth['ssl_version']) else: tls_config = self._get_tls_config(client_cert=(auth['cert_path'], auth['key_path']), verify=True, assert_hostname=auth['tls_hostname'], ssl_version=auth['ssl_version']) return dict(base_url=auth['docker_host'], tls=tls_config, version=auth['api_version'], timeout=auth['timeout']) if auth['tls_verify'] and auth['cacert_path']: # TLS with cacert only tls_config = self._get_tls_config(ca_cert=auth['cacert_path'], assert_hostname=auth['tls_hostname'], verify=True, ssl_version=auth['ssl_version']) return dict(base_url=auth['docker_host'], tls=tls_config, version=auth['api_version'], timeout=auth['timeout']) if auth['tls_verify']: # TLS with verify and no certs tls_config = self._get_tls_config(verify=True, assert_hostname=auth['tls_hostname'], ssl_version=auth['ssl_version']) return dict(base_url=auth['docker_host'], tls=tls_config, version=auth['api_version'], timeout=auth['timeout']) # No TLS return dict(base_url=auth['docker_host'], version=auth['api_version'], timeout=auth['timeout']) def _handle_ssl_error(self, error): match = re.match(r"hostname.*doesn\'t match (\'.*\')", str(error)) if match: self.fail("You asked for verification that Docker host name matches %s. The actual hostname is %s. " "Most likely you need to set DOCKER_TLS_HOSTNAME or pass tls_hostname with a value of %s. " "You may also use TLS without verification by setting the tls parameter to true." % (self.auth_params['tls_hostname'], match.group(1), match.group(1))) self.fail("SSL Exception: %s" % (error)) def _get_minimal_versions(self, option_minimal_versions, ignore_params=None): self.option_minimal_versions = dict() for option in self.module.argument_spec: if ignore_params is not None: if option in ignore_params: continue self.option_minimal_versions[option] = dict() self.option_minimal_versions.update(option_minimal_versions) for option, data in self.option_minimal_versions.items(): # Test whether option is supported, and store result support_docker_py = True support_docker_api = True if 'docker_py_version' in data: support_docker_py = self.docker_py_version >= LooseVersion(data['docker_py_version']) if 'docker_api_version' in data: support_docker_api = self.docker_api_version >= LooseVersion(data['docker_api_version']) data['supported'] = support_docker_py and support_docker_api # Fail if option is not supported but used if not data['supported']: # Test whether option is specified if 'detect_usage' in data: used = data['detect_usage'](self) else: used = self.module.params.get(option) is not None if used and 'default' in self.module.argument_spec[option]: used = self.module.params[option] != self.module.argument_spec[option]['default'] if used: # If the option is used, compose error message. if 'usage_msg' in data: usg = data['usage_msg'] else: usg = 'set %s option' % (option, ) if not support_docker_api: msg = 'docker API version is %s. Minimum version required is %s to %s.' msg = msg % (self.docker_api_version_str, data['docker_api_version'], usg) elif not support_docker_py: if LooseVersion(data['docker_py_version']) < LooseVersion('2.0.0'): msg = ("docker-py version is %s. Minimum version required is %s to %s. " "Consider switching to the 'docker' package if you do not require Python 2.6 support.") elif self.docker_py_version < LooseVersion('2.0.0'): msg = ("docker-py version is %s. Minimum version required is %s to %s. " "You have to switch to the Python 'docker' package. First uninstall 'docker-py' before " "installing 'docker' to avoid a broken installation.") else: msg = "docker version is %s. Minimum version required is %s to %s." msg = msg % (docker_version, data['docker_py_version'], usg) else: # should not happen msg = 'Cannot %s with your configuration.' % (usg, ) self.fail(msg) def get_container(self, name=None): ''' Lookup a container and return the inspection results. ''' if name is None: return None search_name = name if not name.startswith('/'): search_name = '/' + name result = None try: for container in self.containers(all=True): self.log("testing container: %s" % (container['Names'])) if isinstance(container['Names'], list) and search_name in container['Names']: result = container break if container['Id'].startswith(name): result = container break if container['Id'] == name: result = container break except SSLError as exc: self._handle_ssl_error(exc) except Exception as exc: self.fail("Error retrieving container list: %s" % exc) if result is not None: try: self.log("Inspecting container Id %s" % result['Id']) result = self.inspect_container(container=result['Id']) self.log("Completed container inspection") except NotFound as exc: return None except Exception as exc: self.fail("Error inspecting container: %s" % exc) return result def find_image(self, name, tag): ''' Lookup an image and return the inspection results. ''' if not name: return None self.log("Find image %s:%s" % (name, tag)) images = self._image_lookup(name, tag) if len(images) == 0: # In API <= 1.20 seeing 'docker.io/' as the name of images pulled from docker hub registry, repo_name = auth.resolve_repository_name(name) if registry == 'docker.io': # the name does not contain a registry, so let's see if docker.io works lookup = "docker.io/%s" % name self.log("Check for docker.io image: %s" % lookup) images = self._image_lookup(lookup, tag) if len(images) > 1: self.fail("Registry returned more than one result for %s:%s" % (name, tag)) if len(images) == 1: try: inspection = self.inspect_image(images[0]['Id']) except Exception as exc: self.fail("Error inspecting image %s:%s - %s" % (name, tag, str(exc))) return inspection self.log("Image %s:%s not found." % (name, tag)) return None def _image_lookup(self, name, tag): ''' Including a tag in the name parameter sent to the docker-py images method does not work consistently. Instead, get the result set for name and manually check if the tag exists. ''' try: response = self.images(name=name) except Exception as exc: self.fail("Error searching for image %s - %s" % (name, str(exc))) images = response if tag: lookup = "%s:%s" % (name, tag) images = [] for image in response: tags = image.get('RepoTags') if tags and lookup in tags: images = [image] break return images def pull_image(self, name, tag="latest"): ''' Pull an image ''' self.log("Pulling image %s:%s" % (name, tag)) old_tag = self.find_image(name, tag) try: for line in self.pull(name, tag=tag, stream=True, decode=True): self.log(line, pretty_print=True) if line.get('error'): if line.get('errorDetail'): error_detail = line.get('errorDetail') self.fail("Error pulling %s - code: %s message: %s" % (name, error_detail.get('code'), error_detail.get('message'))) else: self.fail("Error pulling %s - %s" % (name, line.get('error'))) except Exception as exc: self.fail("Error pulling image %s:%s - %s" % (name, tag, str(exc))) new_tag = self.find_image(name, tag) return new_tag, old_tag == new_tag def report_warnings(self, result, warnings_key=None): ''' Checks result of client operation for warnings, and if present, outputs them. ''' if warnings_key is None: warnings_key = ['Warnings'] for key in warnings_key: if not isinstance(result, Mapping): return result = result.get(key) if isinstance(result, Sequence): for warning in result: self.module.warn('Docker warning: {0}'.format(warning)) def compare_dict_allow_more_present(av, bv): ''' Compare two dictionaries for whether every entry of the first is in the second. ''' for key, value in av.items(): if key not in bv: return False if bv[key] != value: return False return True def compare_generic(a, b, method, type): ''' Compare values a and b as described by method and type. Returns ``True`` if the values compare equal, and ``False`` if not. ``a`` is usually the module's parameter, while ``b`` is a property of the current object. ``a`` must not be ``None`` (except for ``type == 'value'``). Valid values for ``method`` are: - ``ignore`` (always compare as equal); - ``strict`` (only compare if really equal) - ``allow_more_present`` (allow b to have elements which a does not have). Valid values for ``type`` are: - ``value``: for simple values (strings, numbers, ...); - ``list``: for ``list``s or ``tuple``s where order matters; - ``set``: for ``list``s, ``tuple``s or ``set``s where order does not matter; - ``set(dict)``: for ``list``s, ``tuple``s or ``sets`` where order does not matter and which contain ``dict``s; ``allow_more_present`` is used for the ``dict``s, and these are assumed to be dictionaries of values; - ``dict``: for dictionaries of values. ''' if method == 'ignore': return True # If a or b is None: if a is None or b is None: # If both are None: equality if a == b: return True # Otherwise, not equal for values, and equal # if the other is empty for set/list/dict if type == 'value': return False # For allow_more_present, allow a to be None if method == 'allow_more_present' and a is None: return True # Otherwise, the iterable object which is not None must have length 0 return len(b if a is None else a) == 0 # Do proper comparison (both objects not None) if type == 'value': return a == b elif type == 'list': if method == 'strict': return a == b else: i = 0 for v in a: while i < len(b) and b[i] != v: i += 1 if i == len(b): return False i += 1 return True elif type == 'dict': if method == 'strict': return a == b else: return compare_dict_allow_more_present(a, b) elif type == 'set': set_a = set(a) set_b = set(b) if method == 'strict': return set_a == set_b else: return set_b >= set_a elif type == 'set(dict)': for av in a: found = False for bv in b: if compare_dict_allow_more_present(av, bv): found = True break if not found: return False if method == 'strict': # If we would know that both a and b do not contain duplicates, # we could simply compare len(a) to len(b) to finish this test. # We can assume that b has no duplicates (as it is returned by # docker), but we don't know for a. for bv in b: found = False for av in a: if compare_dict_allow_more_present(av, bv): found = True break if not found: return False return True def clean_dict_booleans_for_docker_api(data): ''' Go doesn't like Python booleans 'True' or 'False', while Ansible is just fine with them in YAML. As such, they need to be converted in cases where we pass dictionaries to the Docker API (e.g. docker_network's driver_options and docker_prune's filters). ''' result = dict() if data is not None: for k, v in data.items(): if v is True: v = 'true' elif v is False: v = 'false' else: v = str(v) result[str(k)] = v return result