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

python_runner/python_action_wrapper.py (11 issues)

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
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
0 ignored issues
show
Coding Style Best Practice introduced by
It seems like _class_name was declared protected and should not be accessed from this context.

Prefixing a member variable _ is usually regarded as the equivalent of declaring it with protected visibility that exists in other languages. Consequentially, such a member should only be accessed from the same class or a child class:

class MyParent:
    def __init__(self):
        self._x = 1;
        self.y = 2;

class MyChild(MyParent):
    def some_method(self):
        return self._x    # Ok, since accessed from a child class

class AnotherClass:
    def some_method(self, instance_of_my_child):
        return instance_of_my_child._x   # Would be flagged as AnotherClass is not
                                         # a child class of MyParent
Loading history...
92
            log_level = self._action_wrapper._log_level
0 ignored issues
show
Coding Style Best Practice introduced by
It seems like _log_level was declared protected and should not be accessed from this context.

Prefixing a member variable _ is usually regarded as the equivalent of declaring it with protected visibility that exists in other languages. Consequentially, such a member should only be accessed from the same class or a child class:

class MyParent:
    def __init__(self):
        self._x = 1;
        self.y = 2;

class MyChild(MyParent):
    def some_method(self):
        return self._x    # Ok, since accessed from a child class

class AnotherClass:
    def some_method(self, instance_of_my_child):
        return instance_of_my_child._x   # Would be flagged as AnotherClass is not
                                         # a child class of MyParent
Loading history...
Comprehensibility Bug introduced by
log_level is re-defining a name which is already available in the outer-scope (previously defined on line 275).

It is generally a bad practice to shadow variables from the outer-scope. In most cases, this is done unintentionally and might lead to unexpected behavior:

param = 5

class Foo:
    def __init__(self, param):   # "param" would be flagged here
        self.param = param
Loading history...
93
            logger = get_logger_for_python_runner_action(action_name=action_name,
94
                                                         log_level=log_level)
95
            pack_name = self._action_wrapper._pack
0 ignored issues
show
Coding Style Best Practice introduced by
It seems like _pack was declared protected and should not be accessed from this context.

Prefixing a member variable _ is usually regarded as the equivalent of declaring it with protected visibility that exists in other languages. Consequentially, such a member should only be accessed from the same class or a child class:

class MyParent:
    def __init__(self):
        self._x = 1;
        self.y = 2;

class MyChild(MyParent):
    def some_method(self):
        return self._x    # Ok, since accessed from a child class

class AnotherClass:
    def some_method(self, instance_of_my_child):
        return instance_of_my_child._x   # Would be flagged as AnotherClass is not
                                         # a child class of MyParent
Loading history...
96
            class_name = self._action_wrapper._class_name
0 ignored issues
show
Coding Style Best Practice introduced by
It seems like _class_name was declared protected and should not be accessed from this context.

Prefixing a member variable _ is usually regarded as the equivalent of declaring it with protected visibility that exists in other languages. Consequentially, such a member should only be accessed from the same class or a child class:

class MyParent:
    def __init__(self):
        self._x = 1;
        self.y = 2;

class MyChild(MyParent):
    def some_method(self):
        return self._x    # Ok, since accessed from a child class

class AnotherClass:
    def some_method(self, instance_of_my_child):
        return instance_of_my_child._x   # Would be flagged as AnotherClass is not
                                         # a child class of MyParent
Loading history...
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,
0 ignored issues
show
Comprehensibility Bug introduced by
parameters is re-defining a name which is already available in the outer-scope (previously defined on line 280).

It is generally a bad practice to shadow variables from the outer-scope. In most cases, this is done unintentionally and might lead to unexpected behavior:

param = 5

class Foo:
    def __init__(self, param):   # "param" would be flagged here
        self.param = param
Loading history...
Comprehensibility Bug introduced by
user is re-defining a name which is already available in the outer-scope (previously defined on line 273).

It is generally a bad practice to shadow variables from the outer-scope. In most cases, this is done unintentionally and might lead to unexpected behavior:

param = 5

class Foo:
    def __init__(self, param):   # "param" would be flagged here
        self.param = param
Loading history...
Comprehensibility Bug introduced by
config is re-defining a name which is already available in the outer-scope (previously defined on line 272).

It is generally a bad practice to shadow variables from the outer-scope. In most cases, this is done unintentionally and might lead to unexpected behavior:

param = 5

class Foo:
    def __init__(self, param):   # "param" would be flagged here
        self.param = param
Loading history...
Comprehensibility Bug introduced by
parent_args is re-defining a name which is already available in the outer-scope (previously defined on line 274).

It is generally a bad practice to shadow variables from the outer-scope. In most cases, this is done unintentionally and might lead to unexpected behavior:

param = 5

class Foo:
    def __init__(self, param):   # "param" would be flagged here
        self.param = param
Loading history...
132
                 log_level=PYTHON_RUNNER_DEFAULT_LOG_LEVEL):
0 ignored issues
show
Comprehensibility Bug introduced by
log_level is re-defining a name which is already available in the outer-scope (previously defined on line 275).

It is generally a bad practice to shadow variables from the outer-scope. In most cases, this is done unintentionally and might lead to unexpected behavior:

param = 5

class Foo:
    def __init__(self, param):   # "param" would be flagged here
        self.param = param
Loading history...
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 '
0 ignored issues
show
Comprehensibility Bug introduced by
msg is re-defining a name which is already available in the outer-scope (previously defined on line 303).

It is generally a bad practice to shadow variables from the outer-scope. In most cases, this is done unintentionally and might lead to unexpected behavior:

param = 5

class Foo:
    def __init__(self, param):   # "param" would be flagged here
        self.param = param
Loading history...
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