diff --git a/changelogs/fragments/reboot-add-boot-command-parameter.yaml b/changelogs/fragments/reboot-add-boot-command-parameter.yaml new file mode 100644 index 00000000000..8790c82b6f1 --- /dev/null +++ b/changelogs/fragments/reboot-add-boot-command-parameter.yaml @@ -0,0 +1,4 @@ +minor_changes: + - > + reboot - add ``reboot_command`` parameter to allow specifying the command + used to reboot the system (https://github.com/ansible/ansible/issues/51359) diff --git a/lib/ansible/modules/reboot.py b/lib/ansible/modules/reboot.py index c5066560161..28378ae5ca0 100644 --- a/lib/ansible/modules/reboot.py +++ b/lib/ansible/modules/reboot.py @@ -60,7 +60,7 @@ options: - Paths to search on the remote machine for the C(shutdown) command. - I(Only) these paths will be searched for the C(shutdown) command. C(PATH) is ignored in the remote node when searching for the C(shutdown) command. type: list - default: ['/sbin', '/usr/sbin', '/usr/local/sbin'] + default: ['/sbin', '/bin', '/usr/sbin', '/usr/bin', '/usr/local/sbin'] version_added: '2.8' boot_time_command: @@ -70,6 +70,16 @@ options: type: str default: 'cat /proc/sys/kernel/random/boot_id' version_added: '2.10' + + reboot_command: + description: + - Command to run that reboots the system, including any parameters passed to the command. + - Can be an absolute path to the command or just the command name. If an absolute path to the + command is not given, C(search_paths) on the target system will be searched to find the absolute path. + - This will cause C(pre_reboot_delay), C(post_reboot_delay), and C(msg) to be ignored. + type: str + default: '[determined based on target OS]' + version_added: '2.11' seealso: - module: ansible.windows.win_reboot author: @@ -89,6 +99,12 @@ EXAMPLES = r''' reboot: search_paths: - '/lib/molly-guard' + +- name: Reboot machine using a custom reboot command + reboot: + reboot_command: launchctl reboot userspace + boot_time_command: uptime | cut -d ' ' -f 5 + ''' RETURN = r''' diff --git a/lib/ansible/plugins/action/reboot.py b/lib/ansible/plugins/action/reboot.py index d898a1aec22..64397b12fa1 100644 --- a/lib/ansible/plugins/action/reboot.py +++ b/lib/ansible/plugins/action/reboot.py @@ -12,8 +12,7 @@ from datetime import datetime, timedelta from ansible.errors import AnsibleError, AnsibleConnectionFailure from ansible.module_utils._text import to_native, to_text -from ansible.module_utils.common.collections import is_string -from ansible.module_utils.common.validation import check_type_str +from ansible.module_utils.common.validation import check_type_list, check_type_str from ansible.plugins.action import ActionBase from ansible.utils.display import Display @@ -32,9 +31,10 @@ class ActionModule(ActionBase): 'msg', 'post_reboot_delay', 'pre_reboot_delay', - 'test_command', + 'reboot_command', 'reboot_timeout', - 'search_paths' + 'search_paths', + 'test_command', )) DEFAULT_REBOOT_TIMEOUT = 600 @@ -114,11 +114,25 @@ class ActionModule(ActionBase): 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_min = self.pre_reboot_delay // 60 - reboot_message = self._task.args.get('msg', self.DEFAULT_REBOOT_MESSAGE) - return args.format(delay_sec=self.pre_reboot_delay, delay_min=delay_min, message=reboot_message) + reboot_command = self._task.args.get('reboot_command') + if reboot_command is not None: + try: + reboot_command = check_type_str(reboot_command, allow_conversion=False) + except TypeError as e: + raise AnsibleError("Invalid value given for 'reboot_command': %s." % to_native(e)) + + # No args were provided + try: + return reboot_command.split(' ', 1)[1] + except IndexError: + return '' + else: + 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_min = self.pre_reboot_delay // 60 + reboot_message = self._task.args.get('msg', self.DEFAULT_REBOOT_MESSAGE) + return args.format(delay_sec=self.pre_reboot_delay, delay_min=delay_min, message=reboot_message) def get_distribution(self, task_vars): # FIXME: only execute the module if we don't already have the facts we need @@ -142,44 +156,49 @@ class ActionModule(ActionBase): raise AnsibleError('Failed to get distribution information. Missing "{0}" in output.'.format(ke.args[0])) def get_shutdown_command(self, task_vars, distribution): - 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) + reboot_command = self._task.args.get('reboot_command') + if reboot_command is not None: + try: + reboot_command = check_type_str(reboot_command, allow_conversion=False) + except TypeError as e: + raise AnsibleError("Invalid value given for 'reboot_command': %s." % to_native(e)) + shutdown_bin = reboot_command.split(' ', 1)[0] + else: + shutdown_bin = self._get_value_from_facts('SHUTDOWN_COMMANDS', distribution, 'DEFAULT_SHUTDOWN_COMMAND') - # FIXME: switch all this to user arg spec validation methods when they are available - # Convert bare strings to a list - if is_string(search_paths): - search_paths = [search_paths] + if shutdown_bin[0] == '/': + return shutdown_bin + else: + default_search_paths = ['/sbin', '/bin', '/usr/sbin', '/usr/bin', '/usr/local/sbin'] + search_paths = self._task.args.get('search_paths', default_search_paths) - # Error if we didn't get a list - err_msg = "'search_paths' must be a string or flat list of strings, got {0}" - try: - incorrect_type = any(not is_string(x) for x in search_paths) - if not isinstance(search_paths, list) or incorrect_type: - raise TypeError - 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 + try: + # Convert bare strings to a list + search_paths = check_type_list(search_paths) + except TypeError: + err_msg = "'search_paths' must be a string or flat list of strings, got {0}" + 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)) + return full_path[0] def deprecated_args(self): for arg, version in self.DEPRECATED_ARGS.items(): @@ -322,7 +341,7 @@ class ActionModule(ActionBase): if reboot_result['rc'] != 0: result['failed'] = True result['rebooted'] = False - result['msg'] = "Reboot command failed. Error was {stdout}, {stderr}".format( + result['msg'] = "Reboot command failed. Error was: '{stdout}, {stderr}'".format( stdout=to_native(reboot_result['stdout'].strip()), stderr=to_native(reboot_result['stderr'].strip())) return result diff --git a/test/integration/targets/reboot/handlers/main.yml b/test/integration/targets/reboot/handlers/main.yml new file mode 100644 index 00000000000..a40bac09929 --- /dev/null +++ b/test/integration/targets/reboot/handlers/main.yml @@ -0,0 +1,4 @@ +- name: remove molly-guard + apt: + name: molly-guard + state: absent diff --git a/test/integration/targets/reboot/tasks/check_reboot.yml b/test/integration/targets/reboot/tasks/check_reboot.yml index 1aff1be2cb6..059c422a35c 100644 --- a/test/integration/targets/reboot/tasks/check_reboot.yml +++ b/test/integration/targets/reboot/tasks/check_reboot.yml @@ -1,5 +1,5 @@ - name: Get current boot time - command: "{{ boot_time_command[ansible_facts['distribution'] | lower] | default('cat /proc/sys/kernel/random/boot_id') }}" + command: "{{ _boot_time_command[ansible_facts['distribution'] | lower] | default('cat /proc/sys/kernel/random/boot_id') }}" register: after_boot_time - name: Ensure system was actually rebooted diff --git a/test/integration/targets/reboot/tasks/get_boot_time.yml b/test/integration/targets/reboot/tasks/get_boot_time.yml index cec22f06ff3..7f79770a158 100644 --- a/test/integration/targets/reboot/tasks/get_boot_time.yml +++ b/test/integration/targets/reboot/tasks/get_boot_time.yml @@ -1,3 +1,3 @@ - name: Get current boot time - command: "{{ boot_time_command[ansible_facts['distribution'] | lower] | default('cat /proc/sys/kernel/random/boot_id') }}" + command: "{{ _boot_time_command[ansible_facts['distribution'] | lower] | default('cat /proc/sys/kernel/random/boot_id') }}" register: before_boot_time diff --git a/test/integration/targets/reboot/tasks/main.yml b/test/integration/targets/reboot/tasks/main.yml index 2568b9b2902..7687cb73ce1 100644 --- a/test/integration/targets/reboot/tasks/main.yml +++ b/test/integration/targets/reboot/tasks/main.yml @@ -1,4 +1,6 @@ -- block: +- name: Test reboot + when: ansible_facts.virtualization_type | default('') not in ['docker', 'container', 'containerd'] + block: # This block can be removed once we have a mechanism in ansible-test to separate # the control node from the managed node. - block: @@ -23,89 +25,17 @@ Skipping reboot test. that: - not controller_temp_file.stat.exists + always: + - name: Cleanup temp file + file: + path: /tmp/Anything-Nutlike-Nuzzle-Plow-Overdue + state: absent + delegate_to: localhost + connection: local + when: inventory_hostname == ansible_play_hosts[0] - - import_tasks: get_boot_time.yml - - - name: Reboot with default settings - reboot: - register: reboot_result - - - import_tasks: check_reboot.yml - - - import_tasks: get_boot_time.yml - - - name: Reboot with all options - reboot: - connect_timeout: 30 - search_paths: /usr/local/bin - msg: Rebooting - post_reboot_delay: 1 - pre_reboot_delay: 61 - test_command: uptime - reboot_timeout: 500 - register: reboot_result - - - import_tasks: check_reboot.yml - - - import_tasks: get_boot_time.yml - - - name: Test with negative values for delays - reboot: - post_reboot_delay: -0.5 - pre_reboot_delay: -61 - register: reboot_result - - - import_tasks: check_reboot.yml - - - name: Use invalid parameter - reboot: - foo: bar - ignore_errors: true - register: invalid_parameter - - - name: Ensure task fails with error - assert: - that: - - invalid_parameter is failed - - "invalid_parameter.msg == 'Invalid options for reboot: foo'" - - - name: Reboot with test command that fails - reboot: - test_command: 'FAIL' - reboot_timeout: "{{ timeout }}" - register: reboot_fail_test - failed_when: "reboot_fail_test.msg != 'Timed out waiting for post-reboot test command (timeout=' ~ timeout ~ ')'" - vars: - timeout: "{{ timeout_value[ansible_facts['distribution'] | lower] | default(60) }}" - - - name: Test molly-guard - block: - - import_tasks: get_boot_time.yml - - - name: Install molly-guard - apt: - update_cache: yes - name: molly-guard - state: present - - - name: Reboot when molly-guard is installed - reboot: - search_paths: /lib/molly-guard - register: reboot_result - - - import_tasks: check_reboot.yml - - when: ansible_facts.distribution in ['Debian', 'Ubuntu'] - tags: - - molly-guard - - always: - - name: Cleanup temp file - file: - path: /tmp/Anything-Nutlike-Nuzzle-Plow-Overdue - state: absent - delegate_to: localhost - connection: local - when: inventory_hostname == ansible_play_hosts[0] - - when: ansible_virtualization_type | default('') != 'docker' + - import_tasks: test_standard_scenarios.yml + - import_tasks: test_reboot_command.yml + - import_tasks: test_invalid_parameter.yml + - import_tasks: test_invalid_test_command.yml + - import_tasks: test_molly_guard.yml diff --git a/test/integration/targets/reboot/tasks/test_invalid_parameter.yml b/test/integration/targets/reboot/tasks/test_invalid_parameter.yml new file mode 100644 index 00000000000..f8e1a8f4b6d --- /dev/null +++ b/test/integration/targets/reboot/tasks/test_invalid_parameter.yml @@ -0,0 +1,11 @@ +- name: Use invalid parameter + reboot: + foo: bar + ignore_errors: yes + register: invalid_parameter + +- name: Ensure task fails with error + assert: + that: + - invalid_parameter is failed + - "invalid_parameter.msg == 'Invalid options for reboot: foo'" diff --git a/test/integration/targets/reboot/tasks/test_invalid_test_command.yml b/test/integration/targets/reboot/tasks/test_invalid_test_command.yml new file mode 100644 index 00000000000..ea1db81deaf --- /dev/null +++ b/test/integration/targets/reboot/tasks/test_invalid_test_command.yml @@ -0,0 +1,8 @@ +- name: Reboot with test command that fails + reboot: + test_command: 'FAIL' + reboot_timeout: "{{ timeout }}" + register: reboot_fail_test + failed_when: "reboot_fail_test.msg != 'Timed out waiting for post-reboot test command (timeout=' ~ timeout ~ ')'" + vars: + timeout: "{{ _timeout_value[ansible_facts['distribution'] | lower] | default(60) }}" diff --git a/test/integration/targets/reboot/tasks/test_molly_guard.yml b/test/integration/targets/reboot/tasks/test_molly_guard.yml new file mode 100644 index 00000000000..f91fd4f1362 --- /dev/null +++ b/test/integration/targets/reboot/tasks/test_molly_guard.yml @@ -0,0 +1,20 @@ +- name: Test molly-guard + when: ansible_facts.distribution in ['Debian', 'Ubuntu'] + tags: + - molly-guard + block: + - import_tasks: get_boot_time.yml + + - name: Install molly-guard + apt: + update_cache: yes + name: molly-guard + state: present + notify: remove molly-guard + + - name: Reboot when molly-guard is installed + reboot: + search_paths: /lib/molly-guard + register: reboot_result + + - import_tasks: check_reboot.yml diff --git a/test/integration/targets/reboot/tasks/test_reboot_command.yml b/test/integration/targets/reboot/tasks/test_reboot_command.yml new file mode 100644 index 00000000000..779d380bad3 --- /dev/null +++ b/test/integration/targets/reboot/tasks/test_reboot_command.yml @@ -0,0 +1,22 @@ +- import_tasks: get_boot_time.yml +- name: Reboot with custom reboot_command using unqualified path + reboot: + reboot_command: reboot + register: reboot_result +- import_tasks: check_reboot.yml + + +- import_tasks: get_boot_time.yml +- name: Reboot with custom reboot_command using absolute path + reboot: + reboot_command: /sbin/reboot + register: reboot_result +- import_tasks: check_reboot.yml + + +- import_tasks: get_boot_time.yml +- name: Reboot with custom reboot_command with parameters + reboot: + reboot_command: shutdown -r now + register: reboot_result +- import_tasks: check_reboot.yml diff --git a/test/integration/targets/reboot/tasks/test_standard_scenarios.yml b/test/integration/targets/reboot/tasks/test_standard_scenarios.yml new file mode 100644 index 00000000000..fac85be7378 --- /dev/null +++ b/test/integration/targets/reboot/tasks/test_standard_scenarios.yml @@ -0,0 +1,32 @@ +- import_tasks: get_boot_time.yml +- name: Reboot with default settings + reboot: + register: reboot_result +- import_tasks: check_reboot.yml + + +- import_tasks: get_boot_time.yml +- name: Reboot with all options except reboot_command + reboot: + connect_timeout: 30 + search_paths: + - /sbin + - /bin + - /usr/sbin + - /usr/bin + msg: Rebooting + post_reboot_delay: 1 + pre_reboot_delay: 61 + test_command: uptime + reboot_timeout: 500 + register: reboot_result +- import_tasks: check_reboot.yml + + +- import_tasks: get_boot_time.yml +- name: Test with negative values for delays + reboot: + post_reboot_delay: -0.5 + pre_reboot_delay: -61 + register: reboot_result +- import_tasks: check_reboot.yml diff --git a/test/integration/targets/reboot/vars/main.yml b/test/integration/targets/reboot/vars/main.yml index 24367c80f5d..0e1997c48e9 100644 --- a/test/integration/targets/reboot/vars/main.yml +++ b/test/integration/targets/reboot/vars/main.yml @@ -1,9 +1,9 @@ -boot_time_command: +_boot_time_command: freebsd: '/sbin/sysctl kern.boottime' openbsd: '/sbin/sysctl kern.boottime' macosx: 'who -b' solaris: 'who -b' sunos: 'who -b' -timeout_value: +_timeout_value: solaris: 120