diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index f0d1b21b8f5..175982c6b6c 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -1364,6 +1364,8 @@ files: maintainers: konstruktoid $modules/systemd_creds_encrypt.py: maintainers: konstruktoid + $modules/systemd_info.py: + maintainers: NomakCooper $modules/sysupgrade.py: maintainers: precurse $modules/taiga_issue.py: diff --git a/plugins/modules/systemd_info.py b/plugins/modules/systemd_info.py new file mode 100644 index 00000000000..02845e94257 --- /dev/null +++ b/plugins/modules/systemd_info.py @@ -0,0 +1,361 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2025, Marco Noce +# 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: systemd_info +short_description: Gather C(systemd) unit info +description: + - This module gathers info about systemd units (services, targets, sockets, mount). + - It runs C(systemctl list-units) (or processes selected units) and collects properties + for each unit using C(systemctl show). + - Even if a unit has a RV(units.loadstate) of V(not-found) or V(masked), it is returned, + but only with the minimal properties (RV(units.name), RV(units.loadstate), RV(units.activestate), RV(units.substate)). + - When O(unitname) and O(extra_properties) are used, the module first checks if the unit exists, + then check if properties exist. If not, the module fails. +version_added: "10.4.0" +options: + unitname: + description: + - List of unit names to process. + - It supports C(.service), C(.target), C(.socket), and C(.mount) units type. + - Each name must correspond to the full name of the C(systemd) unit. + type: list + elements: str + default: [] + extra_properties: + description: + - Additional properties to retrieve (appended to the default ones). + - Note that all property names are converted to lower-case. + type: list + elements: str + default: [] +author: + - Marco Noce (@NomakCooper) +extends_documentation_fragment: + - community.general.attributes + - community.general.attributes.info_module +''' + +EXAMPLES = r''' +--- +# Gather info for all systemd services, targets, sockets and mount +- name: Gather all systemd unit info + community.general.systemd_info: + register: results + +# Gather info for selected units with extra properties. +- name: Gather info for selected unit(s) + community.general.systemd_info: + unitname: + - systemd-journald.service + - systemd-journald.socket + - sshd-keygen.target + - -.mount + extra_properties: + - Description + register: results +''' + +RETURN = r''' +units: + description: + - Dictionary of systemd unit info keyed by unit name. + - Additional fields will be returned depending on the value of O(extra_properties). + returned: success + type: dict + elements: dict + contains: + name: + description: Unit full name. + returned: always + type: str + sample: systemd-journald.service + loadstate: + description: + - The state of the unit's configuration load. + - The most common values are V(loaded), V(not-found), and V(masked), but other values are possible as well. + returned: always + type: str + sample: loaded + activestate: + description: + - The current active state of the unit. + - The most common values are V(active), V(inactive), and V(failed), but other values are possible as well. + returned: always + type: str + sample: active + substate: + description: + - The detailed sub state of the unit. + - The most common values are V(running), V(dead), V(exited), V(failed), V(listening), V(active), and V(mounted), but other values are possible as well. + returned: always + type: str + sample: running + fragmentpath: + description: Path to the unit's fragment file. + returned: always except for C(.mount) units. + type: str + sample: /usr/lib/systemd/system/systemd-journald.service + unitfilepreset: + description: + - The preset configuration state for the unit file. + - The most common values are V(enabled), V(disabled), and V(static), but other values are possible as well. + returned: always except for C(.mount) units. + type: str + sample: disabled + unitfilestate: + description: + - The actual configuration state for the unit file. + - The most common values are V(enabled), V(disabled), and V(static), but other values are possible as well. + returned: always except for C(.mount) units. + type: str + sample: enabled + mainpid: + description: PID of the main process of the unit. + returned: only for C(.service) units. + type: str + sample: 798 + execmainpid: + description: PID of the ExecStart process of the unit. + returned: only for C(.service) units. + type: str + sample: 799 + options: + description: The mount options. + returned: only for C(.mount) units. + type: str + sample: rw,relatime,noquota + type: + description: The filesystem type of the mounted device. + returned: only for C(.mount) units. + type: str + sample: ext4 + what: + description: The device that is mounted. + returned: only for C(.mount) units. + type: str + sample: /dev/sda1 + where: + description: The mount point where the device is mounted. + returned: only for C(.mount) units. + type: str + sample: / + sample: { + "-.mount": { + "activestate": "active", + "description": "Root Mount", + "loadstate": "loaded", + "name": "-.mount", + "options": "rw,relatime,seclabel,attr2,inode64,logbufs=8,logbsize=32k,noquota", + "substate": "mounted", + "type": "xfs", + "what": "/dev/mapper/cs-root", + "where": "/" + }, + "sshd-keygen.target": { + "activestate": "active", + "description": "sshd-keygen.target", + "fragmentpath": "/usr/lib/systemd/system/sshd-keygen.target", + "loadstate": "loaded", + "name": "sshd-keygen.target", + "substate": "active", + "unitfilepreset": "disabled", + "unitfilestate": "static" + }, + "systemd-journald.service": { + "activestate": "active", + "description": "Journal Service", + "execmainpid": "613", + "fragmentpath": "/usr/lib/systemd/system/systemd-journald.service", + "loadstate": "loaded", + "mainpid": "613", + "name": "systemd-journald.service", + "substate": "running", + "unitfilepreset": "disabled", + "unitfilestate": "static" + }, + "systemd-journald.socket": { + "activestate": "active", + "description": "Journal Socket", + "fragmentpath": "/usr/lib/systemd/system/systemd-journald.socket", + "loadstate": "loaded", + "name": "systemd-journald.socket", + "substate": "running", + "unitfilepreset": "disabled", + "unitfilestate": "static" + } + } +''' + +from ansible.module_utils.basic import AnsibleModule + + +def run_command(module, cmd): + rc, stdout, stderr = module.run_command(cmd, check_rc=True) + return stdout.strip() + + +def parse_show_output(output): + result = {} + for line in output.splitlines(): + if "=" in line: + key, val = line.split("=", 1) + key = key.lower() + if key not in result: + result[key] = val + return result + + +def get_unit_properties(module, systemctl_bin, unit, prop_list): + cmd = [systemctl_bin, "show", "-p", ",".join(prop_list), "--", unit] + output = run_command(module, cmd) + return parse_show_output(output) + + +def determine_category(unit): + if unit.endswith('.service'): + return 'service' + elif unit.endswith('.target'): + return 'target' + elif unit.endswith('.socket'): + return 'socket' + elif unit.endswith('.mount'): + return 'mount' + else: + return None + + +def extract_unit_properties(unit_data, prop_list): + lowerprop = [x.lower() for x in prop_list] + extracted = { + prop: unit_data[prop] for prop in lowerprop if prop in unit_data + } + return extracted + + +def unit_exists(module, systemctl_bin, unit): + cmd = [systemctl_bin, "show", "-p", "LoadState", "--", unit] + rc, stdout, stderr = module.run_command(cmd) + return (rc == 0) + + +def validate_unit_and_properties(module, systemctl_bin, unit, extra_properties): + cmd = [systemctl_bin, "show", "-p", "LoadState", "--", unit] + + output = run_command(module, cmd) + if "loadstate=not-found" in output.lower(): + module.fail_json(msg="Unit '{0}' does not exist or is inaccessible.".format(unit)) + + if extra_properties: + unit_data = get_unit_properties(module, systemctl_bin, unit, extra_properties) + missing_props = [prop for prop in extra_properties if prop.lower() not in unit_data] + if missing_props: + module.fail_json(msg="The following properties do not exist for unit '{0}': {1}".format(unit, ", ".join(missing_props))) + + +def main(): + module_args = dict( + unitname=dict(type='list', elements='str', default=[]), + extra_properties=dict(type='list', elements='str', default=[]) + ) + + module = AnsibleModule( + argument_spec=module_args, + supports_check_mode=True + ) + + systemctl_bin = module.get_bin_path('systemctl', required=True) + + run_command(module, [systemctl_bin, '--version']) + + base_properties = { + 'service': ['FragmentPath', 'UnitFileState', 'UnitFilePreset', 'MainPID', 'ExecMainPID'], + 'target': ['FragmentPath', 'UnitFileState', 'UnitFilePreset'], + 'socket': ['FragmentPath', 'UnitFileState', 'UnitFilePreset'], + 'mount': ['Where', 'What', 'Options', 'Type'] + } + state_props = ['LoadState', 'ActiveState', 'SubState'] + + results = {} + + if not module.params['unitname']: + list_cmd = [ + systemctl_bin, "list-units", + "--no-pager", + "--type", "service,target,socket,mount", + "--all", + "--plain", + "--no-legend" + ] + list_output = run_command(module, list_cmd) + for line in list_output.splitlines(): + tokens = line.split() + if len(tokens) < 4: + continue + + unit_name = tokens[0] + loadstate = tokens[1] + activestate = tokens[2] + substate = tokens[3] + + fact = { + "name": unit_name, + "loadstate": loadstate, + "activestate": activestate, + "substate": substate + } + + if loadstate in ("not-found", "masked"): + results[unit_name] = fact + continue + + category = determine_category(unit_name) + if not category: + results[unit_name] = fact + continue + + props = base_properties.get(category, []) + full_props = set(props + state_props) + unit_data = get_unit_properties(module, systemctl_bin, unit_name, full_props) + + fact.update(extract_unit_properties(unit_data, full_props)) + results[unit_name] = fact + + else: + selected_units = module.params['unitname'] + extra_properties = module.params['extra_properties'] + + for unit in selected_units: + validate_unit_and_properties(module, systemctl_bin, unit, extra_properties) + category = determine_category(unit) + + if not category: + module.fail_json(msg="Could not determine the category for unit '{0}'.".format(unit)) + + props = base_properties.get(category, []) + full_props = set(props + state_props + extra_properties) + unit_data = get_unit_properties(module, systemctl_bin, unit, full_props) + fact = {"name": unit} + minimal_keys = ["LoadState", "ActiveState", "SubState"] + + fact.update(extract_unit_properties(unit_data, minimal_keys)) + + ls = unit_data.get("loadstate", "").lower() + if ls not in ("not-found", "masked"): + fact.update(extract_unit_properties(unit_data, full_props)) + + results[unit] = fact + + module.exit_json(changed=False, units=results) + + +if __name__ == '__main__': + main() diff --git a/tests/integration/targets/systemd_info/aliases b/tests/integration/targets/systemd_info/aliases new file mode 100644 index 00000000000..84a120ca8cb --- /dev/null +++ b/tests/integration/targets/systemd_info/aliases @@ -0,0 +1,10 @@ +# Copyright (c) Ansible Project +# 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 + +needs/root +azp/posix/1 +skip/aix +skip/freebsd +skip/osx +skip/macos \ No newline at end of file diff --git a/tests/integration/targets/systemd_info/tasks/main.yml b/tests/integration/targets/systemd_info/tasks/main.yml new file mode 100644 index 00000000000..dabc5fae9a4 --- /dev/null +++ b/tests/integration/targets/systemd_info/tasks/main.yml @@ -0,0 +1,26 @@ +--- +# Copyright (c) Ansible Project +# 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 + +- name: skip Alpine + meta: end_host + when: ansible_distribution == 'Alpine' + +- name: check ansible_service_mgr + ansible.builtin.assert: + that: ansible_service_mgr == 'systemd' + +- name: Test systemd_facts + block: + + - name: Run tests + import_tasks: tests.yml + + when: > + (ansible_distribution in ['RedHat', 'CentOS', 'ScientificLinux'] and ansible_distribution_major_version is version('7', '>=')) or + ansible_distribution == 'Fedora' or + (ansible_distribution == 'Ubuntu' and ansible_distribution_version is version('15.04', '>=')) or + (ansible_distribution == 'Debian' and ansible_distribution_version is version('8', '>=')) or + ansible_os_family == 'Suse' or + ansible_distribution == 'Archlinux' \ No newline at end of file diff --git a/tests/integration/targets/systemd_info/tasks/tests.yml b/tests/integration/targets/systemd_info/tasks/tests.yml new file mode 100644 index 00000000000..0806885b9bf --- /dev/null +++ b/tests/integration/targets/systemd_info/tasks/tests.yml @@ -0,0 +1,107 @@ +# Copyright (c) Ansible Project +# 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 + +- name: Gather all units from shell + ansible.builtin.command: systemctl list-units --no-pager --type service,target,socket,mount --all --plain --no-legend + register: all_units + +- name: Assert command run successfully + ansible.builtin.assert: + that: + - all_units.rc == 0 + +- name: Gather all units + community.general.systemd_info: + register: units_all + +- name: Check all units exists + ansible.builtin.assert: + that: + - units_all is defined + - units_all.units | length == all_units.stdout_lines | length + success_msg: "Success: All units collected." + +- name: Build all units list + set_fact: + shell_units: "{{ all_units.stdout_lines | map('split') | list }}" + +- name: Check all units properties + ansible.builtin.assert: + that: + - units_all.units[item[0]].name == item[0] + - units_all.units[item[0]].loadstate == item[1] + - units_all.units[item[0]].activestate == item[2] + - units_all.units[item[0]].substate == item[3] + loop: "{{ shell_units }}" + loop_control: + label: "{{ item[0] }}" + +- name: Gather systemd-journald.service properties from shell + ansible.builtin.command: systemctl show systemd-journald.service -p Id,LoadState,ActiveState,SubState,FragmentPath,MainPID,ExecMainPID,UnitFileState,UnitFilePreset,Description,Restart + register: journald_prop + +- name: Assert command run successfully + ansible.builtin.assert: + that: + - journald_prop.rc == 0 + +- name: Gather systemd-journald.service + community.general.systemd_info: + unitname: + - systemd-journald.service + register: journal_unit + +- name: Check unit facts and all properties + ansible.builtin.assert: + that: + - journal_unit.units is defined + - journal_unit.units['systemd-journald.service'] is defined + - journal_unit.units['systemd-journald.service'].name is defined + - journal_unit.units['systemd-journald.service'].loadstate is defined + - journal_unit.units['systemd-journald.service'].activestate is defined + - journal_unit.units['systemd-journald.service'].substate is defined + - journal_unit.units['systemd-journald.service'].fragmentpath is defined + - journal_unit.units['systemd-journald.service'].mainpid is defined + - journal_unit.units['systemd-journald.service'].execmainpid is defined + - journal_unit.units['systemd-journald.service'].unitfilestate is defined + - journal_unit.units['systemd-journald.service'].unitfilepreset is defined + success_msg: "Success: All properties collected." + +- name: Create dict of properties from shell + ansible.builtin.set_fact: + journald_shell: "{{ dict(journald_prop.stdout_lines | map('split', '=', 1) | list) }}" + +- name: Check properties content + ansible.builtin.assert: + that: + - journal_unit.units['systemd-journald.service'].name == journald_shell.Id + - journal_unit.units['systemd-journald.service'].loadstate == journald_shell.LoadState + - journal_unit.units['systemd-journald.service'].activestate == journald_shell.ActiveState + - journal_unit.units['systemd-journald.service'].substate == journald_shell.SubState + - journal_unit.units['systemd-journald.service'].fragmentpath == journald_shell.FragmentPath + - journal_unit.units['systemd-journald.service'].mainpid == journald_shell.MainPID + - journal_unit.units['systemd-journald.service'].execmainpid == journald_shell.ExecMainPID + - journal_unit.units['systemd-journald.service'].unitfilestate == journald_shell.UnitFileState + - journal_unit.units['systemd-journald.service'].unitfilepreset == journald_shell.UnitFilePreset + success_msg: "Success: Property values are correct." + +- name: Gather systemd-journald.service extra properties + community.general.systemd_info: + unitname: + - systemd-journald.service + extra_properties: + - Description + - Restart + register: journal_extra + +- name: Check new properties + ansible.builtin.assert: + that: + - journal_extra.units is defined + - journal_extra.units['systemd-journald.service'] is defined + - journal_extra.units['systemd-journald.service'].description is defined + - journal_extra.units['systemd-journald.service'].restart is defined + - journal_extra.units['systemd-journald.service'].description == journald_shell.Description + - journal_extra.units['systemd-journald.service'].restart == journald_shell.Restart + success_msg: "Success: Extra property values are correct." \ No newline at end of file