mirror of
https://github.com/ansible-collections/community.general.git
synced 2024-09-14 20:13:21 +02:00
04e990281e
* Add flag to Docker pull_image to know when the image is already latest Whenever the flag pull is set to 'yes' the resource is always defined as 'changed'. That is not true in case the image is already at the latest version. Related to ansible/ansible#19549 * Docker pull_image does not change status if the image is latest
453 lines
18 KiB
Python
453 lines
18 KiB
Python
#
|
|
# 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 <http://www.gnu.org/licenses/>.
|
|
|
|
import os
|
|
import re
|
|
import json
|
|
import sys
|
|
import copy
|
|
from distutils.version import LooseVersion
|
|
|
|
from ansible.module_utils.basic import AnsibleModule, BOOLEANS_TRUE, BOOLEANS_FALSE
|
|
from ansible.module_utils.six.moves.urllib.parse import urlparse
|
|
|
|
HAS_DOCKER_PY = True
|
|
HAS_DOCKER_PY_2 = False
|
|
HAS_DOCKER_ERROR = None
|
|
|
|
try:
|
|
from requests.exceptions import SSLError
|
|
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 import auth
|
|
if 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
|
|
|
|
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),
|
|
)
|
|
|
|
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
|
|
|
|
|
|
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):
|
|
|
|
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 LooseVersion(docker_version) < LooseVersion(MIN_DOCKER_VERSION):
|
|
self.fail("Error: docker-py version is %s. Minimum version required is %s." % (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)
|
|
|
|
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', 'localhost'),
|
|
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:
|
|
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 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 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/<name>' 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))
|
|
alreadyToLatest = False
|
|
try:
|
|
for line in self.pull(name, tag=tag, stream=True, decode=True):
|
|
self.log(line, pretty_print=True)
|
|
if line.get('status'):
|
|
if line.get('status').startswith('Status: Image is up to date for'):
|
|
alreadyToLatest = 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)))
|
|
|
|
return self.find_image(name, tag), alreadyToLatest
|
|
|
|
|