64d07c0b7f
* Deploy services using kolla-ansible deploy * Reconfigure the image for one or more services to use an invalid * config * Deploy/reconfigure services using kolla-ansible reconfigure The invalid config could be a wrong docker registry, wrong image name, wrong tag, etc. The restart handler for the service fails, and the old container is left running. The restart handler for the service fails, and the old container is stopped and removed. This leaves the service in a broken state. This change fixes the issue by pulling the image if necessary prior to stopping and removing the container. Change-Id: I85b2a1b224d4c4d85c32c4922a2cd2c41171a1dc Closes-Bug: #1852572
1025 lines
35 KiB
Python
1025 lines
35 KiB
Python
#!/usr/bin/env python
|
|
|
|
# Copyright 2015 Sam Yaple
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
# FIXME(yoctozepto): this module does *not* validate "common_options" which are
|
|
# a hacky way to seed most usages of kolla_docker in kolla-ansible ansible
|
|
# playbooks - caution has to be exerted when setting "common_options"
|
|
|
|
# FIXME(yoctozepto): restart_policy is *not* checked in the container
|
|
|
|
import json
|
|
import os
|
|
import shlex
|
|
import traceback
|
|
|
|
import docker
|
|
|
|
from ansible.module_utils.basic import AnsibleModule
|
|
|
|
DOCUMENTATION = '''
|
|
---
|
|
module: kolla_docker
|
|
short_description: Module for controlling Docker
|
|
description:
|
|
- A module targeting at controlling Docker as used by Kolla.
|
|
options:
|
|
common_options:
|
|
description:
|
|
- A dict containing common params such as login info
|
|
required: False
|
|
type: dict
|
|
default: dict()
|
|
action:
|
|
description:
|
|
- The action the module should take
|
|
required: True
|
|
type: str
|
|
choices:
|
|
- compare_container
|
|
- compare_image
|
|
- create_volume
|
|
- ensure_image
|
|
- get_container_env
|
|
- get_container_state
|
|
- pull_image
|
|
- remove_container
|
|
- remove_image
|
|
- remove_volume
|
|
- recreate_or_restart_container
|
|
- restart_container
|
|
- start_container
|
|
- stop_container
|
|
- stop_container_and_remove_container
|
|
api_version:
|
|
description:
|
|
- The version of the api for docker-py to use when contacting docker
|
|
required: False
|
|
type: str
|
|
default: auto
|
|
auth_email:
|
|
description:
|
|
- The email address used to authenticate
|
|
required: False
|
|
type: str
|
|
auth_password:
|
|
description:
|
|
- The password used to authenticate
|
|
required: False
|
|
type: str
|
|
auth_registry:
|
|
description:
|
|
- The registry to authenticate
|
|
required: False
|
|
type: str
|
|
auth_username:
|
|
description:
|
|
- The username used to authenticate
|
|
required: False
|
|
type: str
|
|
command:
|
|
description:
|
|
- The command to execute in the container
|
|
required: False
|
|
type: str
|
|
detach:
|
|
description:
|
|
- Detach from the container after it is created
|
|
required: False
|
|
default: True
|
|
type: bool
|
|
name:
|
|
description:
|
|
- Name of the container or volume to manage
|
|
required: False
|
|
type: str
|
|
environment:
|
|
description:
|
|
- The environment to set for the container
|
|
required: False
|
|
type: dict
|
|
image:
|
|
description:
|
|
- Name of the docker image
|
|
required: False
|
|
type: str
|
|
ipc_mode:
|
|
description:
|
|
- Set docker ipc namespace
|
|
required: False
|
|
type: str
|
|
default: None
|
|
choices:
|
|
- host
|
|
cap_add:
|
|
description:
|
|
- Add capabilities to docker container
|
|
required: False
|
|
type: list
|
|
default: list()
|
|
security_opt:
|
|
description:
|
|
- Set container security profile
|
|
required: False
|
|
type: list
|
|
default: list()
|
|
labels:
|
|
description:
|
|
- List of labels to apply to container
|
|
required: False
|
|
type: dict
|
|
default: dict()
|
|
pid_mode:
|
|
description:
|
|
- Set docker pid namespace
|
|
required: False
|
|
type: str
|
|
default: None
|
|
choices:
|
|
- host
|
|
privileged:
|
|
description:
|
|
- Set the container to privileged
|
|
required: False
|
|
default: False
|
|
type: bool
|
|
remove_on_exit:
|
|
description:
|
|
- When not detaching from container, remove on successful exit
|
|
required: False
|
|
default: True
|
|
type: bool
|
|
restart_policy:
|
|
description:
|
|
- When docker restarts the container (does not affect checks)
|
|
required: False
|
|
type: str
|
|
choices:
|
|
- no
|
|
- on-failure
|
|
- always
|
|
- unless-stopped
|
|
restart_retries:
|
|
description:
|
|
- How many times to attempt a restart if 'on-failure' policy is set
|
|
type: int
|
|
default: 10
|
|
volumes:
|
|
description:
|
|
- Set volumes for docker to use
|
|
required: False
|
|
type: list
|
|
volumes_from:
|
|
description:
|
|
- Name or id of container(s) to use volumes from
|
|
required: True
|
|
type: list
|
|
state:
|
|
description:
|
|
- Check container status
|
|
required: False
|
|
type: str
|
|
choices:
|
|
- running
|
|
- exited
|
|
- paused
|
|
tty:
|
|
description:
|
|
- Allocate TTY to container
|
|
required: False
|
|
default: False
|
|
type: bool
|
|
author: Sam Yaple
|
|
'''
|
|
|
|
EXAMPLES = '''
|
|
- hosts: kolla_docker
|
|
tasks:
|
|
- name: Start container
|
|
kolla_docker:
|
|
image: ubuntu
|
|
name: test_container
|
|
action: start_container
|
|
- name: Remove container
|
|
kolla_docker:
|
|
name: test_container
|
|
action: remove_container
|
|
- name: Pull image without starting container
|
|
kolla_docker:
|
|
action: pull_image
|
|
image: private-registry.example.com:5000/ubuntu
|
|
- name: Create named volume
|
|
kolla_docker:
|
|
action: create_volume
|
|
name: name_of_volume
|
|
- name: Remove named volume
|
|
kolla_docker:
|
|
action: remove_volume
|
|
name: name_of_volume
|
|
- name: Remove image
|
|
kolla_docker:
|
|
action: remove_image
|
|
image: name_of_image
|
|
'''
|
|
|
|
|
|
def get_docker_client():
|
|
return docker.APIClient
|
|
|
|
|
|
class DockerWorker(object):
|
|
|
|
def __init__(self, module):
|
|
self.module = module
|
|
self.params = self.module.params
|
|
self.changed = False
|
|
# Use this to store arguments to pass to exit_json().
|
|
self.result = {}
|
|
|
|
# TLS not fully implemented
|
|
# tls_config = self.generate_tls()
|
|
|
|
options = {
|
|
'version': self.params.get('api_version')
|
|
}
|
|
|
|
self.dc = get_docker_client()(**options)
|
|
|
|
def generate_tls(self):
|
|
tls = {'verify': self.params.get('tls_verify')}
|
|
tls_cert = self.params.get('tls_cert'),
|
|
tls_key = self.params.get('tls_key'),
|
|
tls_cacert = self.params.get('tls_cacert')
|
|
|
|
if tls['verify']:
|
|
if tls_cert:
|
|
self.check_file(tls_cert)
|
|
self.check_file(tls_key)
|
|
tls['client_cert'] = (tls_cert, tls_key)
|
|
if tls_cacert:
|
|
self.check_file(tls_cacert)
|
|
tls['verify'] = tls_cacert
|
|
|
|
return docker.tls.TLSConfig(**tls)
|
|
|
|
def check_file(self, path):
|
|
if not os.path.isfile(path):
|
|
self.module.fail_json(
|
|
failed=True,
|
|
msg='There is no file at "{}"'.format(path)
|
|
)
|
|
if not os.access(path, os.R_OK):
|
|
self.module.fail_json(
|
|
failed=True,
|
|
msg='Permission denied for file at "{}"'.format(path)
|
|
)
|
|
|
|
def check_image(self):
|
|
find_image = ':'.join(self.parse_image())
|
|
for image in self.dc.images():
|
|
repo_tags = image.get('RepoTags')
|
|
if not repo_tags:
|
|
continue
|
|
for image_name in repo_tags:
|
|
if image_name == find_image:
|
|
return image
|
|
|
|
def check_volume(self):
|
|
for vol in self.dc.volumes()['Volumes'] or list():
|
|
if vol['Name'] == self.params.get('name'):
|
|
return vol
|
|
|
|
def check_container(self):
|
|
find_name = '/{}'.format(self.params.get('name'))
|
|
for cont in self.dc.containers(all=True):
|
|
if find_name in cont['Names']:
|
|
return cont
|
|
|
|
def get_container_info(self):
|
|
container = self.check_container()
|
|
if not container:
|
|
return None
|
|
return self.dc.inspect_container(self.params.get('name'))
|
|
|
|
def compare_container(self):
|
|
container = self.check_container()
|
|
if not container or self.check_container_differs():
|
|
self.changed = True
|
|
return self.changed
|
|
|
|
def check_container_differs(self):
|
|
container_info = self.get_container_info()
|
|
return (
|
|
self.compare_cap_add(container_info) or
|
|
self.compare_security_opt(container_info) or
|
|
self.compare_image(container_info) or
|
|
self.compare_ipc_mode(container_info) or
|
|
self.compare_labels(container_info) or
|
|
self.compare_privileged(container_info) or
|
|
self.compare_pid_mode(container_info) or
|
|
self.compare_volumes(container_info) or
|
|
self.compare_volumes_from(container_info) or
|
|
self.compare_environment(container_info) or
|
|
self.compare_container_state(container_info) or
|
|
self.compare_dimensions(container_info) or
|
|
self.compare_command(container_info)
|
|
)
|
|
|
|
def compare_ipc_mode(self, container_info):
|
|
new_ipc_mode = self.params.get('ipc_mode')
|
|
current_ipc_mode = container_info['HostConfig'].get('IpcMode')
|
|
if not current_ipc_mode:
|
|
current_ipc_mode = None
|
|
|
|
# only check IPC mode if it is specified
|
|
if new_ipc_mode is not None and new_ipc_mode != current_ipc_mode:
|
|
return True
|
|
return False
|
|
|
|
def compare_cap_add(self, container_info):
|
|
new_cap_add = self.params.get('cap_add', list())
|
|
current_cap_add = container_info['HostConfig'].get('CapAdd',
|
|
list())
|
|
if not current_cap_add:
|
|
current_cap_add = list()
|
|
if set(new_cap_add).symmetric_difference(set(current_cap_add)):
|
|
return True
|
|
|
|
def compare_security_opt(self, container_info):
|
|
ipc_mode = self.params.get('ipc_mode')
|
|
pid_mode = self.params.get('pid_mode')
|
|
privileged = self.params.get('privileged', False)
|
|
# NOTE(jeffrey4l) security opt is disabled when using host ipc mode or
|
|
# host pid mode or privileged. So no need to compare security opts
|
|
if ipc_mode == 'host' or pid_mode == 'host' or privileged:
|
|
return False
|
|
new_sec_opt = self.params.get('security_opt', list())
|
|
current_sec_opt = container_info['HostConfig'].get('SecurityOpt',
|
|
list())
|
|
if not current_sec_opt:
|
|
current_sec_opt = list()
|
|
if set(new_sec_opt).symmetric_difference(set(current_sec_opt)):
|
|
return True
|
|
|
|
def compare_pid_mode(self, container_info):
|
|
new_pid_mode = self.params.get('pid_mode')
|
|
current_pid_mode = container_info['HostConfig'].get('PidMode')
|
|
if not current_pid_mode:
|
|
current_pid_mode = None
|
|
|
|
if new_pid_mode != current_pid_mode:
|
|
return True
|
|
|
|
def compare_privileged(self, container_info):
|
|
new_privileged = self.params.get('privileged')
|
|
current_privileged = container_info['HostConfig']['Privileged']
|
|
if new_privileged != current_privileged:
|
|
return True
|
|
|
|
def compare_image(self, container_info=None):
|
|
container_info = container_info or self.get_container_info()
|
|
parse_repository_tag = docker.utils.parse_repository_tag
|
|
if not container_info:
|
|
return True
|
|
new_image = self.check_image()
|
|
current_image = container_info['Image']
|
|
if not new_image:
|
|
return True
|
|
if new_image['Id'] != current_image:
|
|
return True
|
|
# NOTE(Jeffrey4l) when new image and the current image have
|
|
# the same id, but the tag name different.
|
|
elif (parse_repository_tag(container_info['Config']['Image']) !=
|
|
parse_repository_tag(self.params.get('image'))):
|
|
return True
|
|
|
|
def compare_labels(self, container_info):
|
|
new_labels = self.params.get('labels')
|
|
current_labels = container_info['Config'].get('Labels', dict())
|
|
image_labels = self.check_image().get('Labels', dict())
|
|
for k, v in image_labels.items():
|
|
if k in new_labels:
|
|
if v != new_labels[k]:
|
|
return True
|
|
else:
|
|
del current_labels[k]
|
|
|
|
if new_labels != current_labels:
|
|
return True
|
|
|
|
def compare_volumes_from(self, container_info):
|
|
new_vols_from = self.params.get('volumes_from')
|
|
current_vols_from = container_info['HostConfig'].get('VolumesFrom')
|
|
if not new_vols_from:
|
|
new_vols_from = list()
|
|
if not current_vols_from:
|
|
current_vols_from = list()
|
|
|
|
if set(current_vols_from).symmetric_difference(set(new_vols_from)):
|
|
return True
|
|
|
|
def compare_volumes(self, container_info):
|
|
volumes, binds = self.generate_volumes()
|
|
current_vols = container_info['Config'].get('Volumes')
|
|
current_binds = container_info['HostConfig'].get('Binds')
|
|
if not volumes:
|
|
volumes = list()
|
|
if not current_vols:
|
|
current_vols = list()
|
|
if not current_binds:
|
|
current_binds = list()
|
|
|
|
if set(volumes).symmetric_difference(set(current_vols)):
|
|
return True
|
|
|
|
new_binds = list()
|
|
if binds:
|
|
for k, v in binds.items():
|
|
new_binds.append("{}:{}:{}".format(k, v['bind'], v['mode']))
|
|
|
|
if set(new_binds).symmetric_difference(set(current_binds)):
|
|
return True
|
|
|
|
def compare_environment(self, container_info):
|
|
if self.params.get('environment'):
|
|
current_env = dict()
|
|
for kv in container_info['Config'].get('Env', list()):
|
|
k, v = kv.split('=', 1)
|
|
current_env.update({k: v})
|
|
|
|
for k, v in self.params.get('environment').items():
|
|
if k not in current_env:
|
|
return True
|
|
if current_env[k] != v:
|
|
return True
|
|
|
|
def compare_container_state(self, container_info):
|
|
new_state = self.params.get('state')
|
|
current_state = container_info['State'].get('Status')
|
|
if new_state != current_state:
|
|
return True
|
|
|
|
def compare_dimensions(self, container_info):
|
|
new_dimensions = self.params.get('dimensions')
|
|
# NOTE(mgoddard): The names used by Docker are inconsisent between
|
|
# configuration of a container's resources and the resources in
|
|
# container_info['HostConfig']. This provides a mapping between the
|
|
# two.
|
|
dimension_map = {
|
|
'mem_limit': 'Memory', 'mem_reservation': 'MemoryReservation',
|
|
'memswap_limit': 'MemorySwap', 'cpu_period': 'CpuPeriod',
|
|
'cpu_quota': 'CpuQuota', 'cpu_shares': 'CpuShares',
|
|
'cpuset_cpus': 'CpusetCpus', 'cpuset_mems': 'CpusetMems',
|
|
'kernel_memory': 'KernelMemory', 'blkio_weight': 'BlkioWeight',
|
|
'ulimits': 'Ulimits'}
|
|
unsupported = set(new_dimensions.keys()) - \
|
|
set(dimension_map.keys())
|
|
if unsupported:
|
|
self.module.exit_json(
|
|
failed=True, msg=repr("Unsupported dimensions"),
|
|
unsupported_dimensions=unsupported)
|
|
current_dimensions = container_info['HostConfig']
|
|
for key1, key2 in dimension_map.items():
|
|
# NOTE(mgoddard): If a resource has been explicitly requested,
|
|
# check for a match. Otherwise, ensure is is set to the default.
|
|
if key1 in new_dimensions:
|
|
if key1 == 'ulimits':
|
|
if self.compare_ulimits(new_dimensions[key1],
|
|
current_dimensions[key2]):
|
|
return True
|
|
elif new_dimensions[key1] != current_dimensions[key2]:
|
|
return True
|
|
elif current_dimensions[key2]:
|
|
# The default values of all currently supported resources are
|
|
# '' or 0 - both falsey.
|
|
return True
|
|
|
|
def compare_ulimits(self, new_ulimits, current_ulimits):
|
|
# The new_ulimits is dict, we need make it to a list of Ulimit
|
|
# instance.
|
|
new_ulimits = self.build_ulimits(new_ulimits)
|
|
|
|
def key(ulimit):
|
|
return ulimit['Name']
|
|
|
|
if current_ulimits is None:
|
|
current_ulimits = []
|
|
return sorted(new_ulimits, key=key) != sorted(current_ulimits, key=key)
|
|
|
|
def compare_command(self, container_info):
|
|
new_command = self.params.get('command')
|
|
if new_command is not None:
|
|
new_command_split = shlex.split(new_command)
|
|
new_path = new_command_split[0]
|
|
new_args = new_command_split[1:]
|
|
if (new_path != container_info['Path'] or
|
|
new_args != container_info['Args']):
|
|
return True
|
|
|
|
def parse_image(self):
|
|
full_image = self.params.get('image')
|
|
|
|
if '/' in full_image:
|
|
registry, image = full_image.split('/', 1)
|
|
else:
|
|
image = full_image
|
|
|
|
if ':' in image:
|
|
return full_image.rsplit(':', 1)
|
|
else:
|
|
return full_image, 'latest'
|
|
|
|
def get_image_id(self):
|
|
full_image = self.params.get('image')
|
|
|
|
image = self.dc.images(name=full_image, quiet=True)
|
|
return image[0] if len(image) == 1 else None
|
|
|
|
def pull_image(self):
|
|
if self.params.get('auth_username'):
|
|
self.dc.login(
|
|
username=self.params.get('auth_username'),
|
|
password=self.params.get('auth_password'),
|
|
registry=self.params.get('auth_registry'),
|
|
email=self.params.get('auth_email')
|
|
)
|
|
|
|
image, tag = self.parse_image()
|
|
old_image_id = self.get_image_id()
|
|
|
|
statuses = [
|
|
json.loads(line.strip().decode('utf-8')) for line in self.dc.pull(
|
|
repository=image, tag=tag, stream=True
|
|
)
|
|
]
|
|
|
|
for status in reversed(statuses):
|
|
if 'error' in status:
|
|
if status['error'].endswith('not found'):
|
|
self.module.fail_json(
|
|
msg="The requested image does not exist: {}:{}".format(
|
|
image, tag),
|
|
failed=True
|
|
)
|
|
else:
|
|
self.module.fail_json(
|
|
msg="Unknown error message: {}".format(
|
|
status['error']),
|
|
failed=True
|
|
)
|
|
|
|
new_image_id = self.get_image_id()
|
|
self.changed = old_image_id != new_image_id
|
|
|
|
def remove_container(self):
|
|
if self.check_container():
|
|
self.changed = True
|
|
# NOTE(jeffrey4l): in some case, docker failed to remove container
|
|
# filesystem and raise error. But the container info is
|
|
# disappeared already. If this happens, assume the container is
|
|
# removed.
|
|
try:
|
|
self.dc.remove_container(
|
|
container=self.params.get('name'),
|
|
force=True
|
|
)
|
|
except docker.errors.APIError:
|
|
if self.check_container():
|
|
raise
|
|
|
|
def generate_volumes(self):
|
|
volumes = self.params.get('volumes')
|
|
if not volumes:
|
|
return None, None
|
|
|
|
vol_list = list()
|
|
vol_dict = dict()
|
|
|
|
for vol in volumes:
|
|
if len(vol) == 0:
|
|
continue
|
|
|
|
if ':' not in vol:
|
|
vol_list.append(vol)
|
|
continue
|
|
|
|
split_vol = vol.split(':')
|
|
|
|
if (len(split_vol) == 2
|
|
and ('/' not in split_vol[0] or '/' in split_vol[1])):
|
|
split_vol.append('rw')
|
|
|
|
vol_list.append(split_vol[1])
|
|
vol_dict.update({
|
|
split_vol[0]: {
|
|
'bind': split_vol[1],
|
|
'mode': split_vol[2]
|
|
}
|
|
})
|
|
|
|
return vol_list, vol_dict
|
|
|
|
def parse_dimensions(self, dimensions):
|
|
# When the data object contains types such as
|
|
# docker.types.Ulimit, Ansible will fail when these are
|
|
# returned via exit_json or fail_json. HostConfig is derived from dict,
|
|
# but its constructor requires additional arguments.
|
|
# to avoid that, here do copy the dimensions and return a new one.
|
|
dimensions = dimensions.copy()
|
|
|
|
supported = {'cpu_period', 'cpu_quota', 'cpu_shares',
|
|
'cpuset_cpus', 'cpuset_mems', 'mem_limit',
|
|
'mem_reservation', 'memswap_limit',
|
|
'kernel_memory', 'blkio_weight', 'ulimits'}
|
|
unsupported = set(dimensions) - supported
|
|
if unsupported:
|
|
self.module.exit_json(failed=True,
|
|
msg=repr("Unsupported dimensions"),
|
|
unsupported_dimensions=unsupported)
|
|
|
|
ulimits = dimensions.get('ulimits')
|
|
if ulimits:
|
|
dimensions['ulimits'] = self.build_ulimits(ulimits)
|
|
|
|
return dimensions
|
|
|
|
def build_ulimits(self, ulimits):
|
|
ulimits_opt = []
|
|
for key, value in ulimits.items():
|
|
soft = value.get('soft')
|
|
hard = value.get('hard')
|
|
ulimits_opt.append(docker.types.Ulimit(name=key,
|
|
soft=soft,
|
|
hard=hard))
|
|
return ulimits_opt
|
|
|
|
def build_host_config(self, binds):
|
|
options = {
|
|
'network_mode': 'host',
|
|
'ipc_mode': self.params.get('ipc_mode'),
|
|
'cap_add': self.params.get('cap_add'),
|
|
'security_opt': self.params.get('security_opt'),
|
|
'pid_mode': self.params.get('pid_mode'),
|
|
'privileged': self.params.get('privileged'),
|
|
'volumes_from': self.params.get('volumes_from')
|
|
}
|
|
|
|
dimensions = self.params.get('dimensions')
|
|
|
|
if dimensions:
|
|
dimensions = self.parse_dimensions(dimensions)
|
|
options.update(dimensions)
|
|
|
|
restart_policy = self.params.get('restart_policy')
|
|
|
|
if restart_policy is not None:
|
|
restart_policy = {'Name': restart_policy}
|
|
# NOTE(Jeffrey4l): MaximumRetryCount is only needed for on-failure
|
|
# policy
|
|
if restart_policy['Name'] == 'on-failure':
|
|
retries = self.params.get('restart_retries')
|
|
if retries is not None:
|
|
restart_policy['MaximumRetryCount'] = retries
|
|
options['restart_policy'] = restart_policy
|
|
|
|
if binds:
|
|
options['binds'] = binds
|
|
|
|
return self.dc.create_host_config(**options)
|
|
|
|
def _inject_env_var(self, environment_info):
|
|
newenv = {
|
|
'KOLLA_SERVICE_NAME': self.params.get('name').replace('_', '-')
|
|
}
|
|
environment_info.update(newenv)
|
|
return environment_info
|
|
|
|
def _format_env_vars(self):
|
|
env = self._inject_env_var(self.params.get('environment'))
|
|
return {k: "" if env[k] is None else env[k] for k in env}
|
|
|
|
def build_container_options(self):
|
|
volumes, binds = self.generate_volumes()
|
|
return {
|
|
'command': self.params.get('command'),
|
|
'detach': self.params.get('detach'),
|
|
'environment': self._format_env_vars(),
|
|
'host_config': self.build_host_config(binds),
|
|
'labels': self.params.get('labels'),
|
|
'image': self.params.get('image'),
|
|
'name': self.params.get('name'),
|
|
'volumes': volumes,
|
|
'tty': self.params.get('tty'),
|
|
}
|
|
|
|
def create_container(self):
|
|
self.changed = True
|
|
options = self.build_container_options()
|
|
self.dc.create_container(**options)
|
|
|
|
def recreate_or_restart_container(self):
|
|
self.changed = True
|
|
container = self.check_container()
|
|
# get config_strategy from env
|
|
environment = self.params.get('environment')
|
|
config_strategy = environment.get('KOLLA_CONFIG_STRATEGY')
|
|
|
|
if not container:
|
|
self.start_container()
|
|
return
|
|
# If config_strategy is COPY_ONCE or container's parameters are
|
|
# changed, try to start a new one.
|
|
if config_strategy == 'COPY_ONCE' or self.check_container_differs():
|
|
# NOTE(mgoddard): Pull the image if necessary before stopping the
|
|
# container, otherwise a failure to pull the image will leave the
|
|
# container stopped.
|
|
if not self.check_image():
|
|
self.pull_image()
|
|
self.stop_container()
|
|
self.remove_container()
|
|
self.start_container()
|
|
elif config_strategy == 'COPY_ALWAYS':
|
|
self.restart_container()
|
|
|
|
def start_container(self):
|
|
if not self.check_image():
|
|
self.pull_image()
|
|
|
|
container = self.check_container()
|
|
if container and self.check_container_differs():
|
|
self.stop_container()
|
|
self.remove_container()
|
|
container = self.check_container()
|
|
|
|
if not container:
|
|
self.create_container()
|
|
container = self.check_container()
|
|
|
|
if not container['Status'].startswith('Up '):
|
|
self.changed = True
|
|
self.dc.start(container=self.params.get('name'))
|
|
|
|
# We do not want to detach so we wait around for container to exit
|
|
if not self.params.get('detach'):
|
|
rc = self.dc.wait(self.params.get('name'))
|
|
# NOTE(jeffrey4l): since python docker package 3.0, wait return a
|
|
# dict all the time.
|
|
if isinstance(rc, dict):
|
|
rc = rc['StatusCode']
|
|
# Include container's return code, standard output and error in the
|
|
# result.
|
|
self.result['rc'] = rc
|
|
self.result['stdout'] = self.dc.logs(self.params.get('name'),
|
|
stdout=True, stderr=False)
|
|
self.result['stderr'] = self.dc.logs(self.params.get('name'),
|
|
stdout=False, stderr=True)
|
|
if self.params.get('remove_on_exit'):
|
|
self.stop_container()
|
|
self.remove_container()
|
|
if rc != 0:
|
|
self.module.fail_json(
|
|
changed=True,
|
|
msg="Container exited with non-zero return code %s" % rc,
|
|
**self.result
|
|
)
|
|
|
|
def get_container_env(self):
|
|
name = self.params.get('name')
|
|
info = self.get_container_info()
|
|
if not info:
|
|
self.module.fail_json(msg="No such container: {}".format(name))
|
|
else:
|
|
envs = dict()
|
|
for env in info['Config']['Env']:
|
|
if '=' in env:
|
|
key, value = env.split('=', 1)
|
|
else:
|
|
key, value = env, ''
|
|
envs[key] = value
|
|
|
|
self.module.exit_json(**envs)
|
|
|
|
def get_container_state(self):
|
|
name = self.params.get('name')
|
|
info = self.get_container_info()
|
|
if not info:
|
|
self.module.fail_json(msg="No such container: {}".format(name))
|
|
else:
|
|
self.module.exit_json(**info['State'])
|
|
|
|
def stop_container(self):
|
|
name = self.params.get('name')
|
|
graceful_timeout = self.params.get('graceful_timeout')
|
|
if not graceful_timeout:
|
|
graceful_timeout = 10
|
|
container = self.check_container()
|
|
if not container:
|
|
self.module.fail_json(
|
|
msg="No such container: {} to stop".format(name))
|
|
elif not container['Status'].startswith('Exited '):
|
|
self.changed = True
|
|
self.dc.stop(name, timeout=graceful_timeout)
|
|
|
|
def stop_and_remove_container(self):
|
|
container = self.check_container()
|
|
if container:
|
|
self.stop_container()
|
|
self.remove_container()
|
|
|
|
def restart_container(self):
|
|
name = self.params.get('name')
|
|
graceful_timeout = self.params.get('graceful_timeout')
|
|
if not graceful_timeout:
|
|
graceful_timeout = 10
|
|
info = self.get_container_info()
|
|
if not info:
|
|
self.module.fail_json(
|
|
msg="No such container: {}".format(name))
|
|
else:
|
|
self.changed = True
|
|
self.dc.stop(name, timeout=graceful_timeout)
|
|
self.dc.start(name)
|
|
|
|
def create_volume(self):
|
|
if not self.check_volume():
|
|
self.changed = True
|
|
self.dc.create_volume(name=self.params.get('name'), driver='local')
|
|
|
|
def remove_volume(self):
|
|
if self.check_volume():
|
|
self.changed = True
|
|
try:
|
|
self.dc.remove_volume(name=self.params.get('name'))
|
|
except docker.errors.APIError as e:
|
|
if e.response.status_code == 409:
|
|
self.module.fail_json(
|
|
failed=True,
|
|
msg="Volume named '{}' is currently in-use".format(
|
|
self.params.get('name')
|
|
)
|
|
)
|
|
raise
|
|
|
|
def remove_image(self):
|
|
if self.check_image():
|
|
self.changed = True
|
|
try:
|
|
self.dc.remove_image(image=self.params.get('image'))
|
|
except docker.errors.APIError as e:
|
|
if e.response.status_code == 409:
|
|
self.module.fail_json(
|
|
failed=True,
|
|
msg="Image '{}' is currently in-use".format(
|
|
self.params.get('image')
|
|
)
|
|
)
|
|
elif e.response.status_code == 500:
|
|
self.module.fail_json(
|
|
failed=True,
|
|
msg="Server error"
|
|
)
|
|
raise
|
|
|
|
def ensure_image(self):
|
|
if not self.check_image():
|
|
self.pull_image()
|
|
|
|
|
|
def generate_module():
|
|
# NOTE(jeffrey4l): add empty string '' to choices let us use
|
|
# pid_mode: "{{ service.pid_mode | default ('') }}" in yaml
|
|
argument_spec = dict(
|
|
common_options=dict(required=False, type='dict', default=dict()),
|
|
action=dict(required=True, type='str',
|
|
choices=['compare_container',
|
|
'compare_image',
|
|
'create_volume',
|
|
'ensure_image',
|
|
'get_container_env',
|
|
'get_container_state',
|
|
'pull_image',
|
|
'recreate_or_restart_container',
|
|
'remove_container',
|
|
'remove_image',
|
|
'remove_volume',
|
|
'restart_container',
|
|
'start_container',
|
|
'stop_container',
|
|
'stop_and_remove_container']),
|
|
api_version=dict(required=False, type='str', default='auto'),
|
|
auth_email=dict(required=False, type='str'),
|
|
auth_password=dict(required=False, type='str', no_log=True),
|
|
auth_registry=dict(required=False, type='str'),
|
|
auth_username=dict(required=False, type='str'),
|
|
command=dict(required=False, type='str'),
|
|
detach=dict(required=False, type='bool', default=True),
|
|
labels=dict(required=False, type='dict', default=dict()),
|
|
name=dict(required=False, type='str'),
|
|
environment=dict(required=False, type='dict'),
|
|
image=dict(required=False, type='str'),
|
|
ipc_mode=dict(required=False, type='str', choices=['',
|
|
'host',
|
|
'private',
|
|
'shareable']),
|
|
cap_add=dict(required=False, type='list', default=list()),
|
|
security_opt=dict(required=False, type='list', default=list()),
|
|
pid_mode=dict(required=False, type='str', choices=['host', '']),
|
|
privileged=dict(required=False, type='bool', default=False),
|
|
graceful_timeout=dict(required=False, type='int', default=10),
|
|
remove_on_exit=dict(required=False, type='bool', default=True),
|
|
restart_policy=dict(required=False, type='str', choices=[
|
|
'no',
|
|
'on-failure',
|
|
'always',
|
|
'unless-stopped']),
|
|
restart_retries=dict(required=False, type='int', default=10),
|
|
state=dict(required=False, type='str', default='running',
|
|
choices=['running',
|
|
'exited',
|
|
'paused']),
|
|
tls_verify=dict(required=False, type='bool', default=False),
|
|
tls_cert=dict(required=False, type='str'),
|
|
tls_key=dict(required=False, type='str'),
|
|
tls_cacert=dict(required=False, type='str'),
|
|
volumes=dict(required=False, type='list'),
|
|
volumes_from=dict(required=False, type='list'),
|
|
dimensions=dict(required=False, type='dict', default=dict()),
|
|
tty=dict(required=False, type='bool', default=False),
|
|
)
|
|
required_if = [
|
|
['action', 'pull_image', ['image']],
|
|
['action', 'start_container', ['image', 'name']],
|
|
['action', 'compare_container', ['name']],
|
|
['action', 'compare_image', ['name']],
|
|
['action', 'create_volume', ['name']],
|
|
['action', 'ensure_image', ['image']],
|
|
['action', 'get_container_env', ['name']],
|
|
['action', 'get_container_state', ['name']],
|
|
['action', 'recreate_or_restart_container', ['name']],
|
|
['action', 'remove_container', ['name']],
|
|
['action', 'remove_image', ['image']],
|
|
['action', 'remove_volume', ['name']],
|
|
['action', 'restart_container', ['name']],
|
|
['action', 'stop_container', ['name']],
|
|
['action', 'stop_and_remove_container', ['name']],
|
|
]
|
|
module = AnsibleModule(
|
|
argument_spec=argument_spec,
|
|
required_if=required_if,
|
|
bypass_checks=False
|
|
)
|
|
|
|
new_args = module.params.pop('common_options', dict())
|
|
|
|
# NOTE(jeffrey4l): merge the environment
|
|
env = module.params.pop('environment', dict())
|
|
if env:
|
|
new_args['environment'].update(env)
|
|
|
|
for key, value in module.params.items():
|
|
if key in new_args and value is None:
|
|
continue
|
|
new_args[key] = value
|
|
|
|
# if pid_mode = ""/None/False, remove it
|
|
if not new_args.get('pid_mode', False):
|
|
new_args.pop('pid_mode', None)
|
|
# if ipc_mode = ""/None/False, remove it
|
|
if not new_args.get('ipc_mode', False):
|
|
new_args.pop('ipc_mode', None)
|
|
|
|
module.params = new_args
|
|
return module
|
|
|
|
|
|
def main():
|
|
module = generate_module()
|
|
|
|
dw = None
|
|
try:
|
|
dw = DockerWorker(module)
|
|
# TODO(inc0): We keep it bool to have ansible deal with consistent
|
|
# types. If we ever add method that will have to return some
|
|
# meaningful data, we need to refactor all methods to return dicts.
|
|
result = bool(getattr(dw, module.params.get('action'))())
|
|
module.exit_json(changed=dw.changed, result=result, **dw.result)
|
|
except Exception:
|
|
module.fail_json(changed=True, msg=repr(traceback.format_exc()),
|
|
**getattr(dw, 'result', {}))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|