diff options
Diffstat (limited to 'ansible/module_utils')
| -rw-r--r-- | ansible/module_utils/kolla_docker_worker.py | 2 | ||||
| -rw-r--r-- | ansible/module_utils/kolla_podman_worker.py | 674 | ||||
| -rw-r--r-- | ansible/module_utils/kolla_systemd_worker.py | 22 |
3 files changed, 688 insertions, 10 deletions
diff --git a/ansible/module_utils/kolla_docker_worker.py b/ansible/module_utils/kolla_docker_worker.py index 2741b006d..d3224f160 100644 --- a/ansible/module_utils/kolla_docker_worker.py +++ b/ansible/module_utils/kolla_docker_worker.py @@ -12,8 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -# FIXME(yoctozepto): restart_policy is *not* checked in the container - import docker import json import os diff --git a/ansible/module_utils/kolla_podman_worker.py b/ansible/module_utils/kolla_podman_worker.py new file mode 100644 index 000000000..c0bf75181 --- /dev/null +++ b/ansible/module_utils/kolla_podman_worker.py @@ -0,0 +1,674 @@ +# 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. + +from podman.errors import APIError +from podman import PodmanClient + +import shlex + +from ansible.module_utils.kolla_container_worker import COMPARE_CONFIG_CMD +from ansible.module_utils.kolla_container_worker import ContainerWorker + +uri = "http+unix:/run/podman/podman.sock" + +CONTAINER_PARAMS = [ + 'name', # string + 'cap_add', # list + 'cgroupns', # 'str',choices=['private', 'host'] + 'command', # arrray of strings -- docker string + + # this part is hidden inside dimensions + 'cpu_period', # int + 'cpu_quota', # int + 'cpuset_cpus', # str + 'cpu_shares' # int + 'cpuset_mems', # str + 'kernel_memory', # int or string + 'mem_limit', # (Union[int, str]) + 'mem_reservation', # (Union[int, str]): Memory soft limit. + 'memswap_limit' # (Union[int, str]): Maximum amount of memory + # + swap a container is allowed to consume. + 'ulimits', # List[Ulimit] + 'blkio_weight', # int between 10 and 1000 + + + 'detach', # bool + 'entrypoint', # string + 'environment', # dict docker - environment - dictionary + 'healthcheck', # same schema as docker -- healthcheck + 'image', # string + 'ipc_mode', # string only option is host + + 'labels', # dict + 'netns', # dict # TODO(i.halomi) - not sure how it works + 'network_options', # string - none,bridge,host,container:id, + # missing in docker but needs to be host + 'pid_mode', # "string" host, private or '' + 'privileged', # bool + 'restart_policy', # set to none, handled by systemd + 'remove', # bool + 'restart_tries', # int doesnt matter done by systemd + 'stop_timeout', # int + 'tty' # bool + # VOLUMES NOT WORKING HAS TO BE DONE WITH MOUNTS + 'volumes', # array of dict + 'volumes_from', # array of strings +] + + +class PodmanWorker(ContainerWorker): + + def __init__(self, module) -> None: + super().__init__(module) + + self.pc = PodmanClient(base_url=uri) + + def prepare_container_args(self): + args = dict( + network_mode='host' + ) + + command = self.params.pop('command', '') + self.params['command'] = shlex.split(command) + + # we have to transform volumes into mounts because podman-py + # functionality is broken + mounts = [] + filtered_volumes = {} + volumes = self.params.get('volumes', []) + if volumes: + self.parse_volumes(volumes, mounts, filtered_volumes) + # we can delete original volumes so it won't raise error later + self.params.pop('volumes', None) + + args['mounts'] = mounts + args['volumes'] = filtered_volumes + + # in case value is not string it has to be converted + environment = self.params.get('environment') + if environment: + for key, value in environment.items(): + environment[key] = str(value) + + healthcheck = self.params.get('healthcheck') + if healthcheck: + healthcheck = self.parse_healthcheck(healthcheck) + self.params.pop('healthcheck', None) + args.update(healthcheck) + + # getting dimensions into separate parameters + dimensions = self.params.get('dimensions') + if dimensions: + dimensions = self.parse_dimensions(dimensions) + args.update(dimensions) + + # NOTE(m.hiner): currently unsupported by Podman API + # args['tmpfs'] = self.generate_tmpfs() + self.params.pop('tmpfs', None) + + # NOTE(m.hiner): in case containers are not privileged, + # they need this capability + if not self.params.get('privileged', False): + args['cap_add'] = self.params.pop('cap_add', []) + ['AUDIT_WRITE'] + + # maybe can be done straight away, + # at first it was around 6 keys that's why it is this way + convert_keys = dict( + graceful_timeout='stop_timeout', + cgroupns_mode='cgroupns' + ) + + # remap differing args + for key_orig, key_new in convert_keys.items(): + if key_orig in self.params: + value = self.params.get(key_orig, None) + + if value is not None: + args[key_new] = value + + # record remaining args + for key, value in self.params.items(): + if key in CONTAINER_PARAMS and value is not None: + args[key] = value + + args.pop('restart_policy', None) # handled by systemd + + return args + + def parse_volumes(self, volumes, mounts, filtered_volumes): + # we can ignore empty strings + volumes = [item for item in volumes if item.strip()] + + for item in volumes: + # if it starts with / it is bind not volume + if item[0] == '/': + mode = None + try: + if item.count(':') == 2: + src, dest, mode = item.split(':') + else: + src, dest = item.split(':') + except ValueError: + self.module.fail_json( + msg="Wrong format of volume: {}".format(item), + failed=True + ) + + mount_item = dict( + source=src, + target=dest, + type='bind', + propagation='rprivate' + ) + if mode == 'ro': + mount_item['read_only'] = True + if mode == 'shared': + mount_item['propagation'] = 'shared' + mounts.append(mount_item) + else: + try: + src, dest = item.split(':') + except ValueError: + self.module.fail_json( + msg="Wrong format of volume: {}".format(item), + failed=True + ) + if src == 'devpts': + mount_item = dict( + target=dest, + type='devpts' + ) + mounts.append(mount_item) + else: + filtered_volumes[src] = dict( + bind=dest, + mode='rw' + ) + + def parse_dimensions(self, dimensions): + 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: + # NOTE(m.hiner): default ulimits have to be filtered out because + # Podman would treat them as new ulimits and break the container + # as a result. Names are a copy of + # default_podman_dimensions_el9 in /ansible/group_vars/all.yml + for name in ['RLIMIT_NOFILE', 'RLIMIT_NPROC']: + ulimits.pop(name, None) + + dimensions['ulimits'] = self.build_ulimits(ulimits) + + return dimensions + + def parse_healthcheck(self, healthcheck): + hc = super().parse_healthcheck(healthcheck) + + # rename key to right format + if hc: + sp = hc['healthcheck'].pop('start_period', None) + if sp: + hc['healthcheck']['StartPeriod'] = sp + + return hc + + def prepare_image_args(self): + image, tag = self.parse_image() + + args = dict( + repository=image, + tag=tag, + tls_verify=self.params.get('tls_verify', False), + stream=False + ) + + if self.params.get('auth_username', False): + args['auth_config'] = dict( + username=self.params.get('auth_username'), + password=self.params.get('auth_password', "") + ) + + if '/' not in image and self.params.get('auth_registry', False): + args['image'] = self.params['auth_registry'] + '/' + image + return args + + def check_image(self): + try: + image = self.pc.images.get(self.params.get('image')) + return image.attrs + except APIError as e: + if e.status_code == 404: + return {} + else: + self.module.fail_json( + failed=True, + msg="Internal error: {}".format( + e.explanation + ) + ) + + def check_volume(self): + try: + vol = self.pc.volumes.get(self.params.get('name')) + return vol.attrs + except APIError as e: + if e.status_code == 404: + return {} + + def check_container(self): + name = self.params.get("name") + for cont in self.pc.containers.list(all=True): + cont.reload() + if name == cont.name: + return cont + + def get_container_info(self): + container = self.check_container() + if not container: + return None + + return container.attrs + + def compare_container(self): + container = self.check_container() + if (not container or + self.check_container_differs() or + self.compare_config() or + self.systemd.check_unit_change()): + self.changed = True + return self.changed + + def compare_pid_mode(self, container_info): + new_pid_mode = self.params.get('pid_mode') or self.params.get('pid') + current_pid_mode = container_info['HostConfig'].get('PidMode') + + if not current_pid_mode: + current_pid_mode = None + + # podman default pid_mode + if new_pid_mode is None and current_pid_mode == 'private': + return False + + if new_pid_mode != current_pid_mode: + return True + + def compare_image(self, container_info=None): + def parse_tag(tag): + splits = tag.rsplit('/', 1) + return splits[-1] + + container_info = container_info or self.get_container_info() + 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 + # compare name:tag + elif (parse_tag(self.params.get('image')) != + parse_tag(container_info['Config']['Image'])): + return True + + def compare_volumes(self, container_info): + def check_slash(string): + if not string: + return string + if string[-1] != '/': + return string + '/' + else: + return string + + raw_volumes, binds = self.generate_volumes() + raw_vols, current_binds = self.generate_volumes( + container_info['HostConfig'].get('Binds')) + + current_vols = [check_slash(vol) for vol in raw_vols if vol] + volumes = [check_slash(vol) for vol in raw_volumes if vol] + + if not volumes: + volumes = list() + if not current_vols: + current_vols = list() + if not current_binds: + current_binds = list() + + volumes.sort() + current_vols.sort() + + if set(volumes).symmetric_difference(set(current_vols)): + return True + + new_binds = list() + new_current_binds = list() + if binds: + for k, v in binds.items(): + k = check_slash(k) + v['bind'] = check_slash(v['bind']) + new_binds.append( + "{}:{}:{}".format(k, v['bind'], v['mode'])) + + if current_binds: + for k, v in current_binds.items(): + k = check_slash(k) + v['bind'] = check_slash(v['bind']) + if 'ro' in v['mode']: + v['mode'] = 'ro' + else: + v['mode'] = 'rw' + new_current_binds.append( + "{}:{}:{}".format(k, v['bind'], v['mode'][0:2])) + + new_binds.sort() + new_current_binds.sort() + + if set(new_binds).symmetric_difference(set(new_current_binds)): + 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 it 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 (except ulimits) currently + # supported resources are '' or 0 - both falsey. + return True + + def compare_config(self): + try: + container = self.pc.containers.get(self.params['name']) + container.reload() + if container.status != 'running': + return True + + rc, raw_output = container.exec_run(COMPARE_CONFIG_CMD, + user='root') + except APIError as e: + if e.is_client_error(): + return True + else: + raise + # Exit codes: + # 0: not changed + # 1: changed + # else: error + if rc == 0: + return False + elif rc == 1: + return True + else: + raise Exception('Failed to compare container configuration: ' + 'ExitCode: %s Message: %s' % + (rc, raw_output.decode('utf-8'))) + + def pull_image(self): + args = self.prepare_image_args() + old_image = self.check_image() + + try: + image = self.pc.images.pull(**args) + + if image.attrs == {}: + self.module.fail_json( + msg="The requested image does not exist: {}".format( + self.params['image']), + failed=True + ) + self.changed = old_image != image.attrs + except APIError as e: + self.module.fail_json( + msg="Unknown error message: {}".format( + str(e)), + failed=True + ) + + def remove_container(self): + self.changed |= self.systemd.remove_unit_file() + container = self.check_container() + if container: + try: + container.remove(force=True) + except APIError: + if self.check_container(): + raise + + def build_ulimits(self, ulimits): + ulimits_opt = [] + for key, value in ulimits.items(): + soft = value.get('soft') + hard = value.get('hard') + # Converted to simple dictionary instead of Ulimit type + ulimits_opt.append(dict(Name=key, + Soft=soft, + Hard=hard)) + return ulimits_opt + + def create_container(self): + args = self.prepare_container_args() + container = self.pc.containers.create(**args) + if container.attrs == {}: + data = container.to_dict() + self.module.fail_json(failed=True, msg="Creation failed", **data) + else: + self.changed |= self.systemd.create_unit_file() + return container + + def recreate_or_restart_container(self): + strategy = self.params.get( + 'environment', dict()).get('KOLLA_CONFIG_STRATEGY') + + container = self.get_container_info() + if not container: + self.start_container() + return + + if strategy == 'COPY_ONCE' or self.check_container_differs(): + self.ensure_image() + + self.stop_container() + self.remove_container() + self.start_container() + + elif strategy == 'COPY_ALWAYS': + self.restart_container() + + def start_container(self): + self.ensure_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 container.status != 'running': + self.changed = True + if self.params.get('restart_policy') == 'no': + container = self.check_container() + container.start() + else: + self.systemd.create_unit_file() + if not self.systemd.start(): + self.module.fail_json( + changed=True, + msg="Container timed out", + **self.check_container().attrs) + + if not self.params.get('detach'): + container = self.check_container() + rc = container.wait() + + stdout = [line.decode() for line in container.logs(stdout=True, + stderr=False)] + stderr = [line.decode() for line in container.logs(stdout=False, + stderr=True)] + + self.result['rc'] = rc + self.result['stdout'] = "\n".join(stdout) if len(stdout) else "" + self.result['stderr'] = "\n".join(stderr) if len(stderr) else "" + + 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 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: + ignore_missing = self.params.get('ignore_missing') + if not ignore_missing: + self.module.fail_json( + msg="No such container: {} to stop".format(name)) + elif not (container.status == 'exited' or + container.status == 'stopped'): + self.changed = True + if self.params.get('restart_policy') != 'no': + self.systemd.create_unit_file() + self.systemd.stop() + else: + container.stop(timeout=str(graceful_timeout)) + + def stop_and_remove_container(self): + container = self.check_container() + + if container: + self.stop_container() + self.remove_container() + + def restart_container(self): + container = self.check_container() + + if not container: + self.module.fail_json( + msg="No such container: {}".format(self.params.get('name')) + ) + else: + self.changed = True + self.systemd.create_unit_file() + + if not self.systemd.restart(): + self.module.fail_json( + changed=True, + msg="Container timed out", + **container.attrs) + + def create_volume(self): + if not self.check_volume(): + self.changed = True + args = dict( + name=self.params.get('name'), + driver='local' + ) + + vol = self.pc.volumes.create(**args) + self.result = vol.attrs + + def remove_volume(self): + if self.check_volume(): + self.changed = True + try: + self.pc.volumes.remove(self.params.get('name')) + except APIError as e: + if e.status_code == 409: + self.module.fail_json( + failed=True, + msg="Volume named '{}' is currently in-use".format( + self.params.get('name') + ) + ) + else: + self.module.fail_json( + failed=True, + msg="Internal error: {}".format( + e.explanation + ) + ) + raise + + def remove_image(self): + if self.check_image(): + image = self.pc.images.get(self.params['image']) + self.changed = True + try: + image.remove() + except APIError as e: + if e.status_code == 409: + self.module.fail_json( + failed=True, + msg="Image '{}' is currently in-use".format( + self.params.get('image') + ) + ) + else: + self.module.fail_json( + failed=True, + msg="Internal error: {}".format( + str(e) + ) + ) + raise + + def ensure_image(self): + if not self.check_image(): + self.pull_image() diff --git a/ansible/module_utils/kolla_systemd_worker.py b/ansible/module_utils/kolla_systemd_worker.py index 72c50b316..58cd1338b 100644 --- a/ansible/module_utils/kolla_systemd_worker.py +++ b/ansible/module_utils/kolla_systemd_worker.py @@ -21,15 +21,15 @@ TEMPLATE = '''# ${service_name} # autogenerated by Kolla-Ansible [Unit] -Description=docker ${service_name} -After=docker.service -Requires=docker.service -StartLimitIntervalSec=${restart_timeout} +Description=${engine} ${service_name} +After=${deps} +Requires=${deps} +StartLimitInterval=${restart_timeout} StartLimitBurst=${restart_retries} [Service] -ExecStart=/usr/bin/docker start -a ${name} -ExecStop=/usr/bin/docker stop ${name} -t ${graceful_timeout} +ExecStart=/usr/bin/${engine} start -a ${name} +ExecStop=/usr/bin/${engine} stop ${name} -t ${graceful_timeout} Restart=${restart_policy} RestartSec=${restart_duration} @@ -46,6 +46,12 @@ class SystemdWorker(object): if not name: return None + container_engine = params.get('container_engine') + if container_engine == 'docker': + dependencies = 'docker.service' + else: + dependencies = 'network-online.target' + restart_policy = params.get('restart_policy', 'no') if restart_policy == 'unless-stopped': restart_policy = 'always' @@ -62,8 +68,8 @@ class SystemdWorker(object): self.container_dict = dict( name=name, service_name='kolla-' + name + '-container.service', - engine='docker', - deps='docker.service', + engine=container_engine, + deps=dependencies, graceful_timeout=params.get('graceful_timeout'), restart_policy=restart_policy, restart_timeout=restart_timeout, |
