Repository: ansible-collections/community.general

Issue:
shutdown: does not work on systemd-based systems without systemd-sysv installed
### Summary
I think this is a bug, since nowadays systemd is very common, and often systemd-sysv isn't installed (which provides a `shutdown` executable).

This is relevant on some of the new systems added to CI in #4222, like Arch Linux.

### Issue Type

Bug Report

### Component Name

shutdown

### Ansible Version

unrelated



Discussion:
Files identified in the description:
* [`plugins/modules/system/shutdown.py`](https://github.com/ansible-collections/community.general/blob/main/plugins/modules/system/shutdown.py)

If these files are incorrect, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
cc @aminvakil @nitzmahone @samdoran
[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: notify --->
I'm old school so I had to search for what the systemd equivalent would be. Apparently:
* `systemctl start reboot.target`
* `systemctl start halt.target`
* `systemctl start poweroff.target`

there is also `poweroff` in Linux
There are also shortcuts for these, namely `systemctl reboot`, `systemctl halt` and `systemctl poweroff`. (I'm not sure whether they are proper shortcuts, or they might do something different.)

Anyway, `poweroff` also comes from SysV, and probably is also installed by systemd-sysv (which symlinks it to `systemctl`, same as the other commands), so this probably will only work when `shutdown` also works.
Files identified in the description:
* [`plugins/modules/shutdown.py`](https://github.com/ansible-collections/community.general/blob/main/plugins/modules/shutdown.py)

If these files are incorrect, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->

Resolving patch:
diff --git a/plugins/action/shutdown.py b/plugins/action/shutdown.py
--- a/plugins/action/shutdown.py
+++ b/plugins/action/shutdown.py
@@ -6,6 +6,7 @@
 # SPDX-License-Identifier: GPL-3.0-or-later
 
 from __future__ import (absolute_import, division, print_function)
+
 __metaclass__ = type
 
 from ansible.errors import AnsibleError, AnsibleConnectionFailure
@@ -80,13 +81,6 @@ def _get_value_from_facts(self, variable_name, distribution, default_value):
                     getattr(self, default_value))))
         return value
 
-    def get_shutdown_command_args(self, distribution):
-        args = self._get_value_from_facts('SHUTDOWN_COMMAND_ARGS', distribution, 'DEFAULT_SHUTDOWN_COMMAND_ARGS')
-        # Convert seconds to minutes. If less that 60, set it to 0.
-        delay_sec = self.delay
-        shutdown_message = self._task.args.get('msg', self.DEFAULT_SHUTDOWN_MESSAGE)
-        return args.format(delay_sec=delay_sec, delay_min=delay_sec // 60, message=shutdown_message)
-
     def get_distribution(self, task_vars):
         # FIXME: only execute the module if we don't already have the facts we need
         distribution = {}
@@ -101,7 +95,8 @@ def get_distribution(self, task_vars):
                     to_native(module_output['module_stdout']).strip(),
                     to_native(module_output['module_stderr']).strip()))
             distribution['name'] = module_output['ansible_facts']['ansible_distribution'].lower()
-            distribution['version'] = to_text(module_output['ansible_facts']['ansible_distribution_version'].split('.')[0])
+            distribution['version'] = to_text(
+                module_output['ansible_facts']['ansible_distribution_version'].split('.')[0])
             distribution['family'] = to_text(module_output['ansible_facts']['ansible_os_family'].lower())
             display.debug("{action}: distribution: {dist}".format(action=self._task.action, dist=distribution))
             return distribution
@@ -109,6 +104,23 @@ def get_distribution(self, task_vars):
             raise AnsibleError('Failed to get distribution information. Missing "{0}" in output.'.format(ke.args[0]))
 
     def get_shutdown_command(self, task_vars, distribution):
+        def find_command(command, find_search_paths):
+            display.debug('{action}: running find module looking in {paths} to get path for "{command}"'.format(
+                action=self._task.action,
+                command=command,
+                paths=find_search_paths))
+            find_result = self._execute_module(
+                task_vars=task_vars,
+                # prevent collection search by calling with ansible.legacy (still allows library/ override of find)
+                module_name='ansible.legacy.find',
+                module_args={
+                    'paths': find_search_paths,
+                    'patterns': [command],
+                    'file_type': 'any'
+                }
+            )
+            return [x['path'] for x in find_result['files']]
+
         shutdown_bin = self._get_value_from_facts('SHUTDOWN_COMMANDS', distribution, 'DEFAULT_SHUTDOWN_COMMAND')
         default_search_paths = ['/sbin', '/usr/sbin', '/usr/local/sbin']
         search_paths = self._task.args.get('search_paths', default_search_paths)
@@ -127,45 +139,53 @@ def get_shutdown_command(self, task_vars, distribution):
         except TypeError:
             raise AnsibleError(err_msg.format(search_paths))
 
-        display.debug('{action}: running find module looking in {paths} to get path for "{command}"'.format(
-            action=self._task.action,
-            command=shutdown_bin,
-            paths=search_paths))
-        find_result = self._execute_module(
-            task_vars=task_vars,
-            # prevent collection search by calling with ansible.legacy (still allows library/ override of find)
-            module_name='ansible.legacy.find',
-            module_args={
-                'paths': search_paths,
-                'patterns': [shutdown_bin],
-                'file_type': 'any'
-            }
-        )
-
-        full_path = [x['path'] for x in find_result['files']]
-        if not full_path:
-            raise AnsibleError('Unable to find command "{0}" in search paths: {1}'.format(shutdown_bin, search_paths))
-        self._shutdown_command = full_path[0]
-        return self._shutdown_command
+        full_path = find_command(shutdown_bin, search_paths)  # find the path to the shutdown command
+        if not full_path:  # if we could not find the shutdown command
+            display.vvv('Unable to find command "{0}" in search paths: {1}, will attempt a shutdown using systemd '
+                        'directly.'.format(shutdown_bin, search_paths))  # tell the user we will try with systemd
+            systemctl_search_paths = ['/bin', '/usr/bin']
+            full_path = find_command('systemctl', systemctl_search_paths)  # find the path to the systemctl command
+            if not full_path:  # if we couldn't find systemctl
+                raise AnsibleError(
+                    'Could not find command "{0}" in search paths: {1} or systemctl command in search paths: {2}, unable to shutdown.'.
+                    format(shutdown_bin, search_paths, systemctl_search_paths))  # we give up here
+            else:
+                return "{0} poweroff".format(full_path[0])  # done, since we cannot use args with systemd shutdown
+
+        # systemd case taken care of, here we add args to the command
+        args = self._get_value_from_facts('SHUTDOWN_COMMAND_ARGS', distribution, 'DEFAULT_SHUTDOWN_COMMAND_ARGS')
+        # Convert seconds to minutes. If less that 60, set it to 0.
+        delay_sec = self.delay
+        shutdown_message = self._task.args.get('msg', self.DEFAULT_SHUTDOWN_MESSAGE)
+        return '{0} {1}'. \
+            format(
+                full_path[0],
+                args.format(
+                    delay_sec=delay_sec,
+                    delay_min=delay_sec // 60,
+                    message=shutdown_message
+                )
+            )
 
     def perform_shutdown(self, task_vars, distribution):
         result = {}
         shutdown_result = {}
-        shutdown_command = self.get_shutdown_command(task_vars, distribution)
-        shutdown_command_args = self.get_shutdown_command_args(distribution)
-        shutdown_command_exec = '{0} {1}'.format(shutdown_command, shutdown_command_args)
+        shutdown_command_exec = self.get_shutdown_command(task_vars, distribution)
 
         self.cleanup(force=True)
         try:
             display.vvv("{action}: shutting down server...".format(action=self._task.action))
-            display.debug("{action}: shutting down server with command '{command}'".format(action=self._task.action, command=shutdown_command_exec))
+            display.debug("{action}: shutting down server with command '{command}'".
+                          format(action=self._task.action, command=shutdown_command_exec))
             if self._play_context.check_mode:
                 shutdown_result['rc'] = 0
             else:
                 shutdown_result = self._low_level_execute_command(shutdown_command_exec, sudoable=self.DEFAULT_SUDOABLE)
         except AnsibleConnectionFailure as e:
             # If the connection is closed too quickly due to the system being shutdown, carry on
-            display.debug('{action}: AnsibleConnectionFailure caught and handled: {error}'.format(action=self._task.action, error=to_text(e)))
+            display.debug(
+                '{action}: AnsibleConnectionFailure caught and handled: {error}'.format(action=self._task.action,
+                                                                                        error=to_text(e)))
             shutdown_result['rc'] = 0
 
         if shutdown_result['rc'] != 0:
diff --git a/plugins/modules/shutdown.py b/plugins/modules/shutdown.py
--- a/plugins/modules/shutdown.py
+++ b/plugins/modules/shutdown.py
@@ -14,6 +14,8 @@
 notes:
   - C(PATH) is ignored on the remote node when searching for the C(shutdown) command. Use I(search_paths)
     to specify locations to search if the default paths do not work.
+  - The I(msg) and I(delay) options are not supported when a shutdown command is not found in I(search_paths), instead
+    the module will attempt to shutdown the system by calling C(systemctl shutdown).
 description:
   - Shut downs a machine.
 version_added: "1.1.0"


Test patch:
diff --git a/tests/integration/targets/shutdown/aliases b/tests/integration/targets/shutdown/aliases
--- a/tests/integration/targets/shutdown/aliases
+++ b/tests/integration/targets/shutdown/aliases
@@ -3,3 +3,4 @@
 # SPDX-License-Identifier: GPL-3.0-or-later
 
 azp/posix/1
+destructive
diff --git a/tests/integration/targets/shutdown/tasks/main.yml b/tests/integration/targets/shutdown/tasks/main.yml
--- a/tests/integration/targets/shutdown/tasks/main.yml
+++ b/tests/integration/targets/shutdown/tasks/main.yml
@@ -7,13 +7,7 @@
 # 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: Install systemd-sysv on Ubuntu 18 and Debian
-  apt:
-    name: systemd-sysv
-    state: present
-  when: (ansible_distribution == 'Ubuntu' and ansible_distribution_major_version is version('18', '>=')) or (ansible_distribution == 'Debian')
-  register: systemd_sysv_install
+#
 
 - name: Execute shutdown with custom message and delay
   community.general.shutdown:
@@ -34,29 +28,44 @@
       - '"Custom Message" in shutdown_result["shutdown_command"]'
       - '"Shut down initiated by Ansible" in shutdown_result_minus["shutdown_command"]'
       - '"Custom Message" not in shutdown_result_minus["shutdown_command"]'
-  when: ansible_os_family not in ['Alpine', 'AIX']
+  when:
+    - 'ansible_os_family not in ["Alpine", "AIX"]'
+    - '"systemctl" not in shutdown_result["shutdown_command"]'
+    - '"systemctl" not in shutdown_result_minus["shutdown_command"]'
 
-- name: Verify shutdown command is present except Alpine, VMKernel
+- name: Verify shutdown command is present except Alpine or AIX or systemd
   assert:
     that: '"shutdown" in shutdown_result["shutdown_command"]'
-  when: ansible_os_family != 'Alpine' and ansible_system != 'VMKernel'
+  when:
+    - "ansible_os_family != 'Alpine'"
+    - "ansible_system != 'VMKernel'"
+    - '"systemctl" not in shutdown_result["shutdown_command"]'
 
-- name: Verify shutdown command is present in Alpine
+- name: Verify shutdown command is present in Alpine except systemd
   assert:
     that: '"poweroff" in shutdown_result["shutdown_command"]'
-  when: ansible_os_family == 'Alpine'
+  when: 
+    - "ansible_os_family == 'Alpine'"
+    - '"systemctl" not in shutdown_result["shutdown_command"]'
 
-- name: Verify shutdown command is present in VMKernel
+
+- name: Verify shutdown command is present in VMKernel except systemd
   assert:
     that: '"halt" in shutdown_result["shutdown_command"]'
-  when: ansible_system == 'VMKernel'
+  when: 
+    - "ansible_system == 'VMKernel'"
+    - '"systemctl" not in shutdown_result["shutdown_command"]'
 
-- name: Verify shutdown delay is present in minutes in Linux
+- name: Verify shutdown delay is present in minutes in Linux except systemd
   assert:
     that:
       - '"-h 1" in shutdown_result["shutdown_command"]'
       - '"-h 0" in shutdown_result_minus["shutdown_command"]'
-  when: ansible_system == 'Linux' and ansible_os_family != 'Alpine'
+  when:
+    - "ansible_system == 'Linux'"
+    - "ansible_os_family != 'Alpine'"
+    - '"systemctl" not in shutdown_result["shutdown_command"]'
+    - '"systemctl" not in shutdown_result_minus["shutdown_command"]'
 
 - name: Verify shutdown delay is present in minutes in Void, MacOSX, OpenBSD
   assert:
@@ -86,8 +95,30 @@
       - '"-d 0" in shutdown_result_minus["shutdown_command"]'
   when: ansible_system == 'VMKernel'
 
-- name: Remove systemd-sysv in ubuntu 18 in case it has been installed in test
+- name: Ensure that systemd-sysv is absent in Ubuntu 18 and Debian
   apt:
-    name: systemd-sysv
+    name: sytemd-sysv
     state: absent
-  when: systemd_sysv_install is changed
+  when: (ansible_distribution == 'Ubuntu' and ansible_distribution_major_version is version('18', '>=')) or (ansible_distribution == 'Debian')
+  register: systemd_sysv_install
+
+- name: Gather package facts
+  package_facts:
+    manager: apt
+  when: (ansible_distribution == 'Ubuntu' and ansible_distribution_major_version is version('18', '>=')) or (ansible_distribution == 'Debian')
+
+- name: Execute shutdown if no systemd-sysv
+  community.general.shutdown:
+  register: shutdown_result
+  check_mode: true
+  when: 
+    - "(ansible_distribution == 'Ubuntu' and ansible_distribution_major_version is version('18', '>=')) or (ansible_distribution == 'Debian')"
+    - '"systemd-sysv" not in ansible_facts.packages'
+
+- name: Install systemd_sysv in case it has been removed in test
+  apt:
+    name: systemd-sysv
+    state: present
+  when:
+    - "(ansible_distribution == 'Ubuntu' and ansible_distribution_major_version is version('18', '>=')) or (ansible_distribution == 'Debian')"
+    - "systemd_sysv_install is changed"