diff --git a/lib/ansible/module_utils/docker_common.py b/lib/ansible/module_utils/docker_common.py new file mode 100644 index 00000000000..ee4211385c5 --- /dev/null +++ b/lib/ansible/module_utils/docker_common.py @@ -0,0 +1,498 @@ +# +# 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 logging +import re +import json +import sys +import copy + +from requests.exceptions import SSLError +from urlparse import urlparse +from logging import Handler, NOTSET +from ansible.module_utils.basic import * + +HAS_DOCKER_PY = True +HAS_DOCKER_ERROR = None + +try: + from docker import Client + from docker import __version__ as docker_version + from docker.errors import APIError, TLSParameterError, NotFound + from docker.tls import TLSConfig + from docker.constants import DEFAULT_TIMEOUT_SECONDS, DEFAULT_DOCKER_API_VERSION + from docker.utils.types import Ulimit, LogConfig +except ImportError as exc: + HAS_DOCKER_ERROR = str(exc) + HAS_DOCKER_PY = False + +DEFAULT_DOCKER_HOST = 'unix://var/run/docker.sock' +DEFAULT_TLS = False +DEFAULT_TLS_VERIFY = False +MIN_DOCKER_VERSION = "1.7.0" + +DOCKER_COMMON_ARGS = dict( + docker_host=dict(type='str', aliases=['docker_url']), + tls_hostname=dict(type='str'), + api_version=dict(type='str', aliases=['docker_api_version']), + timeout=dict(type='int'), + 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'), + tls=dict(type='bool'), + tls_verify=dict(type='bool'), + debug=dict(type='bool', default=False), + filter_logger=dict(type='bool', default=False), + log_path=dict(type='str', default='docker.log'), + log_mode=dict(type='str', choices=['stderr', 'file', 'syslog'], default='syslog'), +) + +DOCKER_MUTUALLY_EXCLUSIVE = [ + ['tls', 'tls_verify'] +] + +DOCKER_REQUIRED_TOGETHER = [ + ['cert_path', 'key_path'] +] + +DEFAULT_DOCKER_REGISTRY = 'https://index.docker.io/v1/' +EMAIL_REGEX = '[^@]+@[^@]+\.[^@]+' +BYTE_SUFFIXES = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] + + +if not HAS_DOCKER_PY: + # 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 + + +def human_to_bytes(number): + if number is None: + return 0 + + if isinstance(number, int): + return number + + if number[-1].isdigit(): + return int(number) + + if number[-1] == BYTE_SUFFIXES[0] and number[-2].isdigit(): + return int(number[:-1]) + + i = 1 + for each in BYTE_SUFFIXES[1:]: + if number[-len(each):] == BYTE_SUFFIXES[i]: + return int(number[:-len(each)]) * (1024 ** i) + i += 1 + + raise ValueError("Failed to convert %s. The suffix must be one of %s" % (number, ','.join(BYTE_SUFFIXES))) + + +def log_msg(data, pretty_print=False): + ''' + Sanitize data to be logged, and if requested, attempt to pretty print JSON. + + :param data: string to dumped to the log + :param pretty_print: bool + :return: None + ''' + out = None + if data: + if isinstance(data, dict): + sanitized = copy.deepcopy(data) + sanitize_dict(sanitized) + elif isinstance(data, list): + sanitized = copy.deepcopy(data) + sanitize_list(sanitized) + else: + sanitized = heuristic_log_sanitize(data) + out = sanitized + if pretty_print: + # Attempt formatting JSON. The sanitizing may have broken things. + try: + out = json.dumps(sanitized, sort_keys=True, indent=4, separators=(',', ': ')) + except: + out = sanitized + return out + + +def sanitize_dict(data): + for key, value in data.items(): + if isinstance(value, basestring): + data[key] = heuristic_log_sanitize(value) + if isinstance(value, dict): + sanitize_dict(value) + if isinstance(value, list): + sanitize_list(value) + + +def sanitize_list(data): + for item in data: + if isinstance(item, basestring): + item = heuristic_log_sanitize(item) + if isinstance(item, list): + sanitize_list(item) + if isinstance(item, dict): + sanitize_dict(item) + +class DockerBaseClass(object): + + def __init__(self): + self.logger = logging.getLogger(self.__class__.__name__) + + def log(self, msg, pretty_print=False): + self.logger.debug(log_msg(msg, pretty_print=pretty_print)) + + +class DockerSysLogHandler(Handler): + + def __init__(self, level=NOTSET, module=None): + self.module = module + super(DockerSysLogHandler, self).__init__(level) + + def emit(self, record): + log_entry = self.format_record + self.module.debug(log_entry) + + +class AnsibleDockerClient(Client): + + def __init__(self, argument_spec=None, supports_check_mode=False, mutually_exclusive=None, + required_together=None, required_if=None): + + self.logger = logging.getLogger(self.__class__.__name__) + + 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) + + if not HAS_DOCKER_PY: + self.fail("Failed to import docker-py - %s. Try `pip install docker-py`" % HAS_DOCKER_ERROR) + + if docker_version < MIN_DOCKER_VERSION: + self.fail("Error: docker-py version is %s. Minimum version required is %s." % (docker_version, + MIN_DOCKER_VERSION)) + + debug = self.module.params.get('debug') + log_mode = self.module.params.get('log_mode') + filter_logger = self.module.params.get('filter_logger') + if debug and log_mode == 'syslog': + handler = DockerSysLogHandler(level=logging.DEBUG, module=self.module) + self.logger.addHandler(handler) + logging.basicConfig(level=logging.DEBUG) + elif debug and log_mode == 'file': + log_path = self.module.params.get('log_path') + logging.basicConfig(level=logging.DEBUG, filename=log_path) + elif debug and log_mode == 'stderr': + logging.basicConfig(level=logging.DEBUG, stream=sys.stderr) + + if filter_logger: + for h in logging.root.handlers: + h.addFilter(logging.Filter(name=self.logger.name)) + + self.check_mode = self.module.check_mode + self._connect_params = self._get_connect_params() + + try: + super(AnsibleDockerClient, self).__init__(**self._connect_params) + except APIError, exc: + self.fail("Docker API error: %s" % exc) + except Exception, exc: + self.fail("Error connecting: %s" % exc) + + def log(self, msg, pretty_print=False): + self.logger.debug(log_msg(msg, pretty_print=pretty_print)) + + 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', 'localhost'), + api_version=self._get_value('api_version', params['api_version'], 'DOCKER_API_VERSION', + DEFAULT_DOCKER_API_VERSION), + 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, 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: + msg = "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)) + self.fail(msg) + self.fail("SSL Exception: %s" % (error)) + + 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 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, exc: + self._handle_ssl_error(exc) + except Exception, 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 Exception, 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 + + lookup = name + if tag: + lookup = "%s:%s" % (name, tag) + + self.log("Find image %s" % lookup) + try: + images = self.images(name=lookup) + except Exception, exc: + self.fail("Error getting image: %s" % str(exc)) + + if len(images) > 1: + self.fail("Registry returned more than one result for %s" % lookup) + + if len(images) == 1: + try: + inspection = self.inspect_image(images[0]['Id']) + except Exception, exc: + self.fail("Error inspecting image %s - %s" % (lookup, str(exc))) + inspection['Name'] = lookup + return inspection + self.log("Image %s not found." % lookup) + return None + + def pull_image(self, name, tag="latest"): + ''' + Pull an image + ''' + try: + self.log("Pulling image %s:%s" % (name, tag)) + for line in self.pull(name, tag=tag, stream=True): + response = json.loads(line) + self.log(response, pretty_print=True) + return self.find_image(name, tag) + except Exception, exc: + self.fail("Error pulling image %s:%s - %s" % (name, tag, str(exc))) + +