9a3f463345
This change adds basic deployment based on Podman container manager as an alternative to Docker. Signed-off-by: Ivan Halomi <i.halomi@partner.samsung.com> Signed-off-by: Martin Hiner <m.hiner@partner.samsung.com> Signed-off-by: Petr Tuma <p.tuma@partner.samsung.com> Change-Id: I2b52964906ba8b19b8b1098717b9423ab954fa3d Depends-On: Ie4b4c1cf8fe6e7ce41eaa703b423dedcb41e3afc
675 lines
23 KiB
Python
675 lines
23 KiB
Python
# 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()
|