From 997761878c311ef1e76b32c05a90c53b1c3af197 Mon Sep 17 00:00:00 2001 From: Salvatore Mesoraca Date: Sun, 26 Mar 2023 09:30:34 +0200 Subject: [PATCH] Add module to manipulate KDE config files using kwriteconfig (#6182) * Add module to manipulate KDE config files using kwriteconfig * Fix license issues * Apply suggestions from code review Co-authored-by: Felix Fontein * Add smeso as kdeconfig.py maintainer * Fix attributes fragment name * Fix test * Do not use shutil.chown It isn't available on old Python versions * Apply suggestions from code review Co-authored-by: Felix Fontein --------- Co-authored-by: Felix Fontein --- .github/BOTMETA.yml | 2 + plugins/modules/kdeconfig.py | 277 +++++++++++++ tests/integration/targets/kdeconfig/aliases | 5 + .../targets/kdeconfig/meta/main.yml | 7 + .../kdeconfig/tasks/files/kwriteconf_fake | 38 ++ .../targets/kdeconfig/tasks/main.yml | 369 ++++++++++++++++++ 6 files changed, 698 insertions(+) create mode 100644 plugins/modules/kdeconfig.py create mode 100644 tests/integration/targets/kdeconfig/aliases create mode 100644 tests/integration/targets/kdeconfig/meta/main.yml create mode 100755 tests/integration/targets/kdeconfig/tasks/files/kwriteconf_fake create mode 100644 tests/integration/targets/kdeconfig/tasks/main.yml diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 69ccbdc147..8377331216 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -664,6 +664,8 @@ files: ignore: DWSR labels: jira maintainers: Slezhuk tarka pertoft + $modules/kdeconfig.py: + maintainers: smeso $modules/kernel_blacklist.py: maintainers: matze $modules/keycloak_: diff --git a/plugins/modules/kdeconfig.py b/plugins/modules/kdeconfig.py new file mode 100644 index 0000000000..42a08dd64d --- /dev/null +++ b/plugins/modules/kdeconfig.py @@ -0,0 +1,277 @@ +#!/usr/bin/python + +# Copyright (c) 2023, Salvatore Mesoraca +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +DOCUMENTATION = r''' +--- +module: kdeconfig +short_description: Manage KDE configuration files +version_added: "6.5.0" +description: + - Add or change individual settings in KDE configuration files. + - It uses B(kwriteconfig) under the hood. + +options: + path: + description: + - Path to the config file. If the file does not exist it will be created. + type: path + required: true + kwriteconfig_path: + description: + - Path to the kwriteconfig executable. If not specified, Ansible will try + to discover it. + type: path + values: + description: + - List of values to set. + type: list + elements: dict + suboptions: + group: + description: + - The option's group. One between this and I(groups) is required. + type: str + groups: + description: + - List of the option's groups. One between this and I(group) is required. + type: list + elements: str + key: + description: + - The option's name. + type: str + required: true + value: + description: + - The option's value. One between this and I(bool_value) is required. + type: str + bool_value: + description: + - Boolean value. + - One between this and I(value) is required. + type: bool + required: true + backup: + description: + - Create a backup file. + type: bool + default: false +extends_documentation_fragment: + - files + - community.general.attributes +attributes: + check_mode: + support: full + diff_mode: + support: full +requirements: + - kwriteconfig +author: + - Salvatore Mesoraca (@smeso) +''' + +EXAMPLES = r''' +- name: Ensure "Homepage=https://www.ansible.com/" in group "Branding" + community.general.kdeconfig: + path: /etc/xdg/kickoffrc + values: + - group: Branding + key: Homepage + value: https://www.ansible.com/ + mode: '0644' + +- name: Ensure "KEY=true" in groups "Group" and "Subgroup", and "KEY=VALUE" in Group2 + community.general.kdeconfig: + path: /etc/xdg/someconfigrc + values: + - groups: [Group, Subgroup] + key: KEY + bool_value: true + - group: Group2 + key: KEY + value: VALUE + backup: true +''' + +RETURN = r''' # ''' + +import os +import shutil +import tempfile +import traceback + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.common.text.converters import to_bytes, to_text + + +class TemporaryDirectory(object): + """Basic backport of tempfile.TemporaryDirectory""" + + def __init__(self, suffix="", prefix="tmp", dir=None): + self.name = None + self.name = tempfile.mkdtemp(suffix, prefix, dir) + + def __enter__(self): + return self.name + + def rm(self): + if self.name: + shutil.rmtree(self.name, ignore_errors=True) + self.name = None + + def __exit__(self, exc, value, tb): + self.rm() + + def __del__(self): + self.rm() + + +def run_kwriteconfig(module, cmd, path, groups, key, value): + """Invoke kwriteconfig with arguments""" + args = [cmd, '--file', path, '--key', key] + for group in groups: + args.extend(['--group', group]) + if isinstance(value, bool): + args.extend(['--type', 'bool']) + if value: + args.append('true') + else: + args.append('false') + else: + args.append(value) + module.run_command(args, check_rc=True) + + +def run_module(module, tmpdir, kwriteconfig): + result = dict(changed=False, msg='OK', path=module.params['path']) + b_path = to_bytes(module.params['path']) + tmpfile = os.path.join(tmpdir, 'file') + b_tmpfile = to_bytes(tmpfile) + diff = dict( + before='', + after='', + before_header=result['path'], + after_header=result['path'], + ) + try: + with open(b_tmpfile, 'wb') as dst: + try: + with open(b_path, 'rb') as src: + b_data = src.read() + except IOError: + result['changed'] = True + else: + dst.write(b_data) + try: + diff['before'] = to_text(b_data) + except UnicodeError: + diff['before'] = repr(b_data) + except IOError: + module.fail_json(msg='Unable to create temporary file', traceback=traceback.format_exc()) + + for row in module.params['values']: + groups = row['groups'] + if groups is None: + groups = [row['group']] + key = row['key'] + value = row['bool_value'] + if value is None: + value = row['value'] + run_kwriteconfig(module, kwriteconfig, tmpfile, groups, key, value) + + with open(b_tmpfile, 'rb') as tmpf: + b_data = tmpf.read() + try: + diff['after'] = to_text(b_data) + except UnicodeError: + diff['after'] = repr(b_data) + + result['changed'] = result['changed'] or diff['after'] != diff['before'] + + file_args = module.load_file_common_arguments(module.params) + + if module.check_mode: + if not result['changed']: + shutil.copystat(b_path, b_tmpfile) + uid, gid = module.user_and_group(b_path) + os.chown(b_tmpfile, uid, gid) + if module._diff: + diff = {} + else: + diff = None + result['changed'] = module.set_fs_attributes_if_different(file_args, result['changed'], diff=diff) + if module._diff: + result['diff'] = diff + module.exit_json(**result) + + if result['changed']: + if module.params['backup'] and os.path.exists(b_path): + result['backup_file'] = module.backup_local(result['path']) + try: + module.atomic_move(b_tmpfile, b_path) + except IOError: + module.ansible.fail_json(msg='Unable to move temporary file %s to %s, IOError' % (tmpfile, result['path']), traceback=traceback.format_exc()) + + if result['changed']: + module.set_fs_attributes_if_different(file_args, result['changed']) + else: + if module._diff: + diff = {} + else: + diff = None + result['changed'] = module.set_fs_attributes_if_different(file_args, result['changed'], diff=diff) + if module._diff: + result['diff'] = diff + module.exit_json(**result) + + +def main(): + single_value_arg = dict(group=dict(type='str'), + groups=dict(type='list', elements='str'), + key=dict(type='str', required=True, no_log=False), + value=dict(type='str'), + bool_value=dict(type='bool')) + required_alternatives = [('group', 'groups'), ('value', 'bool_value')] + module_args = dict( + values=dict(type='list', + elements='dict', + options=single_value_arg, + mutually_exclusive=required_alternatives, + required_one_of=required_alternatives, + required=True), + path=dict(type='path', required=True), + kwriteconfig_path=dict(type='path'), + backup=dict(type='bool', default=False), + ) + + module = AnsibleModule( + argument_spec=module_args, + add_file_common_args=True, + supports_check_mode=True, + ) + + kwriteconfig = None + if module.params['kwriteconfig_path'] is not None: + kwriteconfig = module.get_bin_path(module.params['kwriteconfig_path'], required=True) + else: + for progname in ('kwriteconfig5', 'kwriteconfig', 'kwriteconfig4'): + kwriteconfig = module.get_bin_path(progname) + if kwriteconfig is not None: + break + if kwriteconfig is None: + module.fail_json(msg='kwriteconfig is not installed') + for v in module.params['values']: + if not v['key']: + module.fail_json(msg="'key' cannot be empty") + with TemporaryDirectory(dir=module.tmpdir) as tmpdir: + run_module(module, tmpdir, kwriteconfig) + + +if __name__ == '__main__': + main() diff --git a/tests/integration/targets/kdeconfig/aliases b/tests/integration/targets/kdeconfig/aliases new file mode 100644 index 0000000000..12d1d6617e --- /dev/null +++ b/tests/integration/targets/kdeconfig/aliases @@ -0,0 +1,5 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +azp/posix/2 diff --git a/tests/integration/targets/kdeconfig/meta/main.yml b/tests/integration/targets/kdeconfig/meta/main.yml new file mode 100644 index 0000000000..982de6eb03 --- /dev/null +++ b/tests/integration/targets/kdeconfig/meta/main.yml @@ -0,0 +1,7 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +dependencies: + - setup_remote_tmp_dir diff --git a/tests/integration/targets/kdeconfig/tasks/files/kwriteconf_fake b/tests/integration/targets/kdeconfig/tasks/files/kwriteconf_fake new file mode 100755 index 0000000000..c29627257e --- /dev/null +++ b/tests/integration/targets/kdeconfig/tasks/files/kwriteconf_fake @@ -0,0 +1,38 @@ +#!/bin/sh + +# Copyright (c) 2023, Salvatore Mesoraca +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# This script is not supposed to correctly emulate +# kwriteconf output format. +# It only tries to emulate its behaviour from the +# point of view of the Ansible module. +# Which is: write something that depends on the arguments +# to the output file, unless we already wrote that before. + +set -e + +args="" +prev_was_file=0 +for var in "$@"; do + if [ $prev_was_file -eq 1 ]; then + fname="$var" + prev_was_file=0 + else + args="$args $var" + fi + if [ "$var" = "--file" ]; then + prev_was_file=1 + fi +done + +if [ "x$fname" = "x" ]; then + exit 1 +fi + +if [ -e "$fname" ]; then + grep -qF "$args" "$fname" && exit 0 +fi + +echo "$args" >> "$fname" diff --git a/tests/integration/targets/kdeconfig/tasks/main.yml b/tests/integration/targets/kdeconfig/tasks/main.yml new file mode 100644 index 0000000000..b66c269942 --- /dev/null +++ b/tests/integration/targets/kdeconfig/tasks/main.yml @@ -0,0 +1,369 @@ +--- +#################################################################### +# WARNING: These are designed specifically for Ansible tests # +# and should not be used as examples of how to write Ansible roles # +#################################################################### + +# Copyright (c) 2023, Salvatore Mesoraca +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Set paths + set_fact: + output_file: "{{ remote_tmp_dir }}/kdeconf" + kwriteconf_fake: "{{ remote_tmp_dir }}/kwriteconf" + +- name: Install fake kwriteconf + copy: + dest: "{{ kwriteconf_fake }}" + src: kwriteconf_fake + mode: 0755 + +- name: Simple test + kdeconfig: + path: "{{ output_file }}" + values: + - group: test + key: test1 + value: test2 + kwriteconfig_path: "{{ kwriteconf_fake }}" + register: result_simple + ignore_errors: true + +- name: Simple test - checks + assert: + that: + - result_simple is changed + - result_simple is not failed + +- name: Simple test - idempotence + kdeconfig: + path: "{{ output_file }}" + values: + - group: test + key: test1 + value: test2 + kwriteconfig_path: "{{ kwriteconf_fake }}" + register: result_simple_idem + ignore_errors: true + +- name: Simple test - idempotence - checks + assert: + that: + - result_simple_idem is not changed + - result_simple_idem is not failed + +- name: Reset + file: + path: "{{ output_file }}" + state: absent + +- name: Group and groups are mutually exclusive + kdeconfig: + path: "{{ output_file }}" + values: + - group: test + groups: [test2] + key: test1 + value: test2 + kwriteconfig_path: "{{ kwriteconf_fake }}" + register: result_group_mutex + ignore_errors: true + +- name: Group and groups are mutually exclusive - checks + assert: + that: + - result_group_mutex is not changed + - result_group_mutex is failed + - "result_group_mutex.msg == 'parameters are mutually exclusive: group|groups found in values'" + +- name: value and bool_value are mutually exclusive + kdeconfig: + path: "{{ output_file }}" + values: + - group: test + key: test1 + value: test2 + bool_value: true + kwriteconfig_path: "{{ kwriteconf_fake }}" + register: result_val_mutex + ignore_errors: true + +- name: value and bool_value are mutually exclusive - checks + assert: + that: + - result_val_mutex is not changed + - result_val_mutex is failed + - "result_val_mutex.msg == 'parameters are mutually exclusive: value|bool_value found in values'" + +- name: bool_value must be bool + kdeconfig: + path: "{{ output_file }}" + values: + - group: test + key: test1 + bool_value: thisisastring + kwriteconfig_path: "{{ kwriteconf_fake }}" + register: result_val_bool + ignore_errors: true + +- name: bool_value must be bool - checks + assert: + that: + - result_val_bool is not changed + - result_val_bool is failed + - "'is not a valid boolean' in result_val_bool.msg" + +- name: Multiple groups test + kdeconfig: + path: "{{ output_file }}" + values: + - groups: + - test + - test1 + - test2 + key: test3 + value: test4 + kwriteconfig_path: "{{ kwriteconf_fake }}" + register: result_groups + ignore_errors: true + +- name: Multiple groups test - checks + assert: + that: + - result_groups is changed + - result_groups is not failed + +- name: Multiple groups test - idempotence + kdeconfig: + path: "{{ output_file }}" + values: + - groups: + - test + - test1 + - test2 + key: test3 + value: test4 + kwriteconfig_path: "{{ kwriteconf_fake }}" + register: result_groups_idem + ignore_errors: true + +- name: Multiple groups test - idempotence - checks + assert: + that: + - result_groups_idem is not changed + - result_groups_idem is not failed + +- name: Reset + file: + path: "{{ output_file }}" + state: absent + +- name: Bool test + kdeconfig: + path: "{{ output_file }}" + values: + - group: test + key: test1 + bool_value: true + kwriteconfig_path: "{{ kwriteconf_fake }}" + register: result_bool + ignore_errors: true + +- name: Simple test - checks + assert: + that: + - result_bool is changed + - result_bool is not failed + +- name: Bool test - idempotence + kdeconfig: + path: "{{ output_file }}" + values: + - group: test + key: test1 + bool_value: on + kwriteconfig_path: "{{ kwriteconf_fake }}" + register: result_bool_idem + ignore_errors: true + +- name: Bool test - idempotence - checks + assert: + that: + - result_bool_idem is not changed + - result_bool_idem is not failed + +- name: Reset + file: + path: "{{ output_file }}" + state: absent + +- name: check_mode test + kdeconfig: + path: "{{ output_file }}" + values: + - group: test + key: test1 + value: test2 + - groups: [testx, testy] + key: testz + bool_value: on + kwriteconfig_path: "{{ kwriteconf_fake }}" + register: result_checkmode + ignore_errors: true + check_mode: true + diff: true + +- name: check_mode test file contents + slurp: + src: "{{ output_file }}" + register: check_mode_contents + ignore_errors: true + +- name: check_mode test - checks + assert: + that: + - result_checkmode is changed + - result_checkmode is not failed + - check_mode_contents is failed + +- name: check_mode test - apply + kdeconfig: + path: "{{ output_file }}" + values: + - group: test + key: test1 + value: test2 + - groups: [testx, testy] + key: testz + bool_value: on + kwriteconfig_path: "{{ kwriteconf_fake }}" + register: result_checkmode_apply + ignore_errors: true + check_mode: false + diff: true + +- name: check_mode test - apply - checks + assert: + that: + - result_checkmode_apply is changed + - result_checkmode_apply is not failed + - "result_checkmode_apply['diff']['after'] == result_checkmode['diff']['after']" + - "result_checkmode_apply['diff']['before'] == result_checkmode['diff']['before']" + +- name: check_mode test - idempotence + kdeconfig: + path: "{{ output_file }}" + values: + - group: test + key: test1 + value: test2 + - groups: [testx, testy] + key: testz + bool_value: on + kwriteconfig_path: "{{ kwriteconf_fake }}" + register: result_checkmode2 + ignore_errors: true + check_mode: true + +- name: check_mode test - idempotence - checks + assert: + that: + - result_checkmode2 is not changed + - result_checkmode2 is not failed + +- name: Reset + file: + path: "{{ output_file }}" + state: absent + +- name: Unicode test + kdeconfig: + path: "{{ output_file }}" + values: + - group: tesòt + key: testè1 + value: testù2 + kwriteconfig_path: "{{ kwriteconf_fake }}" + register: result_unicode + ignore_errors: true + +- name: Unicode test - checks + assert: + that: + - result_unicode is changed + - result_unicode is not failed + +- name: Reset + file: + path: "{{ output_file }}" + state: absent + +- name: Missing groups + kdeconfig: + path: "{{ output_file }}" + values: + - key: test1 + value: test2 + kwriteconfig_path: "{{ kwriteconf_fake }}" + register: result_mgroup + ignore_errors: true + +- name: Missing groups - checks + assert: + that: + - result_mgroup is not changed + - result_mgroup is failed + - "result_mgroup.msg == 'one of the following is required: group, groups found in values'" + +- name: Missing key + kdeconfig: + path: "{{ output_file }}" + values: + - group: test1 + value: test2 + kwriteconfig_path: "{{ kwriteconf_fake }}" + register: result_mkey + ignore_errors: true + +- name: Missing key - checks + assert: + that: + - result_mkey is not changed + - result_mkey is failed + - "result_mkey.msg == 'missing required arguments: key found in values'" + +- name: Missing value + kdeconfig: + path: "{{ output_file }}" + values: + - group: test1 + key: test2 + kwriteconfig_path: "{{ kwriteconf_fake }}" + register: result_mvalue + ignore_errors: true + +- name: Missing value - checks + assert: + that: + - result_mvalue is not changed + - result_mvalue is failed + - "result_mvalue.msg == 'one of the following is required: value, bool_value found in values'" + +- name: Empty key + kdeconfig: + path: "{{ output_file }}" + values: + - group: test1 + key: + value: test2 + kwriteconfig_path: "{{ kwriteconf_fake }}" + register: result_ekey + ignore_errors: true + +- name: Empty key - checks + assert: + that: + - result_ekey is not changed + - result_ekey is failed + - "result_ekey.msg == \"'key' cannot be empty\""