From ce028911e399d5cc63262c6049f8c185b5eabbbf Mon Sep 17 00:00:00 2001 From: Rich Megginson Date: Thu, 7 Nov 2024 16:13:01 -0700 Subject: [PATCH] feat: add support for user units Feature: The role can manage user units in addition to system units. Each item in each input list can be a string or a `dict` consisting of the item (file, template, or unit), a user name, and a state (for files and templates). The role will not create users and will give an error if a non-existent user is specified. Reason: The role should allow management of user units. Result: The role can manage user units. NOTE: User units only supported on EL8 and later. Signed-off-by: Rich Megginson --- README.md | 72 ++++++++- defaults/main.yml | 13 ++ library/systemd_units.py | 35 ++++- tasks/cancel_linger.yml | 18 +++ tasks/cancel_linger_for_user.yml | 53 +++++++ tasks/get_systemd_units_info.yml | 15 ++ tasks/get_update_user_info.yml | 40 +++++ tasks/main.yml | 215 ++++++++++++++------------ tasks/manage_unit_files.yml | 97 ++++++++++++ tasks/manage_units.yml | 39 +++++ tasks/manage_user_info.yml | 77 ++++++++++ tests/files/bar.service | 6 + tests/tests_user_units.yml | 250 +++++++++++++++++++++++++++++++ vars/main.yml | 9 ++ 14 files changed, 830 insertions(+), 109 deletions(-) create mode 100644 tasks/cancel_linger.yml create mode 100644 tasks/cancel_linger_for_user.yml create mode 100644 tasks/get_systemd_units_info.yml create mode 100644 tasks/get_update_user_info.yml create mode 100644 tasks/manage_unit_files.yml create mode 100644 tasks/manage_units.yml create mode 100644 tasks/manage_user_info.yml create mode 100644 tests/files/bar.service create mode 100644 tests/tests_user_units.yml diff --git a/README.md b/README.md index 023d26c..e37b949 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ wrapper around systemd and template Ansible Core modules. ## Requirements -See below +*NOTE:* Support for user units is not available in EL7 or earlier. This feature +is only available in EL8 and later. ### Collection requirements @@ -24,6 +25,26 @@ ansible-galaxy collection install -vv -r meta/collection-requirements.yml List of variables consumed by the role follows, note that none of them is mandatory. +Each of the variables can either be a list of strings, or a list of `dicts`. + +The list of strings form assumes that the items to be managed are system units +owned by `root`, and for files, assumes that the files should be `present`. + +The list of `dict` form looks like this: + +```yaml +systemd_unit_files: + - item: some.service + user: my_user + state: [present|absent] +``` + +Use the `dict` form to manage user units, and to remove unit files. If using +user units, the role will manage lingering for those users. + +*NOTE:* Support for user units is not available in EL7 or earlier. This feature +is only available in EL8 and later. + ### systemd_unit_files List of systemd unit file names that should be deployed to managed nodes. @@ -79,7 +100,7 @@ List of unit files that shall be unmasked via systemd. This variable is used to handle reboots required by transactional updates. If a transactional update requires a reboot, the role will proceed with the reboot if systemd_transactional_update_reboot_ok is set to true. If set to false, the role will notify the user that a reboot is required, allowing for custom handling of the reboot requirement. If this variable is not set, the role will fail to ensure the reboot requirement is not overlooked. -Example of setting the variables: +Example of setting the variables for the simple list of strings format: ```yaml systemd_unit_files: @@ -96,12 +117,49 @@ systemd_enabled_units: - bar.service ``` +Example of setting the variables for the list of `dict` format: + +```yaml +systemd_unit_files: + - item: foo.service + user: root + state: present + - item: bar.service + user: my_user + state: absent +systemd_dropins: + - item: cups.service.conf.j2 + user: root + state: present + - item: avahi-daemon.service.conf.j2 + user: my_user + state: absent +systemd_started_units: + - item: foo.service + user: root + - item: bar.service + user: my_user +systemd_enabled_units: + - item: foo.service + user: root + - item: bar.service + user: my_user +``` + ## Variables Exported by the Role ### `systemd_units` -Variable shall contain a list of dictionaries where each entry describes state of one systemd unit -present on the managed host. +The variable is a `dict`. Each key is the name of a systemd unit. Each value +is a dict with fields that describe the state of that systemd unit present on +the managed host for the system scope. + +### `systemd_units_user` + +Variable shall contain a dict. Each key is the name of a user given in one of +the lists passed to the role, and `root` (even if `root` is not given). Each +value is a dict of systemd units for that user, or system units for `root`, in +the format of `systemd_units` above. ## Example Playbook @@ -112,7 +170,10 @@ present on the managed host. systemd_unit_file_templates: - foo.service.j2 systemd_started_units: - - foo.service + - item: foo.service + user: root + - item: bar.service + user: my_user systemd_enabled_units: - foo.service roles: @@ -130,3 +191,4 @@ MIT ## Author Michal Sekletar +Rich Megginson diff --git a/defaults/main.yml b/defaults/main.yml index 63fc0b8..3236500 100644 --- a/defaults/main.yml +++ b/defaults/main.yml @@ -1,5 +1,12 @@ # SPDX-License-Identifier: MIT --- +# These can either be lists of strings, which are the names of files, units, etc. +# or a list of dict +# the dict can have the following fields +# item: name of file, template, unit, etc. +# user: root for system units, or user for user units +# if no user is given, root is used +# state: default is present - use absent to remove unit files systemd_unit_files: [] systemd_unit_file_templates: [] systemd_dropins: [] @@ -11,4 +18,10 @@ systemd_enabled_units: [] systemd_disabled_units: [] systemd_masked_units: [] systemd_unmasked_units: [] + +# This only applies to transactional update systems systemd_transactional_update_reboot_ok: null + +# fail the role if there is an attempt to use a feature +# not supported by the version of systemd +systemd_fail_if_too_old: true diff --git a/library/systemd_units.py b/library/systemd_units.py index a34485b..a63b95f 100644 --- a/library/systemd_units.py +++ b/library/systemd_units.py @@ -31,7 +31,15 @@ requirements: [systemd] description: - This module gathers state of systemd units and exposes it as facts data -options: {} +options: + user: + description: user name + type: str + required: true + user_facts: + description: getent facts about user + type: dict + required: true """ RETURN = r""" @@ -97,10 +105,15 @@ def units(self): systemctl = self.module.get_bin_path( "systemctl", opt_dirs=["/usr/bin", "/usr/local/bin"] ) + if self.module.params["user"] == "root": + flag = "--system" + else: + flag = "--user" units = {} rc, stdout, stderr = self.module.run_command( - "%s list-units --no-pager --no-legend" % systemctl, use_unsafe_shell=True + "%s %s list-units --no-pager --no-legend" % (systemctl, flag), + use_unsafe_shell=True, ) if rc != 0: self.module.warn("Could not list units: %s" % stderr) @@ -121,12 +134,24 @@ def units(self): def main(): - module = AnsibleModule(argument_spec={}, supports_check_mode=True) + argument_spec = dict( + user=dict(type="str", required=True), + user_facts=dict(type="dict", required=True), + ) + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) systemd_facts_module = SystemdUnitsFacts(module) units = systemd_facts_module.units() - - results = dict(ansible_facts={"systemd_units": units}) + user = module.params["user"] + user_facts = module.params["user_facts"] + # module.log("user [%s] user_facts %s" % (user, user_facts)) + user_facts[user] = units + facts = {"systemd_units_user": user_facts} + if user == "root": + # add the legacy fact + facts.update({"systemd_units": units}) + + results = dict(ansible_facts=facts) module.exit_json(**results) diff --git a/tasks/cancel_linger.yml b/tasks/cancel_linger.yml new file mode 100644 index 0000000..f581271 --- /dev/null +++ b/tasks/cancel_linger.yml @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: MIT +# Inputs: +# __systemd_user_info +--- +- name: Check if linger for users can be cancelled + vars: + __systemd_linger_users: "{{ __systemd_user_info | dict2items | + rejectattr('key', 'match', '^root$') | list }}" + when: __systemd_linger_users | length > 0 + block: + - name: Cancel linger for given user + include_tasks: cancel_linger_for_user.yml + vars: + __systemd_linger_data: "{{ __systemd_linger_user_data.value }}" + __systemd_linger_user: "{{ __systemd_linger_user_data.key }}" + loop: "{{ __systemd_linger_users }}" + loop_control: + loop_var: __systemd_linger_user_data diff --git a/tasks/cancel_linger_for_user.yml b/tasks/cancel_linger_for_user.yml new file mode 100644 index 0000000..e47c292 --- /dev/null +++ b/tasks/cancel_linger_for_user.yml @@ -0,0 +1,53 @@ +# SPDX-License-Identifier: MIT +# Inputs: +# __systemd_linger_user_data +--- +- name: Get unit files for user + find: + path: "{{ __systemd_linger_data['units_dir'] }}" + register: __systemd_find + +- name: Cancel linger if no files + command: loginctl disable-linger {{ __systemd_linger_user | quote }} + changed_when: true + register: __systemd_cancel_linger + when: __systemd_find.matched == 0 + args: + removes: /var/lib/systemd/linger/{{ __systemd_linger_user }} + +- name: Wait for user session to exit closing state # noqa no-handler + command: loginctl show-user --value -p State {{ __systemd_linger_user | quote }} + register: __systemd_user_state + changed_when: false + until: __systemd_user_state.stdout != "closing" + when: __systemd_cancel_linger is changed + ignore_errors: true + +# see https://github.com/systemd/systemd/issues/26744#issuecomment-2261509208 +- name: Handle user stuck in closing state + vars: + __pat: "Failed to get user: User ID .* is not logged in or lingering" + when: + - __systemd_cancel_linger is changed + - __systemd_user_state is failed + - not __systemd_user_state.stderr is match(__pat) + block: + - name: Stop logind + service: + name: systemd-logind + state: stopped + + - name: Wait for user session to exit closing state + command: loginctl show-user --value -p State {{ __systemd_linger_user | quote }} + changed_when: false + register: __systemd_user_state + until: __systemd_user_state.stderr is match(__pat) or + __systemd_user_state.stdout != "closing" + failed_when: + - not __systemd_user_state.stderr is match(__pat) + - __systemd_user_state.stdout == "closing" + + - name: Restart logind + service: + name: systemd-logind + state: started diff --git a/tasks/get_systemd_units_info.yml b/tasks/get_systemd_units_info.yml new file mode 100644 index 0000000..8705957 --- /dev/null +++ b/tasks/get_systemd_units_info.yml @@ -0,0 +1,15 @@ +# SPDX-License-Identifier: MIT +# Inputs: +# __systemd_units_info +# Outputs: +# ansible_facts +--- +- name: Gather systemd units state for system and user units + systemd_units: + user: "{{ __systemd_units_info.key }}" + user_facts: "{{ ansible_facts['systemd_units_user'] | d({}) }}" + become: "{{ __systemd_units_info.key != 'root' }}" + become_user: "{{ __systemd_units_info.key if __systemd_units_info.key != 'root' + else omit }}" + environment: + XDG_RUNTIME_DIR: "{{ __systemd_units_info.value['xdg_dir'] }}" diff --git a/tasks/get_update_user_info.yml b/tasks/get_update_user_info.yml new file mode 100644 index 0000000..e9091b3 --- /dev/null +++ b/tasks/get_update_user_info.yml @@ -0,0 +1,40 @@ +# SPDX-License-Identifier: MIT +# Inputs: +# __systemd_user_name +# Outputs: +# __systemd_user_info updated with getent info from user +--- +- name: Get user information + getent: + database: passwd + key: "{{ __systemd_user_name }}" + fail_key: true + when: "'getent_passwd' not in ansible_facts or + __systemd_user_name not in ansible_facts['getent_passwd']" + +- name: Update systemd user info with new user info + when: __systemd_user_name not in __systemd_user_info + set_fact: + __systemd_user_info: | + {% set getent = ansible_facts["getent_passwd"][__systemd_user_name] %} + {% set rv = __systemd_user_info | d({}) %} + {% if __systemd_user_name not in rv %} + {% set _ = rv.__setitem__(__systemd_user_name, {}) %} + {% endif %} + {% if "xdg_dir" not in rv[__systemd_user_name] %} + {% set xdg_dir = "/run/user/" ~ getent[1] %} + {% set _ = rv[__systemd_user_name].update({"xdg_dir": xdg_dir}) %} + {% endif %} + {% if "units_dir" not in rv[__systemd_user_name] %} + {% if __systemd_user_name == "root" %} + {% set _ = rv[__systemd_user_name].update({"units_dir": __admin_units_dir}) %} + {% else %} + {% set units_dir = getent[4] ~ '/' ~ __user_units_dir %} + {% set _ = rv[__systemd_user_name].update({"units_dir": units_dir}) %} + {% endif %} + {% endif %} + {% if "group" not in rv[__systemd_user_name] %} + {% set group = getent[2] %} + {% set _ = rv[__systemd_user_name].update({"group": group}) %} + {% endif %} + {{ rv }} diff --git a/tasks/main.yml b/tasks/main.yml index 5057745..448a0b4 100644 --- a/tasks/main.yml +++ b/tasks/main.yml @@ -1,5 +1,44 @@ # SPDX-License-Identifier: MIT --- +- name: Ensure ansible_facts used by role + setup: + gather_subset: "{{ __required_facts }}" + when: __required_facts | difference(ansible_facts.keys() | list) | + length > 0 + vars: + __required_facts: + - distribution + - distribution_major_version + - os_family + +- name: Support for user units only available in EL7 and later + fail: + msg: > + User units not supported for {{ ansible_facts["distribution"] }} version + {{ ansible_facts["distribution_major_version"] }} - support is only + available on EL8 and later + when: + - ansible_facts["os_family"] == "RedHat" + - ansible_facts["distribution_major_version"] is version("8", "<") + - __has_user_units | bool + - systemd_fail_if_too_old | bool + vars: + __has_user_units: "{{ __systemd_all_units | select('mapping') | + selectattr('user', 'defined') | rejectattr('user', 'match', '^root$') | + list | length > 0 }}" + +- name: Support for user units only available in EL7 and later + meta: end_host + when: + - ansible_facts["os_family"] == "RedHat" + - ansible_facts["distribution_major_version"] is version("8", "<") + - __has_user_units | bool + - not systemd_fail_if_too_old + vars: + __has_user_units: "{{ __systemd_all_units | select('mapping') | + selectattr('user', 'defined') | rejectattr('user', 'match', '^root$') | + list | length > 0 }}" + - name: Determine if system is ostree and set flag when: not __systemd_is_ostree is defined block: @@ -54,109 +93,87 @@ when: - systemd_transactional_update_reboot_ok is none -- name: Deploy unit files - copy: - src: "{{ item }}" - dest: "{{ __admin_units_dir }}/{{ item }}" - owner: root - group: root - mode: '0644' - loop: "{{ systemd_unit_files }}" - register: __systemd_unit_files_result - -- name: Deploy unit file templates - template: - src: "{{ item }}" - dest: "{{ __admin_units_dir }}/{{ __dest }}" - owner: root - group: root - mode: '0644' - loop: "{{ systemd_unit_file_templates }}" - register: __systemd_unit_file_templates_result - vars: - __dest: "{{ item | regex_replace('[.]j2$', '') }}" - -- name: Create drop-in directories - file: - path: "{{ __admin_units_dir }}/{{ '.'.join(__dest.split('.')[:-1]) }}.d/" - state: directory - owner: root - group: root - mode: '0755' - loop: "{{ systemd_dropins }}" - vars: - __dest: "{{ item | regex_replace('[.]j2$', '') }}" - -- name: Deploy systemd drop in config - template: - src: "{{ item }}" - dest: "{{ __admin_units_dir }}/\ - {{ '.'.join(__dest.split('.')[:-1]) }}.d/\ - 99-override.conf" - owner: root - group: root - mode: '0644' - loop: "{{ systemd_dropins }}" - register: __systemd_dropins_result - vars: - __dest: "{{ item | regex_replace('[.]j2$', '') }}" +- name: Reset __systemd_user_info + set_fact: + __systemd_user_info: {} +- name: Manage files and templates and their directories + include_tasks: manage_unit_files.yml + vars: + __systemd_list_name: "{{ managed_unit_files }}" + loop_control: + loop_var: managed_unit_files + loop: + - systemd_unit_files + - systemd_unit_file_templates + - systemd_dropins + +# handle reload for system and users that had changed files - name: Reload systemd systemd: daemon_reload: true - when: > - __systemd_unit_files_result is changed or - __systemd_unit_file_templates_result is changed or - __systemd_dropins_result is changed - -- name: Ensure required units are started - systemd: - name: "{{ item }}" - state: started - loop: "{{ systemd_started_units }}" - -- name: Ensure required units are stopped - systemd: - name: "{{ item }}" - state: stopped - loop: "{{ systemd_stopped_units }}" - -- name: Ensure required units are restarted - systemd: - name: "{{ item }}" - state: restarted - loop: "{{ systemd_restarted_units }}" - -- name: Ensure required units are reloaded - systemd: - name: "{{ item }}" - state: reloaded - loop: "{{ systemd_reloaded_units }}" - -- name: Ensure required units are enabled - systemd: - name: "{{ item }}" - enabled: true - loop: "{{ systemd_enabled_units }}" - -- name: Ensure required units are disabled - systemd: - name: "{{ item }}" - enabled: false - loop: "{{ systemd_disabled_units }}" + scope: "{{ 'system' if item == 'root' else 'user' }}" + loop: "{{ __users_with_changes }}" + become: "{{ item != 'root' }}" + become_user: "{{ item if item != 'root' else omit }}" + environment: + XDG_RUNTIME_DIR: "{{ __systemd_user_info[item]['xdg_dir'] }}" + vars: + all_results: "{{ __systemd_unit_files_result.get('results', []) + + __systemd_unit_file_templates_result.get('results', []) + + __systemd_unit_files_absent_result.get('results', []) }}" + __users_with_changes: "{{ all_results | selectattr('changed', 'defined') | + selectattr('changed') | map(attribute='invocation') | + map(attribute='module_args') | map(attribute='owner') | unique | list }}" + +- name: Manage units + include_tasks: manage_units.yml + vars: + __systemd_list_name: "{{ managed_units.name }}" + __systemd_state: "{{ managed_units.state | d('') }}" + __systemd_enabled: "{{ managed_units.enabled | d('') }}" + __systemd_masked: "{{ managed_units.masked | d('') }}" + loop_control: + loop_var: managed_units + loop: + - name: systemd_started_units + state: started + - name: systemd_stopped_units + state: stopped + - name: systemd_restarted_units + state: restarted + - name: systemd_reloaded_units + state: reloaded + - name: systemd_enabled_units + enabled: "true" + - name: systemd_disabled_units + enabled: "false" + - name: systemd_masked_units + masked: "true" + - name: systemd_unmasked_units + masked: "false" + +- name: DebugBefore + debug: + msg: facts {{ ansible_facts.systemd_units_user | d({}) | to_nice_json }} + +- name: Gather systemd units state for system and user units + include_tasks: get_systemd_units_info.yml + loop: "{{ __user_info | dict2items }}" + loop_control: + loop_var: __systemd_units_info + vars: + __user_info: "{{ __systemd_user_info if __systemd_user_info.keys() | length > 0 + else {'root': {'xdg_dir': '/run/user/0'}} }}" -- name: Ensure required units are masked - systemd: - name: "{{ item }}" - masked: true - loop: "{{ systemd_masked_units }}" +- name: DebugAfter + debug: + msg: facts {{ ansible_facts.systemd_units_user | d({}) | to_nice_json }} -- name: Ensure required units are unmasked - systemd: - name: "{{ item }}" - masked: false - loop: "{{ systemd_unmasked_units }}" +- name: Set legacy systemd_units output variable + set_fact: + systemd_units: "{{ ansible_facts['systemd_units'] + if 'systemd_units' in ansible_facts else {} }}" -- name: Gather systemd units state - systemd_units: - register: systemd_units +- name: Cancel linger for users if necessary + include_tasks: cancel_linger.yml diff --git a/tasks/manage_unit_files.yml b/tasks/manage_unit_files.yml new file mode 100644 index 0000000..ff67b20 --- /dev/null +++ b/tasks/manage_unit_files.yml @@ -0,0 +1,97 @@ +# SPDX-License-Identifier: MIT +# Inputs: +# __systemd_list_name - name of variable to use for operation +--- +- name: Manage unit files, templates, and directories + when: + - __systemd_list_name in __list_names + - __systemd_list | length > 0 + vars: + __systemd_list: "{{ lookup('vars', __systemd_list_name) }}" + __list_names: + - systemd_unit_files + - systemd_unit_file_templates + - systemd_dropins + block: + # this uses __systemd_list set above + - name: Manage user info + include_tasks: manage_user_info.yml + + - name: Manage files and templates + when: __systemd_dict_list | length > 0 + vars: + __unit: "{{ item.item | regex_replace('[.]j2$', '') + if __systemd_list_name in ['systemd_unit_file_templates', 'systemd_dropins'] + else item.item }}" + __file: "{{ '99-override.conf' if __systemd_list_name == 'systemd_dropins' + else __unit }}" + __path: "{{ item.units_dir ~ '/' ~ '.'.join(__unit.split('.')[:-1]) ~ '.d' + if __systemd_list_name == 'systemd_dropins' + else item.units_dir }}" + __systemd_present: "{{ __systemd_dict_list | selectattr('state', 'match', '^present$') | + list }}" + __systemd_absent: "{{ __systemd_dict_list | selectattr('state', 'match', '^absent$') | + list }}" + block: + - name: Ensure directories are present for users + file: + path: "{{ __path }}" + owner: "{{ item.user }}" + state: directory + mode: "{{ item.dir_mode }}" + loop: "{{ __systemd_present | rejectattr('user', 'match', '^root$') | list }}" + + - name: Ensure directories are present for dropins + file: + path: "{{ __path }}" + owner: "{{ item.user }}" + state: directory + mode: "{{ item.dir_mode }}" + when: __systemd_list_name == "systemd_dropins" + loop: "{{ __systemd_present }}" + + - name: Ensure unit files are present + copy: + src: "{{ item.item }}" + dest: "{{ __path }}/{{ __file }}" + owner: "{{ item.user }}" + group: "{{ item.group }}" + mode: "{{ item.mode }}" + when: __systemd_list_name == "systemd_unit_files" + loop: "{{ __systemd_present }}" + register: __systemd_unit_files_result + + - name: Ensure unit file templates are present + template: + src: "{{ item.item }}" + dest: "{{ __path }}/{{ __file }}" + owner: "{{ item.user }}" + group: "{{ item.group }}" + mode: "{{ item.mode }}" + when: __systemd_list_name != "systemd_unit_files" + loop: "{{ __systemd_present }}" + register: __systemd_unit_file_templates_result + + - name: Ensure unit files are absent + file: + path: "{{ __path }}/{{ __file }}" + state: absent + owner: "{{ item.user }}" # only needed for daemon_reload which happens later + loop: "{{ __systemd_absent }}" + register: __systemd_unit_files_absent_result + + - name: Find files in dropins directory + find: + path: "{{ __path }}" + loop: "{{ __systemd_absent }}" + when: __systemd_list_name == "systemd_dropins" + register: __systemd_find + + - name: Remove dropin directory if no more files + file: + path: "{{ item['invocation']['module_args']['path'] }}" + state: absent + loop: "{{ __systemd_find.results | d([]) }}" + when: + - __systemd_list_name == "systemd_dropins" + - item.matched == 0 diff --git a/tasks/manage_units.yml b/tasks/manage_units.yml new file mode 100644 index 0000000..b1b3280 --- /dev/null +++ b/tasks/manage_units.yml @@ -0,0 +1,39 @@ +# SPDX-License-Identifier: MIT +# Inputs: +# __systemd_list_name - name of variable to use for operation +# __systemd_state - setting for state or omit if absent +# __systemd_enabled - setting for enabled or omit if absent +# __systemd_masked - setting for masked or omit if absent +--- +- name: Manage units + when: + - __systemd_list_name in __list_names + - __systemd_list | length > 0 + vars: + __systemd_list: "{{ lookup('vars', __systemd_list_name) }}" + __list_names: + - systemd_started_units + - systemd_stopped_units + - systemd_restarted_units + - systemd_reloaded_units + - systemd_enabled_units + - systemd_disabled_units + - systemd_masked_units + - systemd_unmasked_units + block: + # this uses __systemd_list set above + - name: Manage user info + include_tasks: manage_user_info.yml + + - name: Ensure required units in correct state + systemd: + name: "{{ item.item }}" + state: "{{ __systemd_state if __systemd_state | length > 0 else omit }}" + enabled: "{{ __systemd_enabled | bool if __systemd_enabled | length > 0 else omit }}" + masked: "{{ __systemd_masked | bool if __systemd_masked | length > 0 else omit }}" + scope: "{{ 'system' if item.user == 'root' else 'user' }}" + loop: "{{ __systemd_dict_list }}" + become: "{{ item.user != 'root' }}" + become_user: "{{ item.user if item.user != 'root' else omit }}" + environment: + XDG_RUNTIME_DIR: "{{ item.xdg_dir }}" diff --git a/tasks/manage_user_info.yml b/tasks/manage_user_info.yml new file mode 100644 index 0000000..b0ad6db --- /dev/null +++ b/tasks/manage_user_info.yml @@ -0,0 +1,77 @@ +# SPDX-License-Identifier: MIT +# convert the given list which is either a list of scalar values or +# a list of dict to a list of dict +# if the given list has users, look up those users with getent +# the returned list of dict will have fields for xdg_dir +# and units_dir which may be user specific +# Input: +# __systemd_list - either a list of strings or a list of dicts +# Output: +# __systemd_user_info - dict mapping user name to xdg_dir +# and units_dir for that user - which may be root +# __systemd_dict_list - list of dict - each dict will have the +# following fields: +# item - the given item from the input list +# user - the user from the input list, or "root" +# state - the state from the input list, or "present" +# xdg_dir - the XDG runtime dir for the user +# units_dir - where to put the systemd unit files - user or system dir +--- +- name: Manage user info and units list + when: __systemd_list | length > 0 + vars: + __systemd_user_list: "{{ ((__systemd_list | select('mapping') | + selectattr('user', 'defined') | map(attribute='user') | list) + + ['root']) | unique | list }}" + block: + # extract user data from the systemd_unit lists, and look up + # user data using getent + - name: Get user information + include_tasks: get_update_user_info.yml + loop: "{{ __systemd_user_list }}" + loop_control: + loop_var: __systemd_user_name + + # convert to the output format + - name: Convert to list of dict with user data + set_fact: + __systemd_dict_list: | + {% set rv = [] %} + {% for item in __systemd_list %} + {% set newitem = {} %} + {% if item is mapping %} + {% set _ = newitem.update(item) %} + {% else %} + {% set _ = newitem.update({"item": item}) %} + {% endif %} + {% if "state" not in newitem %} + {% set _ = newitem.update({"state": "present"}) %} + {% endif %} + {% if "user" not in newitem %} + {% set _ = newitem.update({"user": "root"}) %} + {% endif %} + {% set user = newitem["user"] %} + {% if "group" not in newitem %} + {% set _ = newitem.update({"group": __systemd_user_info[user]["group"]}) %} + {% endif %} + {% if "xdg_dir" not in newitem %} + {% set _ = newitem.update({"xdg_dir": __systemd_user_info[user]["xdg_dir"]}) %} + {% endif %} + {% if "units_dir" not in newitem %} + {% set _ = newitem.update({"units_dir": __systemd_user_info[user]["units_dir"]}) %} + {% endif %} + {% if "mode" not in newitem %} + {% set _ = newitem.update({"mode": (user == "root") | ternary("0644", "0600")}) %} + {% endif %} + {% if "dir_mode" not in newitem %} + {% set _ = newitem.update({"dir_mode": (user == "root") | ternary("0755", "0700")}) %} + {% endif %} + {% set _ = rv.append(newitem) %} + {% endfor %} + {{ rv }} + + - name: Enable linger if needed + command: loginctl enable-linger {{ item | quote }} + loop: "{{ __systemd_user_list | reject('match', '^root$') | list }}" + args: + creates: /var/lib/systemd/linger/{{ item }} diff --git a/tests/files/bar.service b/tests/files/bar.service new file mode 100644 index 0000000..4ba8a41 --- /dev/null +++ b/tests/files/bar.service @@ -0,0 +1,6 @@ +[Service] +ExecStart=/bin/sleep 3600 +ExecReload=/bin/touch /tmp/bar-reloaded + +[Install] +WantedBy=multi-user.target diff --git a/tests/tests_user_units.yml b/tests/tests_user_units.yml new file mode 100644 index 0000000..fb9e0fb --- /dev/null +++ b/tests/tests_user_units.yml @@ -0,0 +1,250 @@ +# SPDX-License-Identifier: MIT +--- +- name: Ensure that the role works with a mix of user and system units + hosts: all + gather_facts: false + vars: + systemd_fail_if_too_old: false # allow test to pass on el7 + __users: + - name: systemd_user1 + uid: 5411 + - name: systemd_user2 + uid: 5412 + __all_units: + - user: "{{ __users[0].name }}" + item: bar.service + - user: root + item: bar.service + - user: "{{ __users[1].name }}" + item: bar.service + - user: "{{ __users[0].name }}" + item: foo.service + - user: root + item: foo.service + - user: "{{ __users[1].name }}" + item: foo.service + __systemd_unit_files: + - user: "{{ __users[0].name }}" + item: bar.service + - user: root + item: bar.service + - user: "{{ __users[1].name }}" + item: bar.service + __systemd_unit_file_templates: + - user: "{{ __users[0].name }}" + item: foo.service.j2 + - user: root + item: foo.service.j2 + - user: "{{ __users[1].name }}" + item: foo.service.j2 + __systemd_dropins: + - user: "{{ __users[0].name }}" + item: foo.service.conf.j2 + - user: root + item: foo.service.conf.j2 + - user: "{{ __users[1].name }}" + item: foo.service.conf.j2 + __systemd_started_units: "{{ __all_units }}" + __systemd_stopped_units: "{{ __all_units }}" + __systemd_restarted_units: "{{ __all_units }}" + __systemd_reloaded_units: "{{ __all_units }}" + __systemd_enabled_units: "{{ __all_units }}" + __systemd_disabled_units: "{{ __all_units }}" + __systemd_masked_units: "{{ __all_units }}" + __systemd_unmasked_units: "{{ __all_units }}" + tasks: + - name: Run tests + block: + - name: Create users + user: + name: "{{ item.name }}" + uid: "{{ item.uid }}" + loop: "{{ __users }}" + + - name: Run role to create and start units + include_role: + name: linux-system-roles.systemd + public: true + vars: + systemd_unit_files: "{{ __systemd_unit_files }}" + systemd_unit_file_templates: "{{ __systemd_unit_file_templates }}" + systemd_dropins: "{{ __systemd_dropins }}" + systemd_started_units: "{{ __systemd_started_units }}" + + - name: Verify units are present and started + assert: + that: + - >- + ansible_facts["systemd_units_user"][item.user] + [item.item] + ["active_state"] == "active" + - >- + ansible_facts["systemd_units_user"][item.user] + [item.item] + ["sub_state"] == "running" + loop: "{{ __all_units }}" + + - name: Verify files are present + stat: + path: "{{ __units_dir }}/{{ item.item }}" + register: __stat + failed_when: not __stat.stat.exists + loop: "{{ __systemd_unit_files }}" + vars: + __units_dir: "{{ __systemd_user_info[item.user]['units_dir'] }}" + + - name: Verify template files are present + stat: + path: "{{ __units_dir ~ '/' ~ __dest }}" + register: __stat + failed_when: not __stat.stat.exists + loop: "{{ __systemd_unit_file_templates }}" + vars: + __units_dir: "{{ __systemd_user_info[item.user]['units_dir'] }}" + __dest: "{{ item.item | regex_replace('[.]j2$', '') }}" + + - name: Verify dropin files are present + stat: + path: "{{ __path }}" + register: __stat + failed_when: not __stat.stat.exists + loop: "{{ __systemd_dropins }}" + vars: + __units_dir: "{{ __systemd_user_info[item.user]['units_dir'] }}" + __dest: "{{ item.item | regex_replace('[.]j2$', '') }}" + __path: "{{ __units_dir }}/\ + {{ '.'.join(__dest.split('.')[:-1]) }}.d/\ + 99-override.conf" + + - name: Get unit file state of units before + # noqa command-instead-of-module + command: systemctl {{ scope }} show -p UnitFileState {{ item.item }} + changed_when: false + register: __before_state + loop: "{{ __all_units }}" + failed_when: __before_state.stdout != "UnitFileState=disabled" + become: "{{ item.user != 'root' }}" + become_user: "{{ item.user if item.user != 'root' else omit }}" + environment: + XDG_RUNTIME_DIR: "{{ __systemd_user_info[item.user]['xdg_dir'] }}" + vars: + scope: "{{ '--system' if item.user == 'root' else '--user' }}" + + - name: Run role to enable units + include_role: + name: linux-system-roles.systemd + vars: + systemd_enabled_units: "{{ __systemd_enabled_units }}" + + - name: Get unit file state of units after + # noqa command-instead-of-module + command: systemctl {{ scope }} show -p UnitFileState {{ item.item }} + changed_when: false + register: __after_state + loop: "{{ __all_units }}" + failed_when: __after_state.stdout != "UnitFileState=enabled" + become: "{{ item.user != 'root' }}" + become_user: "{{ item.user if item.user != 'root' else omit }}" + environment: + XDG_RUNTIME_DIR: "{{ __systemd_user_info[item.user]['xdg_dir'] }}" + vars: + scope: "{{ '--system' if item.user == 'root' else '--user' }}" + + - name: Run role to disable units + include_role: + name: linux-system-roles.systemd + vars: + systemd_disabled_units: "{{ __systemd_disabled_units }}" + + - name: Get unit file state of units after + # noqa command-instead-of-module + command: systemctl {{ scope }} show -p UnitFileState {{ item.item }} + changed_when: false + register: __after_state + loop: "{{ __all_units }}" + failed_when: __after_state.stdout != "UnitFileState=disabled" + become: "{{ item.user != 'root' }}" + become_user: "{{ item.user if item.user != 'root' else omit }}" + environment: + XDG_RUNTIME_DIR: "{{ __systemd_user_info[item.user]['xdg_dir'] }}" + vars: + scope: "{{ '--system' if item.user == 'root' else '--user' }}" + + - name: Run role to stop units + include_role: + name: linux-system-roles.systemd + vars: + systemd_stopped_units: "{{ __systemd_stopped_units }}" + + - name: Verify units are absent + assert: + that: item.item not in ansible_facts["systemd_units_user"][item.user] + loop: "{{ __all_units }}" + + - name: Run role to remove unit files and dropins + include_role: + name: linux-system-roles.systemd + public: true + vars: + systemd_unit_files: "{{ __systemd_unit_files | + map('combine', __absent) | list }}" + systemd_unit_file_templates: "{{ __systemd_unit_file_templates | + map('combine', __absent) | list }}" + systemd_dropins: "{{ __systemd_dropins | + map('combine', __absent) | list }}" + systemd_started_units: [] + __absent: + state: absent + + - name: Verify files are absent + stat: + path: "{{ __units_dir }}/{{ item.item }}" + register: __stat + failed_when: __stat.stat.exists + loop: "{{ __systemd_unit_files }}" + vars: + __units_dir: "{{ __systemd_user_info[item.user]['units_dir'] }}" + + - name: Verify template files are absent + stat: + path: "{{ __units_dir ~ '/' ~ __dest }}" + register: __stat + failed_when: __stat.stat.exists + loop: "{{ __systemd_unit_file_templates }}" + vars: + __units_dir: "{{ __systemd_user_info[item.user]['units_dir'] }}" + __dest: "{{ item.item | regex_replace('[.]j2$', '') }}" + + - name: Verify dropin files are absent + stat: + path: "{{ __path }}" + register: __stat + failed_when: __stat.stat.exists + loop: "{{ __systemd_dropins }}" + vars: + __units_dir: "{{ __systemd_user_info[item.user]['units_dir'] }}" + __dest: "{{ item.item | regex_replace('[.]j2$', '') }}" + __path: "{{ __units_dir }}/\ + {{ '.'.join(__dest.split('.')[:-1]) }}.d/\ + 99-override.conf" + + - name: Verify no lingering + stat: + path: /var/lib/systemd/linger/{{ item.name }} + register: __stat + failed_when: __stat.stat.exists + loop: "{{ __users }}" + + rescue: + - name: Get journald information + command: journalctl -ex + failed_when: true + changed_when: false + + always: + - name: Remove users + user: + name: "{{ item.name }}" + uid: "{{ item.uid }}" + state: absent + loop: "{{ __users }}" diff --git a/vars/main.yml b/vars/main.yml index 8754bfd..9ab1b2e 100644 --- a/vars/main.yml +++ b/vars/main.yml @@ -4,6 +4,15 @@ __systemd_packages: - systemd __admin_units_dir: /etc/systemd/system +# relative to xdg config dir/home dir +__user_units_dir: .config/systemd/user + +# convenience for checking units +__systemd_all_units: "{{ systemd_unit_files + systemd_unit_file_templates + + systemd_dropins + systemd_started_units + systemd_stopped_units + + systemd_restarted_units + systemd_reloaded_units + systemd_enabled_units + + systemd_disabled_units + systemd_masked_units + systemd_unmasked_units }}" + # BEGIN - DO NOT EDIT THIS BLOCK - rh distros variables # Ansible distribution identifiers that the role treats like RHEL __systemd_rh_distros: