Skip to content

dconf: add feature to populate a subpath from file #10432

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 21 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelogs/fragments/10432-dconf-load-subpath.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
minor_changes:
- dconf - adds support of `load` to populate a subpath from file
113 changes: 106 additions & 7 deletions plugins/modules/dconf.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,17 @@
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).
- One of O(value) and O(path) are 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").
path:
type: path
required: false
description:
- Remote path to the configuration to apply.
- One of O(value) and O(path) are required for O(state=present).

state:
type: str
required: false
Expand Down Expand Up @@ -122,12 +129,20 @@
key: "/org/cinnamon/desktop-effects"
value: "false"
state: present

- name: Load terminal profile in Gnome
community.general.dconf:
key: "/org/gnome/terminal/legacy/profiles/:"
path: "/tmp/solarized_dark.dump"
state: present
"""


import os
import sys

from ansible.module_utils.six.moves.configparser import ConfigParser

from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.common.respawn import (
has_respawned,
Expand Down Expand Up @@ -224,7 +239,7 @@ def _get_existing_dbus_session(self):

return None

def run_command(self, command):
def run_command(self, command, data=None):
"""
Runs the specified command within a functional D-Bus session. Command is
effectively passed-on to AnsibleModule.run_command() method, with
Expand All @@ -233,19 +248,21 @@ def run_command(self, command):
:param command: Command to run, including parameters. Each element of the list should be a string.
:type module: list

:kw data: If given, information to write to the stdin of the command

: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)
rc, out, err = self.module.run_command(command, data=data)

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)
rc, out, err = self.module.run_command(command, data=data, environ_update=extra_environment)

return rc, out, err

Expand Down Expand Up @@ -390,6 +407,71 @@ def reset(self, key):
# Value was changed.
return True

def load(self, key, path):
"""
Load the config file in specified path.

if an error occurs, a call will be made to AnsibleModule.fail_json.

:param key: dconf directory for which the config should be set.
:type key: str

:param path: Remote configuration path to set for the specified dconf path.
:type value: str

:returns: bool -- True if a change was made, False if no change was required.
"""
def _create_dconf_key(root, sub_dir, key):
# Root should end with '/'
if sub_dir == "/":
# No sub directory
return "%s%s" % (root, key)
return "%s%s/%s" % (root, sub_dir, key)

# Ensure key refers to a directory, as required by dconf
root_dir = key
if not root_dir.endswith('/'):
root_dir += '/'

# Read config to check if change is needed and passing to command line
try:
with open(path, 'r') as fd:
raw_config = fd.read()
except IOError as ex:
self.module.fail_json(msg='Failed while reading configuration file %s with error: %s' % (path, ex))

# Parse configuration file
config = ConfigParser()
try:
config.read_string(raw_config)
except Exception as e:
self.module.fail_json(msg='Failed while parsing config with error: %s' % e)

# For each sub-directory, check if at least one change is needed
changed = any(
not self.variants_are_equal(self.read(_create_dconf_key(root_dir, sub_dir, k)), v)
for sub_dir in config.sections()
for k, v in config[sub_dir].items()
)

if self.check_mode or not changed:
return changed

# Set-up command to run. Since DBus is needed for write operation, wrap
# dconf command dbus-launch.
command = [self.dconf_bin, 'load', root_dir]

# Run the command and fetch standard return code, stdout, and stderr.
dbus_wrapper = DBusWrapper(self.module)
rc, out, err = dbus_wrapper.run_command(command, data=raw_config)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens to keys that already exist, but are not part of raw_config? Are these kept, or removed?

If the latter, the changed check above is not correct.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If keys exist in the database but, are not part of raw_config then they're kept. changed check should be correct!


if rc != 0:
self.module.fail_json(msg='dconf failed while load config %s, root dir %s with error: %s' % (path, root_dir, err),
out=out,
err=err)
# Value was changed.
return changed


def main():
# Setup the Ansible module
Expand All @@ -399,10 +481,14 @@ def main():
key=dict(required=True, type='str', no_log=False),
# Converted to str below after special handling of bool.
value=dict(required=False, default=None, type='raw'),
path=dict(required=False, default=None, type='path'),
),
supports_check_mode=True,
required_if=[
('state', 'present', ['value']),
('state', 'present', ['value', 'path'], True),
],
mutually_exclusive=[
['value', 'path'],
],
)

Expand Down Expand Up @@ -459,11 +545,24 @@ def main():

# Process based on different states.
if module.params['state'] == 'read':
# TODO: Handle this case when 'state=present' and 'key' is the only one
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what this comment means. Can you please explain it?

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)
if module.params['path']:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if module.params['path']:
if module.params['path'] is not None:

# Use 'dconf load' to propagate multiple entries from the root given by 'key'
changed = dconf.load(module.params['key'], module.params['path'])
module.exit_json(changed=changed)
elif module.params['value']:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
elif module.params['value']:
else:

# Use 'dconf write' to modify the key
changed = dconf.write(module.params['key'], module.params['value'])
module.exit_json(changed=changed)
else:
# NOTE: This case shouldn't happen yet as 'key' and 'path' are
# required with 'state=present'
# TODO: if 'key' ends with '/' then 'dconf list' should be used
# else, 'dconf read'
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this TODO be part of dconf.load()?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't thing so.

The architecture defined by the original author suggests that:

  • dconf.read handles dconf read [key]
  • dconf.write handles dconf write [key] [value]
  • dconf.load handles dconf load [key] < [path] (this contribution)

If key ends with '/', it means that it's a directory. If value is set, it should be illegal. If path is set, we're in the case of the contribution. If none of them is set, user tries to read a directory. It cannot be handled neither by dconf read nor dconf dump instead, dconf list must be used. In the future, dconf.list must be created to by compliant.

As you said, state=read is a mistake. As I understand, it means that reading key (whatever the meaning - a key or a directory) should have been handled by state=present. That's why I added this TODO here.

Are you agree with that?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As you said, state=read is a mistake. As I understand, it means that reading key (whatever the meaning - a key or a directory) should have been handled by state=present. That's why I added this TODO here.

What do you mean that read is a mistake? The read state is meant to read the key value without making changes to it - the present state will/might change the value. Or have I misunderstood something here?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi,

Felix wrote the following sentence:

Note that load is not a state. (Not that read is one, but that mistake has already been made in the past.)

I understand that state=read shouldn't be valid. If it's a valid state, that's fine with me!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

state=read should not be there, it should be moved to a separate _info module. Since that state has been added a long time ago (together with the module in 2017), it's still there until someone invests some work to move it to a separate module though. But if someone tries to add a similar "state" again to a module nowadays, it would be rejected.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Anyway, this comment has been removed to follow the guidance of russoz!

module.fail_json(msg="'key' or 'path' must be defined.")
elif module.params['state'] == 'absent':
changed = dconf.reset(module.params['key'])
module.exit_json(changed=changed)
Expand Down
Loading