#!/usr/bin/python # -*- coding: utf-8 -*- # Copyright (c) 2017, Branko Majic # 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 from __future__ import absolute_import, division, print_function __metaclass__ = type DOCUMENTATION = r''' module: dconf author: - "Branko Majic (@azaghal)" short_description: Modify and read dconf database description: - This module allows modifications and reading of C(dconf) database. The module is implemented as a wrapper around C(dconf) tool. Please see the dconf(1) man page for more details. - Since C(dconf) requires a running D-Bus session to change values, the module will try to detect an existing session and reuse it, or run the tool via C(dbus-run-session). notes: - This module depends on C(psutil) Python library (version 4.0.0 and upwards), C(dconf), C(dbus-send), and C(dbus-run-session) binaries. Depending on distribution you are using, you may need to install additional packages to have these available. - Detection of existing, running D-Bus session, required to change settings via C(dconf), is not 100% reliable due to implementation details of D-Bus daemon itself. This might lead to running applications not picking-up changes on the fly if options are changed via Ansible and C(dbus-run-session). - Keep in mind that the C(dconf) CLI tool, which this module wraps around, utilises an unusual syntax for the values (GVariant). For example, if you wanted to provide a string value, the correct syntax would be I(value="'myvalue'") - with single quotes as part of the Ansible parameter value. - When using loops in combination with a value like "[('xkb', 'us'), ('xkb', 'se')]", you need to be aware of possible type conversions. Applying a filter C({{ item.value | string }}) to the parameter variable can avoid potential conversion problems. - The easiest way to figure out exact syntax/value you need to provide for a key is by making the configuration change in application affected by the key, and then having a look at value set via commands C(dconf dump /path/to/dir/) or C(dconf read /path/to/key). options: key: type: str required: true description: - A dconf key to modify or read from the dconf database. value: type: str required: false description: - Value to set for the specified dconf key. Value should be specified in GVariant format. Due to complexity of this format, it is best to have a look at existing values in the dconf database. - Required for I(state=present). state: type: str required: false default: present choices: [ 'read', 'present', 'absent' ] description: - The action to take upon the key/value. ''' RETURN = r""" value: description: value associated with the requested key returned: success, state was "read" type: str sample: "'Default'" """ EXAMPLES = r""" - name: Configure available keyboard layouts in Gnome community.general.dconf: key: "/org/gnome/desktop/input-sources/sources" value: "[('xkb', 'us'), ('xkb', 'se')]" state: present - name: Read currently available keyboard layouts in Gnome community.general.dconf: key: "/org/gnome/desktop/input-sources/sources" state: read register: keyboard_layouts - name: Reset the available keyboard layouts in Gnome community.general.dconf: key: "/org/gnome/desktop/input-sources/sources" state: absent - name: Configure available keyboard layouts in Cinnamon community.general.dconf: key: "/org/gnome/libgnomekbd/keyboard/layouts" value: "['us', 'se']" state: present - name: Read currently available keyboard layouts in Cinnamon community.general.dconf: key: "/org/gnome/libgnomekbd/keyboard/layouts" state: read register: keyboard_layouts - name: Reset the available keyboard layouts in Cinnamon community.general.dconf: key: "/org/gnome/libgnomekbd/keyboard/layouts" state: absent - name: Disable desktop effects in Cinnamon community.general.dconf: key: "/org/cinnamon/desktop-effects" value: "false" state: present """ import os import traceback PSUTIL_IMP_ERR = None try: import psutil HAS_PSUTIL = True except ImportError: PSUTIL_IMP_ERR = traceback.format_exc() HAS_PSUTIL = False from ansible.module_utils.basic import AnsibleModule, missing_required_lib class DBusWrapper(object): """ Helper class that can be used for running a command with a working D-Bus session. If possible, command will be run against an existing D-Bus session, otherwise the session will be spawned via dbus-run-session. Example usage: dbus_wrapper = DBusWrapper(ansible_module) dbus_wrapper.run_command(["printenv", "DBUS_SESSION_BUS_ADDRESS"]) """ def __init__(self, module): """ Initialises an instance of the class. :param module: Ansible module instance used to signal failures and run commands. :type module: AnsibleModule """ # Store passed-in arguments and set-up some defaults. self.module = module # Try to extract existing D-Bus session address. self.dbus_session_bus_address = self._get_existing_dbus_session() # If no existing D-Bus session was detected, check if dbus-run-session # is available. if self.dbus_session_bus_address is None: self.dbus_run_session_cmd = self.module.get_bin_path('dbus-run-session', required=True) def _get_existing_dbus_session(self): """ Detects and returns an existing D-Bus session bus address. :returns: string -- D-Bus session bus address. If a running D-Bus session was not detected, returns None. """ # We'll be checking the processes of current user only. uid = os.getuid() # Go through all the pids for this user, try to extract the D-Bus # session bus address from environment, and ensure it is possible to # connect to it. self.module.debug("Trying to detect existing D-Bus user session for user: %d" % uid) for pid in psutil.pids(): try: process = psutil.Process(pid) process_real_uid, dummy, dummy = process.uids() if process_real_uid == uid and 'DBUS_SESSION_BUS_ADDRESS' in process.environ(): dbus_session_bus_address_candidate = process.environ()['DBUS_SESSION_BUS_ADDRESS'] self.module.debug("Found D-Bus user session candidate at address: %s" % dbus_session_bus_address_candidate) dbus_send_cmd = self.module.get_bin_path('dbus-send', required=True) command = [dbus_send_cmd, '--address=%s' % dbus_session_bus_address_candidate, '--type=signal', '/', 'com.example.test'] rc, dummy, dummy = self.module.run_command(command) if rc == 0: self.module.debug("Verified D-Bus user session candidate as usable at address: %s" % dbus_session_bus_address_candidate) return dbus_session_bus_address_candidate # This can happen with things like SSH sessions etc. except psutil.AccessDenied: pass # Process has disappeared while inspecting it except psutil.NoSuchProcess: pass self.module.debug("Failed to find running D-Bus user session, will use dbus-run-session") return None def run_command(self, command): """ Runs the specified command within a functional D-Bus session. Command is effectively passed-on to AnsibleModule.run_command() method, with modification for using dbus-run-session if necessary. :param command: Command to run, including parameters. Each element of the list should be a string. :type module: list :returns: tuple(result_code, standard_output, standard_error) -- Result code, standard output, and standard error from running the command. """ if self.dbus_session_bus_address is None: self.module.debug("Using dbus-run-session wrapper for running commands.") command = [self.dbus_run_session_cmd] + command rc, out, err = self.module.run_command(command) if self.dbus_session_bus_address is None and rc == 127: self.module.fail_json(msg="Failed to run passed-in command, dbus-run-session faced an internal error: %s" % err) else: extra_environment = {'DBUS_SESSION_BUS_ADDRESS': self.dbus_session_bus_address} rc, out, err = self.module.run_command(command, environ_update=extra_environment) return rc, out, err class DconfPreference(object): def __init__(self, module, check_mode=False): """ Initialises instance of the class. :param module: Ansible module instance used to signal failures and run commands. :type module: AnsibleModule :param check_mode: Specify whether to only check if a change should be made or if to actually make a change. :type check_mode: bool """ self.module = module self.check_mode = check_mode # Check if dconf binary exists self.dconf_bin = self.module.get_bin_path('dconf', required=True) def read(self, key): """ Retrieves current value associated with the dconf key. If an error occurs, a call will be made to AnsibleModule.fail_json. :returns: string -- Value assigned to the provided key. If the value is not set for specified key, returns None. """ command = [self.dconf_bin, "read", key] rc, out, err = self.module.run_command(command) if rc != 0: self.module.fail_json(msg='dconf failed while reading the value with error: %s' % err, out=out, err=err) if out == '': value = None else: value = out.rstrip('\n') return value def write(self, key, value): """ Writes the value for specified key. If an error occurs, a call will be made to AnsibleModule.fail_json. :param key: dconf key for which the value should be set. Should be a full path. :type key: str :param value: Value to set for the specified dconf key. Should be specified in GVariant format. :type value: str :returns: bool -- True if a change was made, False if no change was required. """ # If no change is needed (or won't be done due to check_mode), notify # caller straight away. if value == self.read(key): return False elif self.check_mode: return True # Set-up command to run. Since DBus is needed for write operation, wrap # dconf command dbus-launch. command = [self.dconf_bin, "write", key, value] # Run the command and fetch standard return code, stdout, and stderr. dbus_wrapper = DBusWrapper(self.module) rc, out, err = dbus_wrapper.run_command(command) if rc != 0: self.module.fail_json(msg='dconf failed while write the value with error: %s' % err, out=out, err=err) # Value was changed. return True def reset(self, key): """ Returns value for the specified key (removes it from user configuration). If an error occurs, a call will be made to AnsibleModule.fail_json. :param key: dconf key to reset. Should be a full path. :type key: str :returns: bool -- True if a change was made, False if no change was required. """ # Read the current value first. current_value = self.read(key) # No change was needed, key is not set at all, or just notify user if we # are in check mode. if current_value is None: return False elif self.check_mode: return True # Set-up command to run. Since DBus is needed for reset operation, wrap # dconf command dbus-launch. command = [self.dconf_bin, "reset", key] # Run the command and fetch standard return code, stdout, and stderr. dbus_wrapper = DBusWrapper(self.module) rc, out, err = dbus_wrapper.run_command(command) if rc != 0: self.module.fail_json(msg='dconf failed while reseting the value with error: %s' % err, out=out, err=err) # Value was changed. return True def main(): # Setup the Ansible module module = AnsibleModule( argument_spec=dict( state=dict(default='present', choices=['present', 'absent', 'read']), key=dict(required=True, type='str', no_log=False), value=dict(required=False, default=None, type='str'), ), supports_check_mode=True ) if not HAS_PSUTIL: module.fail_json(msg=missing_required_lib("psutil"), exception=PSUTIL_IMP_ERR) # If present state was specified, value must be provided. if module.params['state'] == 'present' and module.params['value'] is None: module.fail_json(msg='State "present" requires "value" to be set.') # Create wrapper instance. dconf = DconfPreference(module, module.check_mode) # Process based on different states. if module.params['state'] == 'read': value = dconf.read(module.params['key']) module.exit_json(changed=False, value=value) elif module.params['state'] == 'present': changed = dconf.write(module.params['key'], module.params['value']) module.exit_json(changed=changed) elif module.params['state'] == 'absent': changed = dconf.reset(module.params['key']) module.exit_json(changed=changed) if __name__ == '__main__': main()