Skip to content

Commit

Permalink
feat: add support for user units
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
richm committed Nov 12, 2024
1 parent 5aa54ad commit c295253
Show file tree
Hide file tree
Showing 14 changed files with 830 additions and 109 deletions.
72 changes: 67 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand All @@ -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

Expand All @@ -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:
Expand All @@ -130,3 +191,4 @@ MIT
## Author

Michal Sekletar <[email protected]>
Rich Megginson <[email protected]>
13 changes: 13 additions & 0 deletions defaults/main.yml
Original file line number Diff line number Diff line change
@@ -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: []
Expand All @@ -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
35 changes: 30 additions & 5 deletions library/systemd_units.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down Expand Up @@ -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)
Expand All @@ -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)


Expand Down
18 changes: 18 additions & 0 deletions tasks/cancel_linger.yml
Original file line number Diff line number Diff line change
@@ -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
53 changes: 53 additions & 0 deletions tasks/cancel_linger_for_user.yml
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions tasks/get_systemd_units_info.yml
Original file line number Diff line number Diff line change
@@ -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'] }}"
40 changes: 40 additions & 0 deletions tasks/get_update_user_info.yml
Original file line number Diff line number Diff line change
@@ -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 }}
Loading

0 comments on commit c295253

Please sign in to comment.