summaryrefslogtreecommitdiffstats
path: root/ansible_collections/community/general/plugins/action/shutdown.py
blob: 01201a640507771d5e36ee9c24f7ac599f5cca78 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Amin Vakil <info@aminvakil.com>
# Copyright (c) 2016-2018, Matt Davis <mdavis@ansible.com>
# Copyright (c) 2018, Sam Doran <sdoran@redhat.com>
# 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

from ansible.errors import AnsibleError, AnsibleConnectionFailure
from ansible.module_utils.common.text.converters import to_native, to_text
from ansible.module_utils.common.collections import is_string
from ansible.plugins.action import ActionBase
from ansible.utils.display import Display

display = Display()


class TimedOutException(Exception):
    pass


class ActionModule(ActionBase):
    TRANSFERS_FILES = False
    _VALID_ARGS = frozenset((
        'msg',
        'delay',
        'search_paths'
    ))

    DEFAULT_CONNECT_TIMEOUT = None
    DEFAULT_PRE_SHUTDOWN_DELAY = 0
    DEFAULT_SHUTDOWN_MESSAGE = 'Shut down initiated by Ansible'
    DEFAULT_SHUTDOWN_COMMAND = 'shutdown'
    DEFAULT_SHUTDOWN_COMMAND_ARGS = '-h {delay_min} "{message}"'
    DEFAULT_SUDOABLE = True

    SHUTDOWN_COMMANDS = {
        'alpine': 'poweroff',
        'vmkernel': 'halt',
    }

    SHUTDOWN_COMMAND_ARGS = {
        'alpine': '',
        'void': '-h +{delay_min} "{message}"',
        'freebsd': '-p +{delay_sec}s "{message}"',
        'linux': DEFAULT_SHUTDOWN_COMMAND_ARGS,
        'macosx': '-h +{delay_min} "{message}"',
        'openbsd': '-h +{delay_min} "{message}"',
        'solaris': '-y -g {delay_sec} -i 5 "{message}"',
        'sunos': '-y -g {delay_sec} -i 5 "{message}"',
        'vmkernel': '-d {delay_sec}',
        'aix': '-Fh',
    }

    def __init__(self, *args, **kwargs):
        super(ActionModule, self).__init__(*args, **kwargs)

    @property
    def delay(self):
        return self._check_delay('delay', self.DEFAULT_PRE_SHUTDOWN_DELAY)

    def _check_delay(self, key, default):
        """Ensure that the value is positive or zero"""
        value = int(self._task.args.get(key, default))
        if value < 0:
            value = 0
        return value

    def _get_value_from_facts(self, variable_name, distribution, default_value):
        """Get dist+version specific args first, then distribution, then family, lastly use default"""
        attr = getattr(self, variable_name)
        value = attr.get(
            distribution['name'] + distribution['version'],
            attr.get(
                distribution['name'],
                attr.get(
                    distribution['family'],
                    getattr(self, default_value))))
        return value

    def get_distribution(self, task_vars):
        # FIXME: only execute the module if we don't already have the facts we need
        distribution = {}
        display.debug('{action}: running setup module to get distribution'.format(action=self._task.action))
        module_output = self._execute_module(
            task_vars=task_vars,
            module_name='ansible.legacy.setup',
            module_args={'gather_subset': 'min'})
        try:
            if module_output.get('failed', False):
                raise AnsibleError('Failed to determine system distribution. {0}, {1}'.format(
                    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['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
        except KeyError as ke:
            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)

        # 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]

        # 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))

        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_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))
            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)))
            shutdown_result['rc'] = 0

        if shutdown_result['rc'] != 0:
            result['failed'] = True
            result['shutdown'] = False
            result['msg'] = "Shutdown command failed. Error was {stdout}, {stderr}".format(
                stdout=to_native(shutdown_result['stdout'].strip()),
                stderr=to_native(shutdown_result['stderr'].strip()))
            return result

        result['failed'] = False
        result['shutdown_command'] = shutdown_command_exec
        return result

    def run(self, tmp=None, task_vars=None):
        self._supports_check_mode = True
        self._supports_async = True

        # If running with local connection, fail so we don't shutdown ourself
        if self._connection.transport == 'local' and (not self._play_context.check_mode):
            msg = 'Running {0} with local connection would shutdown the control node.'.format(self._task.action)
            return {'changed': False, 'elapsed': 0, 'shutdown': False, 'failed': True, 'msg': msg}

        if task_vars is None:
            task_vars = {}

        result = super(ActionModule, self).run(tmp, task_vars)

        if result.get('skipped', False) or result.get('failed', False):
            return result

        distribution = self.get_distribution(task_vars)

        # Initiate shutdown
        shutdown_result = self.perform_shutdown(task_vars, distribution)

        if shutdown_result['failed']:
            result = shutdown_result
            return result

        result['shutdown'] = True
        result['changed'] = True
        result['shutdown_command'] = shutdown_result['shutdown_command']

        return result