mirror of
https://github.com/ansible-collections/community.general.git
synced 2026-04-19 07:11:25 +00:00
* dconf: add dbus-broker support by improving D-Bus session discovery Extend DBusWrapper._get_existing_dbus_session() to check: 1. DBUS_SESSION_BUS_ADDRESS in the current process environment 2. /run/user/<uid>/bus (canonical socket for systemd and dbus-broker) 3. Process scan (legacy fallback, as before) Also add _validate_address() to support both dbus-send and busctl, making the module work on systems using dbus-broker (e.g. Fedora Silverblue) where no process exposes DBUS_SESSION_BUS_ADDRESS in its environment. Fixes: https://github.com/ansible-collections/community.general/issues/495 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * dconf: add changelog fragment for dbus-broker support Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * dconf: restore dbus validator requirement and example usage Restore fail_json when neither dbus-send nor busctl is available, preserving the original hard requirement for a validator binary. Restore the example invocation in DBusWrapper docstring. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
496 lines
20 KiB
Python
496 lines
20 KiB
Python
#!/usr/bin/python
|
|
|
|
# Copyright (c) 2017, Branko Majic <branko@majic.rs>
|
|
# 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 annotations
|
|
|
|
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 tries to detect an existing session and reuse
|
|
it, or run the tool using C(dbus-run-session). Both C(dbus-daemon) and C(dbus-broker) are supported.
|
|
requirements:
|
|
- Optionally the C(gi.repository) Python library (usually included in the OS on hosts which have C(dconf)); this is to become
|
|
a non-optional requirement in a future major release of community.general.
|
|
notes:
|
|
- This module depends on C(psutil) Python library (version 4.0.0 and upwards), C(dconf), and either C(dbus-send) or C(busctl)
|
|
for D-Bus session validation, and C(dbus-run-session) as a fallback when no existing session is found.
|
|
Depending on distribution you are using, you may need to install additional packages to have these available.
|
|
Both the reference C(dbus-daemon) implementation and C(dbus-broker) are supported.
|
|
- This module uses the C(gi.repository) Python library when available for accurate comparison of values in C(dconf) to values
|
|
specified in Ansible code. C(gi.repository) is likely to be present on most systems which have C(dconf) but may not be
|
|
present everywhere. When it is missing, a simple string comparison between values is used, and there may be false positives,
|
|
that is, Ansible may think that a value is being changed when it is not. This fallback is to be removed in version 15.0.0
|
|
of this collection, at which point the module C(gi.repository) is going to be required.
|
|
- The library C(gi.repository) is usually provided by operating system packages (C(python3-gi) for Ubuntu/Debian,
|
|
C(python3-gobject) for Red Hat/Fedora, C(python-gobject) for Arch Linux, to mention a few).
|
|
It is available in PyPI as C(PyGObject) but it only provides bindings for system libraries, so using the system packages
|
|
is highly recommended over installing it with C(pip).
|
|
- Detection of existing, running D-Bus session, required to change settings using 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 using 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 O(value="'myvalue'") - with single quotes
|
|
as part of the Ansible parameter value.
|
|
- When using loops in combination with a value like V("[('xkb', 'us'\), ('xkb', 'se'\)]"), you need to be aware of possible
|
|
type conversions. Applying a filter V({{ 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 using commands C(dconf dump /path/to/dir/) or C(dconf
|
|
read /path/to/key).
|
|
extends_documentation_fragment:
|
|
- community.general.attributes
|
|
attributes:
|
|
check_mode:
|
|
support: full
|
|
diff_mode:
|
|
support: none
|
|
options:
|
|
key:
|
|
type: str
|
|
required: true
|
|
description:
|
|
- A dconf key to modify or read from the dconf database.
|
|
value:
|
|
type: raw
|
|
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 O(state=present).
|
|
- Although the type is specified as "raw", it should typically be specified as a string. However, boolean values in
|
|
particular are handled properly even when specified as booleans rather than strings (in fact, handling booleans properly
|
|
is why the type of this parameter is "raw").
|
|
state:
|
|
type: str
|
|
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 sys
|
|
|
|
from ansible.module_utils.basic import AnsibleModule
|
|
from ansible.module_utils.common.respawn import (
|
|
has_respawned,
|
|
probe_interpreters_for_module,
|
|
respawn_module,
|
|
)
|
|
|
|
from ansible_collections.community.general.plugins.module_utils import deps
|
|
|
|
glib_module_name = "gi.repository.GLib"
|
|
|
|
try:
|
|
from gi.repository.GLib import GError, Variant
|
|
except ImportError:
|
|
Variant = None
|
|
GError = AttributeError
|
|
|
|
with deps.declare("psutil"):
|
|
import psutil
|
|
|
|
|
|
class DBusWrapper:
|
|
"""
|
|
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.
|
|
|
|
Discovery order:
|
|
1. DBUS_SESSION_BUS_ADDRESS in the current process environment
|
|
2. /run/user/<uid>/bus -- canonical socket for systemd and dbus-broker
|
|
3. Process scan for DBUS_SESSION_BUS_ADDRESS -- legacy fallback
|
|
|
|
Validation uses C(dbus-send) if available, C(busctl) otherwise.
|
|
|
|
Example usage:
|
|
|
|
dbus_wrapper = DBusWrapper(ansible_module)
|
|
dbus_wrapper.run_command(["printenv", "DBUS_SESSION_BUS_ADDRESS"])
|
|
"""
|
|
|
|
def __init__(self, module):
|
|
self.module = module
|
|
self.dbus_session_bus_address = self._get_existing_dbus_session()
|
|
|
|
if self.dbus_session_bus_address is None:
|
|
self.dbus_run_session_cmd = self.module.get_bin_path("dbus-run-session", required=True)
|
|
|
|
def _validate_address(self, address):
|
|
dbus_send = self.module.get_bin_path("dbus-send")
|
|
if dbus_send:
|
|
rc, dummy, dummy = self.module.run_command(
|
|
[dbus_send, f"--address={address}", "--type=signal", "/", "com.example.test"]
|
|
)
|
|
if rc == 0:
|
|
return True
|
|
|
|
busctl = self.module.get_bin_path("busctl")
|
|
if busctl:
|
|
rc, dummy, dummy = self.module.run_command([busctl, f"--address={address}", "list"])
|
|
if rc == 0:
|
|
return True
|
|
|
|
if not dbus_send and not busctl:
|
|
self.module.fail_json(msg="Neither dbus-send nor busctl is available. Please install one of them.")
|
|
|
|
return False
|
|
|
|
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()
|
|
|
|
# Step 1: current process environment
|
|
address = os.environ.get("DBUS_SESSION_BUS_ADDRESS")
|
|
if address:
|
|
self.module.debug(f"Trying D-Bus address from environment: {address}")
|
|
if self._validate_address(address):
|
|
self.module.debug(f"Using D-Bus session from environment: {address}")
|
|
return address
|
|
|
|
# Step 2: canonical systemd/dbus-broker socket path
|
|
canonical_path = f"/run/user/{uid}/bus"
|
|
if os.path.exists(canonical_path):
|
|
address = f"unix:path={canonical_path}"
|
|
self.module.debug(f"Trying canonical D-Bus socket: {canonical_path}")
|
|
if self._validate_address(address):
|
|
self.module.debug(f"Using canonical D-Bus socket: {canonical_path}")
|
|
return address
|
|
|
|
# Step 3: process scan (legacy fallback)
|
|
self.module.debug(f"Scanning processes for D-Bus session (uid={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(
|
|
f"Found D-Bus user session candidate at address: {dbus_session_bus_address_candidate}"
|
|
)
|
|
if self._validate_address(dbus_session_bus_address_candidate):
|
|
self.module.debug(
|
|
f"Verified D-Bus user session candidate as usable at address: {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 rc == 127:
|
|
self.module.fail_json(
|
|
msg=f"Failed to run passed-in command, dbus-run-session faced an internal error: {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:
|
|
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)
|
|
|
|
@staticmethod
|
|
def variants_are_equal(canonical_value, user_value):
|
|
"""Compare two string GVariant representations for equality.
|
|
|
|
Assumes `canonical_value` is "canonical" in the sense that the type of
|
|
the variant is specified explicitly if it cannot be inferred; this is
|
|
true for textual representations of variants generated by the `dconf`
|
|
command. The type of `canonical_value` is used to parse `user_value`,
|
|
so the latter does not need to be explicitly typed.
|
|
|
|
Returns True if the two values are equal.
|
|
"""
|
|
if canonical_value is None:
|
|
# It's unset in dconf database, so anything the user is trying to
|
|
# set is a change.
|
|
return False
|
|
try:
|
|
variant1 = Variant.parse(None, canonical_value)
|
|
variant2 = Variant.parse(variant1.get_type(), user_value)
|
|
return variant1 == variant2
|
|
except GError:
|
|
return canonical_value == user_value
|
|
|
|
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, environ_update={"LANGUAGE": "C", "LC_ALL": "C"})
|
|
|
|
if rc != 0:
|
|
self.module.fail_json(msg=f"dconf failed while reading the value with error: {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 self.variants_are_equal(self.read(key), value):
|
|
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=f"dconf failed while writing key {key}, value {value} with error: {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=f"dconf failed while resetting the value with error: {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),
|
|
# Converted to str below after special handling of bool.
|
|
value=dict(type="raw"),
|
|
),
|
|
supports_check_mode=True,
|
|
required_if=[
|
|
("state", "present", ["value"]),
|
|
],
|
|
)
|
|
|
|
if Variant is None:
|
|
# This interpreter can't see the GLib module. To try to fix that, we'll
|
|
# look in common locations for system-owned interpreters that can see
|
|
# it; if we find one, we'll respawn under it. Otherwise we'll proceed
|
|
# with degraded performance, without the ability to parse GVariants.
|
|
# Later (in a different PR) we'll actually deprecate this degraded
|
|
# performance level and fail with an error if the library can't be
|
|
# found.
|
|
|
|
if has_respawned():
|
|
# This shouldn't be possible; short-circuit early if it happens.
|
|
module.fail_json(msg=f"{glib_module_name} must be installed and visible from {sys.executable}.")
|
|
|
|
interpreters = ["/usr/bin/python3", "/usr/bin/python"]
|
|
|
|
interpreter = probe_interpreters_for_module(interpreters, glib_module_name)
|
|
|
|
if interpreter:
|
|
# Found the Python bindings; respawn this module under the
|
|
# interpreter where we found them.
|
|
respawn_module(interpreter)
|
|
# This is the end of the line for this process, it will exit here
|
|
# once the respawned module has completed.
|
|
|
|
# Try to be forgiving about the user specifying a boolean as the value, or
|
|
# more accurately about the fact that YAML and Ansible are quite insistent
|
|
# about converting strings that look like booleans into booleans. Convert
|
|
# the boolean into a string of the type dconf will understand. Any type for
|
|
# the value other than boolean is just converted into a string directly.
|
|
if module.params["value"] is not None:
|
|
if isinstance(module.params["value"], bool):
|
|
module.params["value"] = "true" if module.params["value"] else "false"
|
|
else:
|
|
module.params["value"] = str(module.params["value"])
|
|
|
|
if Variant is None:
|
|
module.deprecate(
|
|
"The gi.repository Python library is not available; "
|
|
"using string comparison to check value equality. This fallback "
|
|
"will be removed in community.general 15.0.0.",
|
|
version="15.0.0",
|
|
collection_name="community.general",
|
|
)
|
|
|
|
deps.validate(module)
|
|
|
|
# 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()
|