Passed
Push — develop ( 51eafa...7602af )
by Plexxi
06:51 queued 03:20
created

PythonRunner.run()   B

Complexity

Conditions 5

Size

Total Lines 47

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
dl 0
loc 47
rs 8.1671
c 0
b 0
f 0
1
# Licensed to the StackStorm, Inc ('StackStorm') under one or more
2
# contributor license agreements.  See the NOTICE file distributed with
3
# this work for additional information regarding copyright ownership.
4
# The ASF licenses this file to You under the Apache License, Version 2.0
5
# (the "License"); you may not use this file except in compliance with
6
# the License.  You may obtain a copy of the License at
7
#
8
#     http://www.apache.org/licenses/LICENSE-2.0
9
#
10
# Unless required by applicable law or agreed to in writing, software
11
# distributed under the License is distributed on an "AS IS" BASIS,
12
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
# See the License for the specific language governing permissions and
14
# limitations under the License.
15
16
import os
17
import sys
18
import abc
19
import json
20
import uuid
21
22
import six
23
from eventlet.green import subprocess
24
25
from st2actions.runners import ActionRunner
26
from st2actions.runners.utils import get_logger_for_python_runner_action
27
from st2common.util.green.shell import run_command
28
from st2common.constants.action import ACTION_OUTPUT_RESULT_DELIMITER
29
from st2common.constants.action import LIVEACTION_STATUS_SUCCEEDED
30
from st2common.constants.action import LIVEACTION_STATUS_FAILED
31
from st2common.constants.action import LIVEACTION_STATUS_TIMED_OUT
32
from st2common.constants.error_codes import PYTHON_ACTION_INVALID_STATUS_EXIT_CODE
33
from st2common.constants.error_messages import PACK_VIRTUALENV_DOESNT_EXIST
34
from st2common.constants.runners import PYTHON_RUNNER_DEFAULT_ACTION_TIMEOUT
35
from st2common.constants.system import API_URL_ENV_VARIABLE_NAME
36
from st2common.constants.system import AUTH_TOKEN_ENV_VARIABLE_NAME
37
from st2common.util.api import get_full_public_api_url
38
from st2common.util.sandboxing import get_sandbox_path
39
from st2common.util.sandboxing import get_sandbox_python_path
40
from st2common.util.sandboxing import get_sandbox_python_binary_path
41
from st2common.util.sandboxing import get_sandbox_virtualenv_path
42
from st2common.exceptions.invalidstatus import InvalidStatusException
43
44
45
__all__ = [
46
    'get_runner',
47
48
    'PythonRunner',
49
    'Action'
50
]
51
52
# constants to lookup in runner_parameters.
53
RUNNER_ENV = 'env'
54
RUNNER_TIMEOUT = 'timeout'
55
56
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
57
WRAPPER_SCRIPT_NAME = 'python_action_wrapper.py'
58
WRAPPER_SCRIPT_PATH = os.path.join(BASE_DIR, WRAPPER_SCRIPT_NAME)
59
60
61
def get_runner():
62
    return PythonRunner(str(uuid.uuid4()))
63
64
65
@six.add_metaclass(abc.ABCMeta)
66
class Action(object):
67
    """
68
    Base action class other Python actions should inherit from.
69
    """
70
71
    description = None
72
73
    def __init__(self, config=None, action_service=None):
74
        """
75
        :param config: Action config.
76
        :type config: ``dict``
77
78
        :param action_service: ActionService object.
79
        :type action_service: :class:`ActionService~
80
        """
81
        self.config = config or {}
82
        self.action_service = action_service
83
        self.logger = get_logger_for_python_runner_action(action_name=self.__class__.__name__)
84
85
    @abc.abstractmethod
86
    def run(self, **kwargs):
87
        pass
88
89
90
class PythonRunner(ActionRunner):
91
92
    def __init__(self, runner_id, timeout=PYTHON_RUNNER_DEFAULT_ACTION_TIMEOUT):
93
        """
94
        :param timeout: Action execution timeout in seconds.
95
        :type timeout: ``int``
96
        """
97
        super(PythonRunner, self).__init__(runner_id=runner_id)
98
        self._timeout = timeout
99
100
    def pre_run(self):
101
        super(PythonRunner, self).pre_run()
102
103
        # TODO :This is awful, but the way "runner_parameters" and other variables get
104
        # assigned on the runner instance is even worse. Those arguments should
105
        # be passed to the constructor.
106
        self._env = self.runner_parameters.get(RUNNER_ENV, {})
107
        self._timeout = self.runner_parameters.get(RUNNER_TIMEOUT, self._timeout)
108
109
    def run(self, action_parameters):
110
        pack = self.get_pack_name()
111
        user = self.get_user()
112
        serialized_parameters = json.dumps(action_parameters) if action_parameters else ''
113
        virtualenv_path = get_sandbox_virtualenv_path(pack=pack)
114
        python_path = get_sandbox_python_binary_path(pack=pack)
115
116
        if virtualenv_path and not os.path.isdir(virtualenv_path):
117
            format_values = {'pack': pack, 'virtualenv_path': virtualenv_path}
118
            msg = PACK_VIRTUALENV_DOESNT_EXIST % format_values
119
            raise Exception(msg)
120
121
        if not self.entry_point:
122
            raise Exception('Action "%s" is missing entry_point attribute' % (self.action.name))
123
124
        args = [
125
            python_path,
126
            WRAPPER_SCRIPT_PATH,
127
            '--pack=%s' % (pack),
128
            '--file-path=%s' % (self.entry_point),
129
            '--parameters=%s' % (serialized_parameters),
130
            '--user=%s' % (user),
131
            '--parent-args=%s' % (json.dumps(sys.argv[1:]))
132
        ]
133
134
        # We need to ensure all the st2 dependencies are also available to the
135
        # subprocess
136
        env = os.environ.copy()
137
        env['PATH'] = get_sandbox_path(virtualenv_path=virtualenv_path)
138
        env['PYTHONPATH'] = get_sandbox_python_path(inherit_from_parent=True,
139
                                                    inherit_parent_virtualenv=True)
140
141
        # Include user provided environment variables (if any)
142
        user_env_vars = self._get_env_vars()
143
        env.update(user_env_vars)
144
145
        # Include common st2 environment variables
146
        st2_env_vars = self._get_common_action_env_variables()
147
        env.update(st2_env_vars)
148
        datastore_env_vars = self._get_datastore_access_env_vars()
149
        env.update(datastore_env_vars)
150
151
        exit_code, stdout, stderr, timed_out = run_command(cmd=args, stdout=subprocess.PIPE,
152
                                                           stderr=subprocess.PIPE, shell=False,
153
                                                           env=env, timeout=self._timeout)
154
155
        return self._get_output_values(exit_code, stdout, stderr, timed_out)
156
157
    def _get_output_values(self, exit_code, stdout, stderr, timed_out):
158
        """
159
        Return sanitized output values.
160
161
        :return: Tuple with status, output and None
162
163
        :rtype: ``tuple``
164
        """
165
        if timed_out:
166
            error = 'Action failed to complete in %s seconds' % (self._timeout)
167
        else:
168
            error = None
169
170
        if exit_code == PYTHON_ACTION_INVALID_STATUS_EXIT_CODE:
171
            raise InvalidStatusException(stderr)
172
173
        if ACTION_OUTPUT_RESULT_DELIMITER in stdout:
174
            split = stdout.split(ACTION_OUTPUT_RESULT_DELIMITER)
175
            assert len(split) == 3
176
            action_result = split[1].strip()
177
            stdout = split[0] + split[2]
178
        else:
179
            action_result = None
180
181
        try:
182
            action_result = json.loads(action_result)
183
        except:
184
            pass
185
        action_status = None
186
        if action_result and action_result != "None":
187
            result = action_result['result']
188
            try:
189
                action_status = action_result['status']
190
            except KeyError:
191
                pass
192
        else:
193
            result = "None"
194
195
        output = {
196
            'stdout': stdout,
197
            'stderr': stderr,
198
            'exit_code': exit_code,
199
            'result': result
200
        }
201
        if error:
202
            output['error'] = error
203
204
        status = self._get_final_status(action_status, timed_out, exit_code)
205
        return (status, output, None)
206
207
    def _get_final_status(self, action_status, timed_out, exit_code):
208
        """
209
        Return final status based on action's status, time out value and
210
        exit code. Example: succeeded, failed, timeout.
211
212
        :return: status
213
214
        :rtype: ``str``
215
        """
216
        if action_status is not None:
217
            if exit_code == 0 and action_status is True:
218
                status = LIVEACTION_STATUS_SUCCEEDED
219
            elif action_status is False:
220
                status = LIVEACTION_STATUS_FAILED
221
        else:
222
            if exit_code == 0:
223
                status = LIVEACTION_STATUS_SUCCEEDED
224
            else:
225
                status = LIVEACTION_STATUS_FAILED
226
227
        if timed_out:
228
            status = LIVEACTION_STATUS_TIMED_OUT
229
230
        return status
231
232
    def _get_env_vars(self):
233
        """
234
        Return sanitized environment variables which will be used when launching
235
        a subprocess.
236
237
        :rtype: ``dict``
238
        """
239
        # Don't allow user to override PYTHONPATH since this would break things
240
        blacklisted_vars = ['pythonpath']
241
        env_vars = {}
242
243
        if self._env:
244
            env_vars.update(self._env)
245
246
        # Remove "blacklisted" environment variables
247
        to_delete = []
248
        for key, value in env_vars.items():
249
            if key.lower() in blacklisted_vars:
250
                to_delete.append(key)
251
252
        for key in to_delete:
253
            del env_vars[key]
254
255
        return env_vars
256
257
    def _get_datastore_access_env_vars(self):
258
        """
259
        Return environment variables so datastore access using client (from st2client)
260
        is possible with actions. This is done to be compatible with sensors.
261
262
        :rtype: ``dict``
263
        """
264
        env_vars = {}
265
        if self.auth_token:
266
            env_vars[AUTH_TOKEN_ENV_VARIABLE_NAME] = self.auth_token.token
267
        env_vars[API_URL_ENV_VARIABLE_NAME] = get_full_public_api_url()
268
269
        return env_vars
270