mirror of
https://github.com/univention/ansible-modules.git
synced 2025-10-26 11:19:34 +00:00
593 lines
22 KiB
Python
Executable File
593 lines
22 KiB
Python
Executable File
#!/usr/bin/python
|
|
# -*- coding: utf-8 -*-
|
|
|
|
# Copyright: (c) 2020-2023, Univention GmbH
|
|
# Written by Lukas Zumvorde <zumvorde@univention.de>,
|
|
# Jan-Luca Kiok <kiok@univention.de>, Melf Clausen <melf.clausen.extern@univention.de>,
|
|
# Tim Breidenbach <breidenbach@univention.de>
|
|
# Based on univention_apps module written by Alexander Ulpts <ulpts@univention.de>
|
|
|
|
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
|
|
|
import re
|
|
import os
|
|
import json
|
|
import tempfile
|
|
from ansible.module_utils.basic import AnsibleModule
|
|
from distutils.version import LooseVersion
|
|
|
|
DOCUMENTATION = '''
|
|
---
|
|
module: univention_app
|
|
version_added: "0.1.3"
|
|
short_description: "Installs and removes apps on Univention Corporate Server"
|
|
extends_documentation_fragment: ''
|
|
description:
|
|
- Allows ansible to control installation, removal, update and configuration of ucs-apps
|
|
notes:
|
|
- none
|
|
requirements: [ ]
|
|
author: Stefan Ahrens, Melf Clausen
|
|
options:
|
|
name:
|
|
description:
|
|
- 'The name of the app'
|
|
required: true
|
|
state:
|
|
description:
|
|
- 'The desired state of the app / present, absent, started, stopped'
|
|
required: true
|
|
version:
|
|
description:
|
|
- 'The desired version of the app / number or "latest" if not specified,
|
|
current version is preserved if app present, latest installed if app absent / downgrade will throw error'
|
|
auth_username:
|
|
description:
|
|
- 'The name of the user with witch to install apps (usually domain-admin)'
|
|
required: true
|
|
auth_password:
|
|
description:
|
|
- 'The password needed to install apps (usually domain-admin)'
|
|
required: true
|
|
config:
|
|
- 'The given configuration the App should have'
|
|
required: false
|
|
stall:
|
|
- 'Whether an App should be stalled or unstalled'
|
|
required: false
|
|
update_app_lists:
|
|
description:
|
|
- 'Updates the list of apps and their versions - Only runs when app is installed or updated'
|
|
required: false
|
|
default: True
|
|
|
|
'''
|
|
|
|
EXAMPLES = '''
|
|
- name: Install ox-connector
|
|
univention_app:
|
|
name: ox-connector
|
|
state: present
|
|
auth_username: Administrator
|
|
auth_password: secret
|
|
|
|
- name: remove ox-connector
|
|
univention_app:
|
|
name: ox-connector
|
|
state: absent
|
|
auth_username: Administrator
|
|
auth_password: secret
|
|
|
|
- name: stop ox-connector
|
|
univention_app:
|
|
name: ox-connector
|
|
state: stopped
|
|
auth_username: Administrator
|
|
auth_password: secret
|
|
|
|
- name: upgrade ox-connector or install in specified version
|
|
univention_app:
|
|
name: ox-connector
|
|
state: present
|
|
version: 2.1.1
|
|
auth_username: Administrator
|
|
auth_password: secret
|
|
|
|
- name: configure ox-connector
|
|
univention_app:
|
|
name: ox-connector
|
|
state: present
|
|
auth_username: Administrator
|
|
auth_password: univention
|
|
config:
|
|
EXAMPLE_PARAMETER: 'ExampleValue'
|
|
|
|
- name: stall ox-connector
|
|
univention_app:
|
|
name: ox-connector
|
|
state: present
|
|
auth_username: Administrator
|
|
auth_password: univention
|
|
stall: "stalled"
|
|
'''
|
|
|
|
RETURN = '''
|
|
msg:
|
|
description: a return message
|
|
returned: success, failure
|
|
type: str
|
|
sample: Non-UCS-system detected. Nothing to do here.
|
|
changed:
|
|
description: if any changes were performed
|
|
returned: success
|
|
type: bool
|
|
sample: True
|
|
'''
|
|
|
|
|
|
def check_ucs():
|
|
''' Check if system is actually UCS, return bool '''
|
|
return os.system("dpkg -s univention-appcenter") == 0
|
|
|
|
|
|
def ansible_exec(action, appname=None, keyfile=None, username=None,
|
|
desired_update=None, configuration=None):
|
|
''' runs ansible's run_command(), choose from actions install, remove, upgrade '''
|
|
univention_app_cmd = {
|
|
'list': "univention-app list --ids-only",
|
|
'update_app_lists': "univention-app update",
|
|
'list-app': "univention-app list {}".format(appname),
|
|
'info': "univention-app info --as-json",
|
|
'install': ("univention-app {} --noninteractive --username {} --pwdfile {} {}='{}' {}"
|
|
.format(action, username, keyfile, appname, desired_update, configuration)),
|
|
'remove': ("univention-app {} --noninteractive --username {} --pwdfile {} {}"
|
|
.format(action, username, keyfile, appname)),
|
|
'upgrade': ("univention-app {} --noninteractive --username {} --pwdfile {} {}='{}'"
|
|
.format(action, username, keyfile, appname, desired_update)),
|
|
'status': ("univention-app {} {}"
|
|
.format(action, appname)),
|
|
'start': ("univention-app start {}"
|
|
.format(appname)),
|
|
'stop': ("univention-app stop {}"
|
|
.format(appname)),
|
|
'get_configuration': "univention-app configure {} --list".format(appname),
|
|
'configure': "univention-app {} {} {}".format(action, appname, configuration),
|
|
'stall': "univention-app {} {}".format(action, appname),
|
|
'undo_stall': "univention-app stall {} --undo".format(appname),
|
|
}
|
|
return module.run_command(univention_app_cmd[action])
|
|
|
|
|
|
def get_apps_status():
|
|
''' Get the status of available, installed and upgradable apps and return lists'''
|
|
def get_app_list():
|
|
''' exec to get list of all available apps on this system '''
|
|
return ansible_exec(action='list')[1]
|
|
|
|
def get_app_info():
|
|
''' exec to get lists of installed and upgradable apps on this system '''
|
|
app_info = ansible_exec(action='info')
|
|
try:
|
|
app_infos = json.loads(app_info[1])
|
|
except Exception as e:
|
|
module.fail_json(msg="unable to parse json: {}".format(e))
|
|
return app_infos['installed'], app_infos['upgradable']
|
|
|
|
global AVAILABLE_APPS_LIST
|
|
global INSTALLED_APPS_LIST
|
|
global UPGRADABLE_APPS_LIST
|
|
AVAILABLE_APPS_LIST = get_app_list()
|
|
INSTALLED_APPS_LIST, UPGRADABLE_APPS_LIST = get_app_info()
|
|
|
|
|
|
def get_app_info():
|
|
''' exec to get lists of installed and upgradable apps on this system '''
|
|
app_info = ansible_exec(action='info')
|
|
try:
|
|
app_infos = json.loads(app_info[1])
|
|
except Exception as e:
|
|
module.fail_json(msg="unable to parse json: {}".format(e))
|
|
return app_infos['installed'], app_infos['upgradable']
|
|
|
|
|
|
# checks what version of app is currently installed
|
|
def check_app_version(_appname):
|
|
app_version = None
|
|
installed_apps_version, _ = get_app_info()
|
|
for app_info in installed_apps_version:
|
|
if _appname in app_info:
|
|
app_version = app_info.split('=')[-1]
|
|
break
|
|
return app_version
|
|
|
|
|
|
def get_and_sort_versions(_appname):
|
|
get_versions = ansible_exec(action='list-app', appname=_appname)[1]
|
|
available_app_versions = re.findall(
|
|
r'\b(\d+\.\d+(?:\.\d+)*(?:-\d+)?(?:-\D+\d+)?(?:\s*v\d+)?)\b', get_versions)
|
|
|
|
available_app_versions.sort(
|
|
key=lambda s: list(map(int, re.split(r'\D+', s))))
|
|
return available_app_versions
|
|
|
|
|
|
def check_target_app_version(_appname, _version):
|
|
if _version == 'current':
|
|
if check_app_present(_appname):
|
|
return check_app_version(_appname)
|
|
elif check_app_absent(_appname):
|
|
_version = 'latest'
|
|
|
|
if _version == 'latest':
|
|
available_app_versions = get_and_sort_versions(_appname)
|
|
latest_version = available_app_versions[-1]
|
|
return latest_version
|
|
return _version
|
|
|
|
|
|
# check if app status is started or stopped
|
|
def check_app_status(_appname):
|
|
app_status = ansible_exec(action='status', appname=_appname)[1]
|
|
if 'Active: active' in app_status:
|
|
return 'started'
|
|
elif 'Active: inactive' in app_status:
|
|
return 'stopped'
|
|
else:
|
|
return 'unknown'
|
|
|
|
|
|
def parse_current_configuration(_config):
|
|
config_lines = _config.split('\n')
|
|
config_dict = {}
|
|
for line in config_lines:
|
|
key_value_pair = line.split(": ", 1)
|
|
if len(key_value_pair) == 2:
|
|
key, value = key_value_pair
|
|
config_dict[key] = value.split(' ')[0].strip("'")
|
|
return config_dict
|
|
|
|
|
|
def format_new_conf(_configuration):
|
|
_conf_str = ""
|
|
if len(_configuration) > 0:
|
|
_conf_str = "--set "
|
|
for setting, value in _configuration.items():
|
|
if value == "" or ' ' in value:
|
|
_conf_str += '{}="{}" '.format(setting, value)
|
|
else:
|
|
_conf_str += '{}={} '.format(setting, value)
|
|
return _conf_str
|
|
|
|
|
|
def check_app_present(_appname):
|
|
''' check if a given app is in INSTALLED_APPS_LIST, return bool '''
|
|
return _appname in AVAILABLE_APPS_LIST and list(filter(lambda x: _appname in x, INSTALLED_APPS_LIST))
|
|
|
|
|
|
def check_app_absent(_appname):
|
|
''' check if a given app is NOT in INSTALLED_APPS_LIST, return bool '''
|
|
return _appname in AVAILABLE_APPS_LIST and not list(filter(lambda x: _appname in x, INSTALLED_APPS_LIST))
|
|
|
|
|
|
def check_app_upgradeable(_appname):
|
|
''' check if a given app is in UPGRADABLE_APPS_LIST, return bool '''
|
|
return _appname in AVAILABLE_APPS_LIST and bool(filter(lambda x: _appname in x, UPGRADABLE_APPS_LIST))
|
|
|
|
|
|
def generate_tmp_auth_file(_data):
|
|
''' generate a temporary auth-file and return path, MUST BE DELETED '''
|
|
fileTemp = tempfile.NamedTemporaryFile(delete=False, mode='w')
|
|
fileTemp.write(_data)
|
|
fileTemp.close()
|
|
return fileTemp.name
|
|
|
|
|
|
def update_app_lists():
|
|
return ansible_exec(action='update_app_lists')
|
|
|
|
|
|
def start_app(_appname):
|
|
ansible_exec(action='start', appname=_appname)
|
|
|
|
|
|
def stop_app(_appname):
|
|
ansible_exec(action='stop', appname=_appname)
|
|
|
|
|
|
def install_app(_appname, _authfile, _desired_version, _auth_username, _configuration):
|
|
''' installs an app with given name and path to auth-file, uses ansible_exec()
|
|
and returns tuple of exit-code and stdout '''
|
|
return ansible_exec(action='install', appname=_appname, keyfile=_authfile, username=_auth_username,
|
|
desired_update=_desired_version, configuration=format_new_conf(_configuration))
|
|
|
|
|
|
def remove_app(_appname, _authfile, _auth_username):
|
|
''' removes an app with given name and path to auth-file, uses ansible_exec()
|
|
and returns tuple of exit-code and stdout'''
|
|
return ansible_exec(action='remove', appname=_appname, keyfile=_authfile, username=_auth_username)
|
|
|
|
|
|
def upgrade_app(_appname, _authfile, _desired_version, _auth_username):
|
|
''' upgrades an app with given name and path to auth-file, uses ansible_exec()
|
|
and returns tuple of exit-code and stdout'''
|
|
return ansible_exec(action='upgrade', appname=_appname, keyfile=_authfile,
|
|
username=_auth_username, desired_update=_desired_version)
|
|
|
|
|
|
def stall_app(_appname, _authfile):
|
|
''' stalls an app with given name and path to auth-file, uses ansible_exec()
|
|
and return tuple of exit-code and stdout. '''
|
|
return ansible_exec(action='stall', appname=_appname, keyfile=_authfile)
|
|
|
|
|
|
def undo_stall_app(_appname, _authfile):
|
|
''' undos the stalling of an app with given name and path to auth-file, uses ansible_exec()
|
|
and return tuple of exit-code and stdout. '''
|
|
return ansible_exec(action='undo_stall', appname=_appname, keyfile=_authfile)
|
|
|
|
|
|
def get_app_configuration(_appname):
|
|
''' get current app configuration, uses ansible_exec()
|
|
and return a dictionary with configuration parameters. '''
|
|
config_output = ansible_exec(
|
|
action='get_configuration', appname=_appname)[1]
|
|
current_app_configuration = parse_current_configuration(config_output)
|
|
return current_app_configuration
|
|
|
|
|
|
def check_config_and_return_differences(_current_config, _app_target_config):
|
|
# Create case-insensitive versions of both configs
|
|
current_config_lower = {k.lower(): v for k, v in _current_config.items()}
|
|
target_config_lower = {k.lower(): v for k, v in _app_target_config.items()}
|
|
# Check if input parameters exist in the app and if params changed
|
|
new_params = {}
|
|
for param in _app_target_config:
|
|
lower_param = param.lower()
|
|
if lower_param not in current_config_lower:
|
|
raise ValueError(
|
|
"The parameter {} does not exist in the app".format(param))
|
|
if current_config_lower[lower_param] != target_config_lower[lower_param]:
|
|
# Get the original key from the current_config
|
|
original_key = [
|
|
key for key in _current_config if key.lower() == lower_param][0]
|
|
new_params[original_key] = _app_target_config[param]
|
|
|
|
return new_params
|
|
|
|
|
|
def configure_app(_appname, _configuration):
|
|
''' set app configuration, uses ansible_exec()
|
|
and return tuple of exit-code and stdout. '''
|
|
return ansible_exec(action='configure', appname=_appname, configuration=format_new_conf(_configuration))
|
|
|
|
|
|
def main():
|
|
''' main() is an entry-point for ansible which checks app-status and installs,
|
|
upgrades, or removes the app based on ansible state and name-parameters '''
|
|
global module # declare ansible-module and parameters globally
|
|
module = AnsibleModule(
|
|
argument_spec=dict(
|
|
name=dict(
|
|
type='str',
|
|
required=True,
|
|
aliases=['app']
|
|
),
|
|
state=dict(
|
|
type='str',
|
|
default='present',
|
|
choices=['present', 'absent', 'started', 'stopped']
|
|
),
|
|
stall=dict(
|
|
type='str',
|
|
required=False,
|
|
choices=["stalled", "unstalled"]
|
|
),
|
|
auth_password=dict(
|
|
type="str",
|
|
required=True,
|
|
no_log=True
|
|
),
|
|
auth_username=dict(
|
|
type="str",
|
|
required=True
|
|
),
|
|
version=dict(
|
|
type='str',
|
|
required=False,
|
|
default='current'
|
|
),
|
|
config=dict(
|
|
type='dict',
|
|
required=False
|
|
),
|
|
update_app_lists=dict(
|
|
type='bool',
|
|
default=True,
|
|
required=False
|
|
)
|
|
),
|
|
# mutually_exclusive=[[]],
|
|
# required_one_of=[[]],
|
|
supports_check_mode=False, # this has to be changed. Use -dry-run were necessary
|
|
)
|
|
|
|
# This module should only run on UCS-systems
|
|
if not check_ucs():
|
|
return module.exit_json(
|
|
changed=True,
|
|
msg='Non-UCS-system detected. Nothing to do here.'
|
|
)
|
|
|
|
# update app lists
|
|
def update_lists():
|
|
if module.params.get('update_app_lists'):
|
|
_update_lists = update_app_lists()
|
|
if _update_lists[0] != 0:
|
|
return module.fail_json(
|
|
msg='''
|
|
An Error occured running univention-app update.
|
|
To disable updating app lists set "update_app_lists" to False
|
|
'''
|
|
)
|
|
# gather infos and vars
|
|
get_apps_status()
|
|
app_status_target = module.params.get('state') # desired state of the app
|
|
app_name = module.params.get('name') # name of the app
|
|
auth_password = module.params.get(
|
|
'auth_password') # password for domain-admin
|
|
auth_username = module.params.get(
|
|
'auth_username')
|
|
app_present = check_app_present(app_name)
|
|
app_absent = check_app_absent(app_name)
|
|
app_stall_target = module.params.get('stall')
|
|
app_target_version = check_target_app_version(
|
|
app_name, module.params.get('version'))
|
|
app_status = check_app_status(app_name)
|
|
app_target_config = module.params.get('config')
|
|
module_changed = False
|
|
config_changed = False
|
|
# User info if config settings are changed
|
|
new_config_msg = None
|
|
|
|
# some basic logic-checks
|
|
if not app_absent and not app_present: # this means the app does not exist
|
|
module.fail_json(msg=("app {} does not exist. Please choose from following options:\n{}"
|
|
.format(app_name, str(AVAILABLE_APPS_LIST))))
|
|
if app_absent and app_present: # schroedinger's app-status
|
|
module.fail_json(
|
|
msg="an error occured while getting the status of {}".format(app_name))
|
|
|
|
if app_status_target != 'absent' and not app_present:
|
|
auth_file = generate_tmp_auth_file(auth_password)
|
|
update_lists()
|
|
config = {}
|
|
if app_target_config:
|
|
default_config = get_app_configuration(app_name)
|
|
try:
|
|
config = check_config_and_return_differences(
|
|
default_config, app_target_config)
|
|
except ValueError as e:
|
|
module.fail_json(
|
|
module_changed=True, msg="The parameter '{}' does not exist on app {}".format(e, app_name))
|
|
|
|
try:
|
|
_install_app = install_app(
|
|
app_name, auth_file, app_target_version, auth_username, config)
|
|
if _install_app[0] == 0:
|
|
module_changed = True
|
|
if len(config) > 0:
|
|
new_config_msg = '. The following configuration options were changed: {}'.format(
|
|
config)
|
|
config_changed = True
|
|
else:
|
|
module.fail_json(
|
|
msg="an error occured while installing {}".format(app_target_version))
|
|
finally:
|
|
os.remove(auth_file)
|
|
|
|
elif app_status_target == 'absent' and app_present:
|
|
auth_file = generate_tmp_auth_file(auth_password)
|
|
try:
|
|
_remove_app = remove_app(app_name, auth_file, auth_username)
|
|
if _remove_app[0] == 0:
|
|
module.exit_json(
|
|
changed=True, msg="App {} was successfully deinstalled.".format(app_name))
|
|
else:
|
|
module.fail_json(
|
|
msg="an error occured while uninstalling {}".format(app_name))
|
|
finally:
|
|
os.remove(auth_file)
|
|
|
|
elif app_status_target == 'absent' and app_absent:
|
|
module.exit_json(
|
|
changed=False, msg="App {} not installed. No change.".format(app_name))
|
|
app_version = check_app_version(app_name) # check App version
|
|
if app_status_target != 'absent' and LooseVersion(app_target_version) > LooseVersion(app_version):
|
|
auth_file = generate_tmp_auth_file(auth_password)
|
|
update_lists()
|
|
try:
|
|
available_app_versions = get_and_sort_versions(app_name)
|
|
# check how many versions between current and target
|
|
versions_to_update = available_app_versions[available_app_versions.index(
|
|
app_version)+1:available_app_versions.index(app_target_version)+1]
|
|
for version in versions_to_update:
|
|
# Update App & check if Update successfull
|
|
_upgrade_app = upgrade_app(
|
|
app_name, auth_file, version, auth_username)
|
|
if _upgrade_app[0] == 0:
|
|
continue
|
|
else:
|
|
module.fail_json(
|
|
msg="an error occured while upgrading {}".format(app_name))
|
|
module_changed = True
|
|
finally:
|
|
os.remove(auth_file)
|
|
|
|
if app_status_target != 'absent' and not config_changed and app_target_config:
|
|
current_config = get_app_configuration(app_name)
|
|
# check if keys exist and params changed
|
|
try:
|
|
new_params = check_config_and_return_differences(
|
|
current_config, app_target_config)
|
|
except ValueError as e:
|
|
module.fail_json(
|
|
module_changed=True, msg="The parameter '{}' does not exist on app {}".format(e, app_name))
|
|
if len(new_params) > 0:
|
|
_configure_app = configure_app(app_name, new_params)
|
|
if not _configure_app[0] == 0:
|
|
module.fail_json(msg="An error occured while configuring {} with configuration:{}".format(
|
|
app_name, new_params))
|
|
else:
|
|
module_changed = True
|
|
new_config_msg = '. The following configuration options were changed: {}'.format(
|
|
new_params)
|
|
|
|
elif app_status_target != 'absent' and LooseVersion(app_target_version) < LooseVersion(app_version):
|
|
module.fail_json(
|
|
msg="""The current version of {} is higher than the desired version.
|
|
The version currently installed is: {}""".format(app_name, app_version))
|
|
|
|
if app_status_target in ['started', 'stopped']:
|
|
if app_status_target == 'started' and app_status != 'started':
|
|
start_app(app_name)
|
|
module_changed = True
|
|
elif app_status_target == 'stopped' and app_status != 'stopped':
|
|
stop_app(app_name)
|
|
module_changed = True
|
|
|
|
if app_present and app_stall_target == 'stalled':
|
|
# stall_app(app_name)
|
|
auth_file = generate_tmp_auth_file(auth_password)
|
|
try:
|
|
_stall_app = stall_app(app_name, auth_file)
|
|
if _stall_app[0] == 0:
|
|
module_changed = True
|
|
else:
|
|
module.fail_json(
|
|
msg="an error occurred while stalling {}".format(app_name))
|
|
finally:
|
|
os.remove(auth_file)
|
|
elif app_present and app_stall_target == 'unstalled':
|
|
# undo_stall_app(app_name)
|
|
auth_file = generate_tmp_auth_file(auth_password)
|
|
try:
|
|
_undo_stall_app = undo_stall_app(app_name, auth_file)
|
|
if _undo_stall_app[0] == 0:
|
|
module_changed = True
|
|
else:
|
|
module.fail_json(
|
|
msg="an error occurred while undoing the stall {}".format(app_name))
|
|
finally:
|
|
os.remove(auth_file)
|
|
|
|
if module_changed:
|
|
module.exit_json(changed=module_changed, msg="{} is {} in version {} {}".format(
|
|
app_name, app_status_target, check_app_version(app_name), new_config_msg))
|
|
else:
|
|
module.exit_json(changed=module_changed, msg="No changes for {}".format(
|
|
app_name))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|