Test Failed
Push — master ( e380d0...f5671d )
by W
02:58
created

python_runner/python_action_wrapper.py (1 issue)

Checks for re-imported code

Unused Code Minor
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
from __future__ import absolute_import
17
18
import os
19
import sys
20
import select
21
import traceback
22
23
# Note: This work-around is required to fix the issue with other Python modules which live
24
# inside this directory polluting and masking sys.path for Python runner actions.
25
# Since this module is ran as a Python script inside a subprocess, directory where the script
26
# lives gets added to sys.path and we don't want that.
27
# Note: We need to use just the suffix, because full path is different depending if the process
28
# is ran in virtualenv or not
29
RUNNERS_PATH_SUFFIX = 'st2common/runners'
30
if __name__ == '__main__':
31
    script_path = sys.path[0]
32
    if RUNNERS_PATH_SUFFIX in script_path:
33
        sys.path.pop(0)
34
35
import sys
0 ignored issues
show
The import sys was already done on line 19. You should be able to
remove this line.
Loading history...
36
import json
37
import argparse
38
39
from st2common import log as logging
40
from st2common import config as st2common_config
41
from st2common.runners.base_action import Action
42
from st2common.runners.utils import get_logger_for_python_runner_action
43
from st2common.runners.utils import get_action_class_instance
44
from st2common.util import loader as action_loader
45
from st2common.constants.action import ACTION_OUTPUT_RESULT_DELIMITER
46
from st2common.constants.keyvalue import SYSTEM_SCOPE
47
from st2common.constants.runners import PYTHON_RUNNER_INVALID_ACTION_STATUS_EXIT_CODE
48
from st2common.constants.runners import PYTHON_RUNNER_DEFAULT_LOG_LEVEL
49
50
__all__ = [
51
    'PythonActionWrapper',
52
    'ActionService'
53
]
54
55
LOG = logging.getLogger(__name__)
56
57
INVALID_STATUS_ERROR_MESSAGE = """
58
If this is an existing action which returns a tuple with two items, it needs to be updated to
59
either:
60
61
1. Return a list instead of a tuple
62
2. Return a tuple where a first items is a status flag - (True, ('item1', 'item2'))
63
64
For more information, please see: https://docs.stackstorm.com/upgrade_notes.html#st2-v1-6
65
""".strip()
66
67
# How many seconds to wait for stdin input when parameters are passed in via stdin before
68
# timing out
69
READ_STDIN_INPUT_TIMEOUT = 2
70
71
72
class ActionService(object):
73
    """
74
    Instance of this class is passed to the action instance and exposes "public" methods which can
75
    be called by the action.
76
    """
77
78
    def __init__(self, action_wrapper):
79
        self._action_wrapper = action_wrapper
80
        self._datastore_service = None
81
82
    @property
83
    def datastore_service(self):
84
        # Late import to avoid very expensive in-direct import (~1 second) when this function is
85
        # not called / used
86
        from st2common.services.datastore import ActionDatastoreService
87
88
        if not self._datastore_service:
89
            # Note: We use temporary auth token generated by the container which is valid for the
90
            # duration of the action lifetime
91
            action_name = self._action_wrapper._class_name
92
            log_level = self._action_wrapper._log_level
93
            logger = get_logger_for_python_runner_action(action_name=action_name,
94
                                                         log_level=log_level)
95
            pack_name = self._action_wrapper._pack
96
            class_name = self._action_wrapper._class_name
97
            auth_token = os.environ.get('ST2_ACTION_AUTH_TOKEN', None)
98
            self._datastore_service = ActionDatastoreService(logger=logger,
99
                                                             pack_name=pack_name,
100
                                                             class_name=class_name,
101
                                                             auth_token=auth_token)
102
        return self._datastore_service
103
104
    ##################################
105
    # General methods
106
    ##################################
107
108
    def get_user_info(self):
109
        return self.datastore_service.get_user_info()
110
111
    ##################################
112
    # Methods for datastore management
113
    ##################################
114
115
    def list_values(self, local=True, prefix=None):
116
        return self.datastore_service.list_values(local=local, prefix=prefix)
117
118
    def get_value(self, name, local=True, scope=SYSTEM_SCOPE, decrypt=False):
119
        return self.datastore_service.get_value(name=name, local=local, scope=scope,
120
                                                decrypt=decrypt)
121
122
    def set_value(self, name, value, ttl=None, local=True, scope=SYSTEM_SCOPE, encrypt=False):
123
        return self.datastore_service.set_value(name=name, value=value, ttl=ttl, local=local,
124
                                                scope=scope, encrypt=encrypt)
125
126
    def delete_value(self, name, local=True, scope=SYSTEM_SCOPE):
127
        return self.datastore_service.delete_value(name=name, local=local, scope=scope)
128
129
130
class PythonActionWrapper(object):
131
    def __init__(self, pack, file_path, config=None, parameters=None, user=None, parent_args=None,
132
                 log_level=PYTHON_RUNNER_DEFAULT_LOG_LEVEL):
133
        """
134
        :param pack: Name of the pack this action belongs to.
135
        :type pack: ``str``
136
137
        :param file_path: Path to the action module.
138
        :type file_path: ``str``
139
140
        :param config: Pack config.
141
        :type config: ``dict``
142
143
        :param parameters: action parameters.
144
        :type parameters: ``dict`` or ``None``
145
146
        :param user: Name of the user who triggered this action execution.
147
        :type user: ``str``
148
149
        :param parent_args: Command line arguments passed to the parent process.
150
        :type parse_args: ``list``
151
        """
152
153
        self._pack = pack
154
        self._file_path = file_path
155
        self._config = config or {}
156
        self._parameters = parameters or {}
157
        self._user = user
158
        self._parent_args = parent_args or []
159
        self._log_level = log_level
160
161
        self._class_name = None
162
        self._logger = logging.getLogger('PythonActionWrapper')
163
164
        try:
165
            st2common_config.parse_args(args=self._parent_args)
166
        except Exception as e:
167
            LOG.debug('Failed to parse config using parent args (parent_args=%s): %s' %
168
                      (str(self._parent_args), str(e)))
169
170
        # Note: We can only set a default user value if one is not provided after parsing the
171
        # config
172
        if not self._user:
173
            # Note: We use late import to avoid performance overhead
174
            from oslo_config import cfg
175
            self._user = cfg.CONF.system_user.user
176
177
    def run(self):
178
        action = self._get_action_instance()
179
        output = action.run(**self._parameters)
180
181
        if isinstance(output, tuple) and len(output) == 2:
182
            # run() method returned status and data - (status, data)
183
            action_status = output[0]
184
            action_result = output[1]
185
        else:
186
            # run() method returned only data, no status (pre StackStorm v1.6)
187
            action_status = None
188
            action_result = output
189
190
        action_output = {
191
            'result': action_result,
192
            'status': None
193
        }
194
195
        if action_status is not None and not isinstance(action_status, bool):
196
            sys.stderr.write('Status returned from the action run() method must either be '
197
                             'True or False, got: %s\n' % (action_status))
198
            sys.stderr.write(INVALID_STATUS_ERROR_MESSAGE)
199
            sys.exit(PYTHON_RUNNER_INVALID_ACTION_STATUS_EXIT_CODE)
200
201
        if action_status is not None and isinstance(action_status, bool):
202
            action_output['status'] = action_status
203
204
            # Special case if result object is not JSON serializable - aka user wanted to return a
205
            # non-simple type (e.g. class instance or other non-JSON serializable type)
206
            try:
207
                json.dumps(action_output['result'])
208
            except TypeError:
209
                action_output['result'] = str(action_output['result'])
210
211
        try:
212
            print_output = json.dumps(action_output)
213
        except Exception:
214
            print_output = str(action_output)
215
216
        # Print output to stdout so the parent can capture it
217
        sys.stdout.write(ACTION_OUTPUT_RESULT_DELIMITER)
218
        sys.stdout.write(print_output + '\n')
219
        sys.stdout.write(ACTION_OUTPUT_RESULT_DELIMITER)
220
        sys.stdout.flush()
221
222
    def _get_action_instance(self):
223
        try:
224
            actions_cls = action_loader.register_plugin(Action, self._file_path)
225
        except Exception as e:
226
            tb_msg = traceback.format_exc()
227
            msg = ('Failed to load action class from file "%s" (action file most likely doesn\'t '
228
                   'exist or contains invalid syntax): %s' % (self._file_path, str(e)))
229
            msg += '\n\n' + tb_msg
230
            exc_cls = type(e)
231
            raise exc_cls(msg)
232
233
        action_cls = actions_cls[0] if actions_cls and len(actions_cls) > 0 else None
234
235
        if not action_cls:
236
            raise Exception('File "%s" has no action class or the file doesn\'t exist.' %
237
                            (self._file_path))
238
239
        # Retrieve name of the action class
240
        # Note - we need to either use cls.__name_ or inspect.getmro(cls)[0].__name__ to
241
        # retrieve a correct name
242
        self._class_name = action_cls.__name__
243
244
        action_service = ActionService(action_wrapper=self)
245
        action_instance = get_action_class_instance(action_cls=action_cls,
246
                                                    config=self._config,
247
                                                    action_service=action_service)
248
        return action_instance
249
250
251
if __name__ == '__main__':
252
    parser = argparse.ArgumentParser(description='Python action runner process wrapper')
253
    parser.add_argument('--pack', required=True,
254
                        help='Name of the pack this action belongs to')
255
    parser.add_argument('--file-path', required=True,
256
                        help='Path to the action module')
257
    parser.add_argument('--config', required=False,
258
                        help='Pack config serialized as JSON')
259
    parser.add_argument('--parameters', required=False,
260
                        help='Serialized action parameters')
261
    parser.add_argument('--stdin-parameters', required=False, action='store_true',
262
                        help='Serialized action parameters via stdin')
263
    parser.add_argument('--user', required=False,
264
                        help='User who triggered the action execution')
265
    parser.add_argument('--parent-args', required=False,
266
                        help='Command line arguments passed to the parent process serialized as '
267
                             ' JSON')
268
    parser.add_argument('--log-level', required=False, default=PYTHON_RUNNER_DEFAULT_LOG_LEVEL,
269
                        help='Log level for actions')
270
    args = parser.parse_args()
271
272
    config = json.loads(args.config) if args.config else {}
273
    user = args.user
274
    parent_args = json.loads(args.parent_args) if args.parent_args else []
275
    log_level = args.log_level
276
277
    if not isinstance(config, dict):
278
        raise ValueError('Pack config needs to be a dictionary')
279
280
    parameters = {}
281
282
    if args.parameters:
283
        LOG.debug('Getting parameters from argument')
284
        args_parameters = args.parameters
285
        args_parameters = json.loads(args_parameters) if args_parameters else {}
286
        parameters.update(args_parameters)
287
288
    if args.stdin_parameters:
289
        LOG.debug('Getting parameters from stdin')
290
291
        i, _, _ = select.select([sys.stdin], [], [], READ_STDIN_INPUT_TIMEOUT)
292
293
        if not i:
294
            raise ValueError(('No input received and timed out while waiting for '
295
                              'parameters from stdin'))
296
297
        stdin_data = sys.stdin.readline().strip()
298
299
        try:
300
            stdin_parameters = json.loads(stdin_data)
301
            stdin_parameters = stdin_parameters.get('parameters', {})
302
        except Exception as e:
303
            msg = ('Failed to parse parameters from stdin. Expected a JSON object with '
304
                   '"parameters" attribute: %s' % (str(e)))
305
            raise ValueError(msg)
306
307
        parameters.update(stdin_parameters)
308
309
    LOG.debug('Received parameters: %s', parameters)
310
311
    assert isinstance(parent_args, list)
312
    obj = PythonActionWrapper(pack=args.pack,
313
                              file_path=args.file_path,
314
                              config=config,
315
                              parameters=parameters,
316
                              user=user,
317
                              parent_args=parent_args,
318
                              log_level=log_level)
319
320
    obj.run()
321