Completed
Pull Request — master (#2895)
by Anthony
05:37
created

ActionExecutionRunnerCommandMixin.run_and_print()   A

Complexity

Conditions 4

Size

Total Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
dl 0
loc 15
rs 9.2
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 ast
18
import copy
19
import json
20
import logging
21
import textwrap
22
import calendar
23
import time
24
import six
25
import sys
26
27
from os.path import join as pjoin
28
29
from st2client import models
30
from st2client.commands import resource
31
from st2client.commands.resource import add_auth_token_to_kwargs_from_cli
32
from st2client.exceptions.operations import OperationFailureException
33
from st2client.formatters import table
34
from st2client.formatters import execution as execution_formatter
35
from st2client.utils import jsutil
36
from st2client.utils.date import format_isodate_for_user_timezone
37
from st2client.utils.date import parse as parse_isotime
38
from st2client.utils.color import format_status
39
40
LOG = logging.getLogger(__name__)
41
42
LIVEACTION_STATUS_REQUESTED = 'requested'
43
LIVEACTION_STATUS_SCHEDULED = 'scheduled'
44
LIVEACTION_STATUS_DELAYED = 'delayed'
45
LIVEACTION_STATUS_RUNNING = 'running'
46
LIVEACTION_STATUS_SUCCEEDED = 'succeeded'
47
LIVEACTION_STATUS_FAILED = 'failed'
48
LIVEACTION_STATUS_TIMED_OUT = 'timeout'
49
LIVEACTION_STATUS_ABANDONED = 'abandoned'
50
LIVEACTION_STATUS_CANCELING = 'canceling'
51
LIVEACTION_STATUS_CANCELED = 'canceled'
52
53
54
LIVEACTION_COMPLETED_STATES = [
55
    LIVEACTION_STATUS_SUCCEEDED,
56
    LIVEACTION_STATUS_FAILED,
57
    LIVEACTION_STATUS_TIMED_OUT,
58
    LIVEACTION_STATUS_CANCELED,
59
    LIVEACTION_STATUS_ABANDONED
60
]
61
62
# Who parameters should be masked when displaying action execution output
63
PARAMETERS_TO_MASK = [
64
    'password',
65
    'private_key'
66
]
67
68
# A list of environment variables which are never inherited when using run
69
# --inherit-env flag
70
ENV_VARS_BLACKLIST = [
71
    'pwd',
72
    'mail',
73
    'username',
74
    'user',
75
    'path',
76
    'home',
77
    'ps1',
78
    'shell',
79
    'pythonpath',
80
    'ssh_tty',
81
    'ssh_connection',
82
    'lang',
83
    'ls_colors',
84
    'logname',
85
    'oldpwd',
86
    'term',
87
    'xdg_session_id'
88
]
89
90
WORKFLOW_RUNNER_TYPES = [
91
    'action-chain',
92
    'mistral-v2',
93
]
94
95
96
def format_parameters(value):
97
    # Mask sensitive parameters
98
    if not isinstance(value, dict):
99
        # No parameters, leave it as it is
100
        return value
101
102
    for param_name, _ in value.items():
103
        if param_name in PARAMETERS_TO_MASK:
104
            value[param_name] = '********'
105
106
    return value
107
108
# String for indenting etc.
109
WF_PREFIX = '+ '
110
NON_WF_PREFIX = '  '
111
INDENT_CHAR = ' '
112
113
114
def format_wf_instances(instances):
115
    """
116
    Adds identification characters to a workflow and appropriately shifts
117
    the non-workflow instances. If no workflows are found does nothing.
118
    """
119
    # only add extr chars if there are workflows.
120
    has_wf = False
121
    for instance in instances:
122
        if not getattr(instance, 'children', None):
123
            continue
124
        else:
125
            has_wf = True
126
            break
127
    if not has_wf:
128
        return instances
129
    # Prepend wf and non_wf prefixes.
130
    for instance in instances:
131
        if getattr(instance, 'children', None):
132
            instance.id = WF_PREFIX + instance.id
133
        else:
134
            instance.id = NON_WF_PREFIX + instance.id
135
    return instances
136
137
138
def format_execution_statuses(instances):
139
    result = []
140
    for instance in instances:
141
        instance = format_execution_status(instance)
142
        result.append(instance)
143
144
    return result
145
146
147
def format_execution_status(instance):
148
    """
149
    Augment instance "status" attribute with number of seconds which have elapsed for all the
150
    executions which are in running state and execution total run time for all the executions
151
    which have finished.
152
    """
153
    start_timestamp = getattr(instance, 'start_timestamp', None)
154
    end_timestamp = getattr(instance, 'end_timestamp', None)
155
156
    if instance.status == LIVEACTION_STATUS_RUNNING and start_timestamp:
157
        start_timestamp = instance.start_timestamp
158
        start_timestamp = parse_isotime(start_timestamp)
159
        start_timestamp = calendar.timegm(start_timestamp.timetuple())
160
        now = int(time.time())
161
        elapsed_seconds = (now - start_timestamp)
162
        instance.status = '%s (%ss elapsed)' % (instance.status, elapsed_seconds)
163
    elif instance.status in LIVEACTION_COMPLETED_STATES and start_timestamp and end_timestamp:
164
        start_timestamp = parse_isotime(start_timestamp)
165
        start_timestamp = calendar.timegm(start_timestamp.timetuple())
166
        end_timestamp = parse_isotime(end_timestamp)
167
        end_timestamp = calendar.timegm(end_timestamp.timetuple())
168
169
        elapsed_seconds = (end_timestamp - start_timestamp)
170
        instance.status = '%s (%ss elapsed)' % (instance.status, elapsed_seconds)
171
172
    return instance
173
174
175
class ActionBranch(resource.ResourceBranch):
176
177
    def __init__(self, description, app, subparsers, parent_parser=None):
178
        super(ActionBranch, self).__init__(
179
            models.Action, description, app, subparsers,
180
            parent_parser=parent_parser,
181
            commands={
182
                'list': ActionListCommand,
183
                'get': ActionGetCommand,
184
                'update': ActionUpdateCommand,
185
                'delete': ActionDeleteCommand
186
            })
187
188
        # Registers extended commands
189
        self.commands['enable'] = ActionEnableCommand(self.resource, self.app, self.subparsers)
190
        self.commands['disable'] = ActionDisableCommand(self.resource, self.app, self.subparsers)
191
        self.commands['execute'] = ActionRunCommand(
192
            self.resource, self.app, self.subparsers,
193
            add_help=False)
194
195
196
class ActionListCommand(resource.ContentPackResourceListCommand):
197
    display_attributes = ['ref', 'pack', 'description']
198
199
200
class ActionGetCommand(resource.ContentPackResourceGetCommand):
201
    display_attributes = ['all']
202
    attribute_display_order = ['id', 'uid', 'ref', 'pack', 'name', 'description',
203
                               'enabled', 'entry_point', 'runner_type',
204
                               'parameters']
205
206
207
class ActionUpdateCommand(resource.ContentPackResourceUpdateCommand):
208
    pass
209
210
211
class ActionEnableCommand(resource.ContentPackResourceEnableCommand):
212
    display_attributes = ['all']
213
    attribute_display_order = ['id', 'ref', 'pack', 'name', 'description',
214
                               'enabled', 'entry_point', 'runner_type',
215
                               'parameters']
216
217
218
class ActionDisableCommand(resource.ContentPackResourceDisableCommand):
219
    display_attributes = ['all']
220
    attribute_display_order = ['id', 'ref', 'pack', 'name', 'description',
221
                               'enabled', 'entry_point', 'runner_type',
222
                               'parameters']
223
224
225
class ActionDeleteCommand(resource.ContentPackResourceDeleteCommand):
226
    pass
227
228
229
class ActionRunCommandMixin(object):
230
    """
231
    Mixin class which contains utility functions related to action execution.
232
    """
233
    display_attributes = ['id', 'action.ref', 'context.user', 'parameters', 'status',
234
                          'start_timestamp', 'end_timestamp', 'result']
235
    attribute_display_order = ['id', 'action.ref', 'context.user', 'parameters', 'status',
236
                               'start_timestamp', 'end_timestamp', 'result']
237
    attribute_transform_functions = {
238
        'start_timestamp': format_isodate_for_user_timezone,
239
        'end_timestamp': format_isodate_for_user_timezone,
240
        'parameters': format_parameters,
241
        'status': format_status
242
    }
243
244
    poll_interval = 2  # how often to poll for execution completion when using sync mode
245
246
    def get_resource(self, ref_or_id, **kwargs):
247
        return self.get_resource_by_ref_or_id(ref_or_id=ref_or_id, **kwargs)
248
249
    def _add_common_options(self):
250
        root_arg_grp = self.parser.add_mutually_exclusive_group()
251
252
        # Display options
253
        task_list_arg_grp = root_arg_grp.add_argument_group()
254
        task_list_arg_grp.add_argument('--raw', action='store_true',
255
                                       help='Raw output, don\'t shot sub-tasks for workflows.')
256
        task_list_arg_grp.add_argument('--show-tasks', action='store_true',
257
                                       help='Whether to show sub-tasks of an execution.')
258
        task_list_arg_grp.add_argument('--depth', type=int, default=-1,
259
                                       help='Depth to which to show sub-tasks. \
260
                                             By default all are shown.')
261
        task_list_arg_grp.add_argument('-w', '--width', nargs='+', type=int, default=None,
262
                                       help='Set the width of columns in output.')
263
264
        execution_details_arg_grp = root_arg_grp.add_mutually_exclusive_group()
265
266
        detail_arg_grp = execution_details_arg_grp.add_mutually_exclusive_group()
267
        detail_arg_grp.add_argument('--attr', nargs='+',
268
                                    default=['id', 'status', 'parameters', 'result'],
269
                                    help=('List of attributes to include in the '
270
                                          'output. "all" or unspecified will '
271
                                          'return all attributes.'))
272
        detail_arg_grp.add_argument('-d', '--detail', action='store_true',
273
                                    help='Display full detail of the execution in table format.')
274
275
        result_arg_grp = execution_details_arg_grp.add_mutually_exclusive_group()
276
        result_arg_grp.add_argument('-k', '--key',
277
                                    help=('If result is type of JSON, then print specific '
278
                                          'key-value pair; dot notation for nested JSON is '
279
                                          'supported.'))
280
281
        return root_arg_grp
282
283
    def _print_execution_details(self, execution, args, **kwargs):
284
        """
285
        Print the execution detail to stdout.
286
287
        This method takes into account if an executed action was workflow or not
288
        and formats the output accordingly.
289
        """
290
        runner_type = execution.action.get('runner_type', 'unknown')
291
        is_workflow_action = runner_type in WORKFLOW_RUNNER_TYPES
292
293
        show_tasks = getattr(args, 'show_tasks', False)
294
        raw = getattr(args, 'raw', False)
295
        detail = getattr(args, 'detail', False)
296
        key = getattr(args, 'key', None)
297
        attr = getattr(args, 'attr', [])
298
299
        if show_tasks and not is_workflow_action:
300
            raise ValueError('--show-tasks option can only be used with workflow actions')
301
302
        if not raw and not detail and (show_tasks or is_workflow_action):
303
            self._run_and_print_child_task_list(execution=execution, args=args, **kwargs)
304
        else:
305
            instance = execution
306
307
            if detail:
308
                formatter = table.PropertyValueTable
309
            else:
310
                formatter = execution_formatter.ExecutionResult
311
312
            if detail:
313
                options = {'attributes': copy.copy(self.display_attributes)}
314
            elif key:
315
                options = {'attributes': ['result.%s' % (key)], 'key': key}
316
            else:
317
                options = {'attributes': attr}
318
319
            options['json'] = args.json
320
            options['attribute_transform_functions'] = self.attribute_transform_functions
321
            self.print_output(instance, formatter, **options)
322
323
    def _run_and_print_child_task_list(self, execution, args, **kwargs):
324
        action_exec_mgr = self.app.client.managers['LiveAction']
325
326
        instance = execution
327
        options = {'attributes': ['id', 'action.ref', 'parameters', 'status', 'start_timestamp',
328
                                  'end_timestamp']}
329
        options['json'] = args.json
330
        options['attribute_transform_functions'] = self.attribute_transform_functions
331
        formatter = execution_formatter.ExecutionResult
332
333
        kwargs['depth'] = args.depth
334
        child_instances = action_exec_mgr.get_property(execution.id, 'children', **kwargs)
335
        child_instances = self._format_child_instances(child_instances, execution.id)
336
        child_instances = format_execution_statuses(child_instances)
337
338
        if not child_instances:
339
            # No child error, there might be a global error, include result in the output
340
            options['attributes'].append('result')
341
342
        # On failure we also want to include error message and traceback at the top level
343
        if instance.status == 'failed':
344
            status_index = options['attributes'].index('status')
345
            if isinstance(instance.result, dict):
346
                tasks = instance.result.get('tasks', [])
347
            else:
348
                tasks = []
349
350
            top_level_error, top_level_traceback = self._get_top_level_error(live_action=instance)
351
352
            if len(tasks) >= 1:
353
                task_error, task_traceback = self._get_task_error(task=tasks[-1])
354
            else:
355
                task_error, task_traceback = None, None
356
357
            if top_level_error:
358
                # Top-level error
359
                instance.error = top_level_error
360
                instance.traceback = top_level_traceback
361
                instance.result = 'See error and traceback.'
362
                options['attributes'].insert(status_index + 1, 'error')
363
                options['attributes'].insert(status_index + 2, 'traceback')
364
            elif task_error:
365
                # Task error
366
                instance.error = task_error
367
                instance.traceback = task_traceback
368
                instance.result = 'See error and traceback.'
369
                instance.failed_on = tasks[-1].get('name', 'unknown')
370
                options['attributes'].insert(status_index + 1, 'error')
371
                options['attributes'].insert(status_index + 2, 'traceback')
372
                options['attributes'].insert(status_index + 3, 'failed_on')
373
374
        # print root task
375
        self.print_output(instance, formatter, **options)
376
377
        # print child tasks
378
        if child_instances:
379
            self.print_output(child_instances, table.MultiColumnTable,
380
                              attributes=['id', 'status', 'task', 'action', 'start_timestamp'],
381
                              widths=args.width, json=args.json,
382
                              yaml=args.yaml,
383
                              attribute_transform_functions=self.attribute_transform_functions)
384
385
    def _get_top_level_error(self, live_action):
386
        """
387
        Retrieve a top level workflow error.
388
389
        :return: (error, traceback)
390
        """
391
        if isinstance(live_action.result, dict):
392
            error = live_action.result.get('error', None)
393
            traceback = live_action.result.get('traceback', None)
394
        else:
395
            error = "See result"
396
            traceback = "See result"
397
398
        return error, traceback
399
400
    def _get_task_error(self, task):
401
        """
402
        Retrieve error message from the provided task.
403
404
        :return: (error, traceback)
405
        """
406
        if not task:
407
            return None, None
408
409
        result = task['result']
410
411
        if isinstance(result, dict):
412
            stderr = result.get('stderr', None)
413
            error = result.get('error', None)
414
            traceback = result.get('traceback', None)
415
            error = error if error else stderr
416
        else:
417
            stderr = None
418
            error = None
419
            traceback = None
420
421
        return error, traceback
422
423
    def _get_action_parameters_from_args(self, action, runner, args):
424
        """
425
        Build a dictionary with parameters which will be passed to the action by
426
        parsing parameters passed to the CLI.
427
428
        :param args: CLI argument.
429
        :type args: ``object``
430
431
        :rtype: ``dict``
432
        """
433
        action_ref_or_id = action.ref
434
435
        def read_file(file_path):
436
            if not os.path.exists(file_path):
437
                raise ValueError('File "%s" doesn\'t exist' % (file_path))
438
439
            if not os.path.isfile(file_path):
440
                raise ValueError('"%s" is not a file' % (file_path))
441
442
            with open(file_path, 'rb') as fp:
443
                content = fp.read()
444
445
            return content
446
447
        def transform_object(value):
448
            # Also support simple key1=val1,key2=val2 syntax
449
            if value.startswith('{'):
450
                # Assume it's JSON
451
                result = value = json.loads(value)
452
            else:
453
                pairs = value.split(',')
454
455
                result = {}
456
                for pair in pairs:
457
                    split = pair.split('=', 1)
458
459
                    if len(split) != 2:
460
                        continue
461
462
                    key, value = split
463
                    result[key] = value
464
            return result
465
466
        transformer = {
467
            'array': (lambda cs_x: [v.strip() for v in cs_x.split(',')]),
468
            'boolean': (lambda x: ast.literal_eval(x.capitalize())),
469
            'integer': int,
470
            'number': float,
471
            'object': transform_object,
472
            'string': str
473
        }
474
475
        def normalize(name, value):
476
            if name in runner.runner_parameters:
477
                param = runner.runner_parameters[name]
478
                if 'type' in param and param['type'] in transformer:
479
                    return transformer[param['type']](value)
480
481
            if name in action.parameters:
482
                param = action.parameters[name]
483
                if 'type' in param and param['type'] in transformer:
484
                    return transformer[param['type']](value)
485
            return value
486
487
        result = {}
488
489
        if not args.parameters:
490
            return result
491
492
        for idx in range(len(args.parameters)):
493
            arg = args.parameters[idx]
494
            if '=' in arg:
495
                k, v = arg.split('=', 1)
496
497
                # Attribute for files are prefixed with "@"
498
                if k.startswith('@'):
499
                    k = k[1:]
500
                    is_file = True
501
                else:
502
                    is_file = False
503
504
                try:
505
                    if is_file:
506
                        # Files are handled a bit differently since we ship the content
507
                        # over the wire
508
                        file_path = os.path.normpath(pjoin(os.getcwd(), v))
509
                        file_name = os.path.basename(file_path)
510
                        content = read_file(file_path=file_path)
511
512
                        if action_ref_or_id == 'core.http':
513
                            # Special case for http runner
514
                            result['_file_name'] = file_name
515
                            result['file_content'] = content
516
                        else:
517
                            result[k] = content
518
                    else:
519
                        result[k] = normalize(k, v)
520
                except Exception as e:
521
                    # TODO: Move transformers in a separate module and handle
522
                    # exceptions there
523
                    if 'malformed string' in str(e):
524
                        message = ('Invalid value for boolean parameter. '
525
                                   'Valid values are: true, false')
526
                        raise ValueError(message)
527
                    else:
528
                        raise e
529
            else:
530
                result['cmd'] = ' '.join(args.parameters[idx:])
531
                break
532
533
        # Special case for http runner
534
        if 'file_content' in result:
535
            if 'method' not in result:
536
                # Default to POST if a method is not provided
537
                result['method'] = 'POST'
538
539
            if 'file_name' not in result:
540
                # File name not provided, use default file name
541
                result['file_name'] = result['_file_name']
542
543
            del result['_file_name']
544
545
        if args.inherit_env:
546
            result['env'] = self._get_inherited_env_vars()
547
548
        return result
549
550
    @add_auth_token_to_kwargs_from_cli
551
    def _print_help(self, args, **kwargs):
552
        # Print appropriate help message if the help option is given.
553
        action_mgr = self.app.client.managers['Action']
554
        action_exec_mgr = self.app.client.managers['LiveAction']
555
556
        if args.help:
557
            action_ref_or_id = getattr(args, 'ref_or_id', None)
558
            action_exec_id = getattr(args, 'id', None)
559
560
            if action_exec_id and not action_ref_or_id:
561
                action_exec = action_exec_mgr.get_by_id(action_exec_id, **kwargs)
562
                args.ref_or_id = action_exec.action
563
564
            if action_ref_or_id:
565
                try:
566
                    action = action_mgr.get_by_ref_or_id(args.ref_or_id, **kwargs)
567
                    if not action:
568
                        raise resource.ResourceNotFoundError('Action %s not found', args.ref_or_id)
569
                    runner_mgr = self.app.client.managers['RunnerType']
570
                    runner = runner_mgr.get_by_name(action.runner_type, **kwargs)
571
                    parameters, required, optional, _ = self._get_params_types(runner,
572
                                                                               action)
573
                    print('')
574
                    print(textwrap.fill(action.description))
575
                    print('')
576
                    if required:
577
                        required = self._sort_parameters(parameters=parameters,
578
                                                         names=required)
579
580
                        print('Required Parameters:')
581
                        [self._print_param(name, parameters.get(name))
582
                            for name in required]
583
                    if optional:
584
                        optional = self._sort_parameters(parameters=parameters,
585
                                                         names=optional)
586
587
                        print('Optional Parameters:')
588
                        [self._print_param(name, parameters.get(name))
589
                            for name in optional]
590
                except resource.ResourceNotFoundError:
591
                    print(('Action "%s" is not found. ' % args.ref_or_id) +
592
                          'Do "st2 action list" to see list of available actions.')
593
                except Exception as e:
594
                    print('ERROR: Unable to print help for action "%s". %s' %
595
                          (args.ref_or_id, e))
596
            else:
597
                self.parser.print_help()
598
            return True
599
        return False
600
601
    @staticmethod
602
    def _print_param(name, schema):
603
        if not schema:
604
            raise ValueError('Missing schema for parameter "%s"' % (name))
605
606
        wrapper = textwrap.TextWrapper(width=78)
607
        wrapper.initial_indent = ' ' * 4
608
        wrapper.subsequent_indent = wrapper.initial_indent
609
        print(wrapper.fill(name))
610
        wrapper.initial_indent = ' ' * 8
611
        wrapper.subsequent_indent = wrapper.initial_indent
612
        if 'description' in schema and schema['description']:
613
            print(wrapper.fill(schema['description']))
614
        if 'type' in schema and schema['type']:
615
            print(wrapper.fill('Type: %s' % schema['type']))
616
        if 'enum' in schema and schema['enum']:
617
            print(wrapper.fill('Enum: %s' % ', '.join(schema['enum'])))
618
        if 'default' in schema and schema['default'] is not None:
619
            print(wrapper.fill('Default: %s' % schema['default']))
620
        print('')
621
622
    @staticmethod
623
    def _get_params_types(runner, action):
624
        runner_params = runner.runner_parameters
625
        action_params = action.parameters
626
        parameters = copy.copy(runner_params)
627
        parameters.update(copy.copy(action_params))
628
        required = set([k for k, v in six.iteritems(parameters) if v.get('required')])
629
630
        def is_immutable(runner_param_meta, action_param_meta):
631
            # If runner sets a param as immutable, action cannot override that.
632
            if runner_param_meta.get('immutable', False):
633
                return True
634
            else:
635
                return action_param_meta.get('immutable', False)
636
637
        immutable = set()
638
        for param in parameters.keys():
639
            if is_immutable(runner_params.get(param, {}),
640
                            action_params.get(param, {})):
641
                immutable.add(param)
642
643
        required = required - immutable
644
        optional = set(parameters.keys()) - required - immutable
645
646
        return parameters, required, optional, immutable
647
648
    def _format_child_instances(self, children, parent_id):
649
        '''
650
        The goal of this method is to add an indent at every level. This way the
651
        WF is represented as a tree structure while in a list. For the right visuals
652
        representation the list must be a DF traversal else the idents will end up
653
        looking strange.
654
        '''
655
        # apply basic WF formating first.
656
        children = format_wf_instances(children)
657
        # setup a depth lookup table
658
        depth = {parent_id: 0}
659
        result = []
660
        # main loop that indents each entry correctly
661
        for child in children:
662
            # make sure child.parent is in depth and while at it compute the
663
            # right depth for indentation purposes.
664
            if child.parent not in depth:
665
                parent = None
666
                for instance in children:
667
                    if WF_PREFIX in instance.id:
668
                        instance_id = instance.id[instance.id.index(WF_PREFIX) + len(WF_PREFIX):]
669
                    else:
670
                        instance_id = instance.id
671
                    if instance_id == child.parent:
672
                        parent = instance
673
                if parent and parent.parent and parent.parent in depth:
674
                    depth[child.parent] = depth[parent.parent] + 1
675
                else:
676
                    depth[child.parent] = 0
677
            # now ident for the right visuals
678
            child.id = INDENT_CHAR * depth[child.parent] + child.id
679
            result.append(self._format_for_common_representation(child))
680
        return result
681
682
    def _format_for_common_representation(self, task):
683
        '''
684
        Formats a task for common representation between mistral and action-chain.
685
        '''
686
        # This really needs to be better handled on the back-end but that would be a bigger
687
        # change so handling in cli.
688
        context = getattr(task, 'context', None)
689
        if context and 'chain' in context:
690
            task_name_key = 'context.chain.name'
691
        elif context and 'mistral' in context:
692
            task_name_key = 'context.mistral.task_name'
693
        # Use LiveAction as the object so that the formatter lookup does not change.
694
        # AKA HACK!
695
        return models.action.LiveAction(**{
696
            'id': task.id,
697
            'status': task.status,
698
            'task': jsutil.get_value(vars(task), task_name_key),
699
            'action': task.action.get('ref', None),
700
            'start_timestamp': task.start_timestamp,
701
            'end_timestamp': getattr(task, 'end_timestamp', None)
702
        })
703
704
    def _sort_parameters(self, parameters, names):
705
        """
706
        Sort a provided list of action parameters.
707
708
        :type parameters: ``list``
709
        :type names: ``list`` or ``set``
710
        """
711
        sorted_parameters = sorted(names, key=lambda name:
712
                                   self._get_parameter_sort_value(
713
                                       parameters=parameters,
714
                                       name=name))
715
716
        return sorted_parameters
717
718
    def _get_parameter_sort_value(self, parameters, name):
719
        """
720
        Return a value which determines sort order for a particular parameter.
721
722
        By default, parameters are sorted using "position" parameter attribute.
723
        If this attribute is not available, parameter is sorted based on the
724
        name.
725
        """
726
        parameter = parameters.get(name, None)
727
728
        if not parameter:
729
            return None
730
731
        sort_value = parameter.get('position', name)
732
        return sort_value
733
734
    def _get_inherited_env_vars(self):
735
        env_vars = os.environ.copy()
736
737
        for var_name in ENV_VARS_BLACKLIST:
738
            if var_name.lower() in env_vars:
739
                del env_vars[var_name.lower()]
740
            if var_name.upper() in env_vars:
741
                del env_vars[var_name.upper()]
742
743
        return env_vars
744
745
746
class ActionExecutionRunnerCommandMixin(object):
747
    @add_auth_token_to_kwargs_from_cli
748
    def run_and_print(self, args, **kwargs):
749
        if self._print_help(args, **kwargs):
750
            return
751
752
        execution = self.run(args, **kwargs)
753
        if args.async:
754
            self.print_output('To get the results, execute:\n st2 execution get %s' %
755
                              (execution.id), six.text_type)
756
        else:
757
            self._print_execution_details(execution=execution, args=args, **kwargs)
758
759
        if execution.status == 'failed':
760
            # Exit with non zero if the action has failed
761
            sys.exit(1)
762
763
    def _get_execution_result(self, execution, action_exec_mgr, args, **kwargs):
764
        pending_statuses = [
765
            LIVEACTION_STATUS_REQUESTED,
766
            LIVEACTION_STATUS_SCHEDULED,
767
            LIVEACTION_STATUS_RUNNING,
768
            LIVEACTION_STATUS_CANCELING
769
        ]
770
771
        if not args.async:
772
            while execution.status in pending_statuses:
773
                time.sleep(self.poll_interval)
774
                if not args.json and not args.yaml:
775
                    sys.stdout.write('.')
776
                    sys.stdout.flush()
777
                execution = action_exec_mgr.get_by_id(execution.id, **kwargs)
778
779
            sys.stdout.write('\n')
780
781
            if execution.status == LIVEACTION_STATUS_CANCELED:
782
                return execution
783
784
        return execution
785
786
787
class ActionRunCommand(ActionRunCommandMixin, ActionExecutionRunnerCommandMixin,
788
                       resource.ResourceCommand):
789
    def __init__(self, resource, *args, **kwargs):
0 ignored issues
show
Comprehensibility Bug introduced by
resource is re-defining a name which is already available in the outer-scope (previously defined on line 30).

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...
790
791
        super(ActionRunCommand, self).__init__(
792
            resource, kwargs.pop('name', 'execute'),
793
            'A command to invoke an action manually.',
794
            *args, **kwargs)
795
796
        self.parser.add_argument('ref_or_id', nargs='?',
797
                                 metavar='ref-or-id',
798
                                 help='Action reference (pack.action_name) ' +
799
                                 'or ID of the action.')
800
        self.parser.add_argument('parameters', nargs='*',
801
                                 help='List of keyword args, positional args, '
802
                                      'and optional args for the action.')
803
804
        self.parser.add_argument('-h', '--help',
805
                                 action='store_true', dest='help',
806
                                 help='Print usage for the given action.')
807
808
        self._add_common_options()
809
810
        if self.name in ['run', 'execute']:
811
            self.parser.add_argument('--trace-tag', '--trace_tag',
812
                                     help='A trace tag string to track execution later.',
813
                                     dest='trace_tag', required=False)
814
            self.parser.add_argument('--trace-id',
815
                                     help='Existing trace id for this execution.',
816
                                     dest='trace_id', required=False)
817
            self.parser.add_argument('-a', '--async',
818
                                     action='store_true', dest='async',
819
                                     help='Do not wait for action to finish.')
820
            self.parser.add_argument('-e', '--inherit-env',
821
                                     action='store_true', dest='inherit_env',
822
                                     help='Pass all the environment variables '
823
                                          'which are accessible to the CLI as "env" '
824
                                          'parameter to the action. Note: Only works '
825
                                          'with python, local and remote runners.')
826
            self.parser.add_argument('-u', '--user', type=str, default=None,
827
                                           help='User under which to run the action (admins only).')
828
829
        if self.name == 'run':
830
            self.parser.set_defaults(async=False)
831
        else:
832
            self.parser.set_defaults(async=True)
833
834
    @add_auth_token_to_kwargs_from_cli
835
    def run(self, args, **kwargs):
836
        if not args.ref_or_id:
837
            self.parser.error('Missing action reference or id')
838
839
        action = self.get_resource(args.ref_or_id, **kwargs)
840
        if not action:
841
            raise resource.ResourceNotFoundError('Action "%s" cannot be found.'
842
                                                 % (args.ref_or_id))
843
844
        runner_mgr = self.app.client.managers['RunnerType']
845
        runner = runner_mgr.get_by_name(action.runner_type, **kwargs)
846
        if not runner:
847
            raise resource.ResourceNotFoundError('Runner type "%s" for action "%s" cannot be found.'
848
                                                 % (action.runner_type, action.name))
849
850
        action_ref = '.'.join([action.pack, action.name])
851
        action_parameters = self._get_action_parameters_from_args(action=action, runner=runner,
852
                                                                  args=args)
853
854
        execution = models.LiveAction()
855
        execution.action = action_ref
856
        execution.parameters = action_parameters
857
        execution.user = args.user
858
859
        if not args.trace_id and args.trace_tag:
860
            execution.context = {'trace_context': {'trace_tag': args.trace_tag}}
861
862
        if args.trace_id:
863
            execution.context = {'trace_context': {'id_': args.trace_id}}
864
865
        action_exec_mgr = self.app.client.managers['LiveAction']
866
867
        execution = action_exec_mgr.create(execution, **kwargs)
868
        execution = self._get_execution_result(execution=execution,
869
                                               action_exec_mgr=action_exec_mgr,
870
                                               args=args, **kwargs)
871
        return execution
872
873
874
class ActionExecutionBranch(resource.ResourceBranch):
875
876
    def __init__(self, description, app, subparsers, parent_parser=None):
877
        super(ActionExecutionBranch, self).__init__(
878
            models.LiveAction, description, app, subparsers,
879
            parent_parser=parent_parser, read_only=True,
880
            commands={'list': ActionExecutionListCommand,
881
                      'get': ActionExecutionGetCommand})
882
883
        # Register extended commands
884
        self.commands['re-run'] = ActionExecutionReRunCommand(self.resource, self.app,
885
                                                              self.subparsers, add_help=False)
886
        self.commands['cancel'] = ActionExecutionCancelCommand(self.resource, self.app,
887
                                                               self.subparsers, add_help=False)
888
889
890
POSSIBLE_ACTION_STATUS_VALUES = ('succeeded', 'running', 'scheduled', 'failed', 'canceled')
891
892
893
class ActionExecutionReadCommand(resource.ResourceCommand):
894
    """
895
    Base class for read / view commands (list and get).
896
    """
897
898
    @classmethod
899
    def _get_exclude_attributes(cls, args):
900
        """
901
        Retrieve a list of exclude attributes for particular command line arguments.
902
        """
903
        exclude_attributes = []
904
905
        result_included = False
906
        trigger_instance_included = False
907
908
        for attr in args.attr:
909
            # Note: We perform startswith check so we correctly detected child attribute properties
910
            # (e.g. result, result.stdout, result.stderr, etc.)
911
            if attr.startswith('result'):
912
                result_included = True
913
914
            if attr.startswith('trigger_instance'):
915
                trigger_instance_included = True
916
917
        if not result_included:
918
            exclude_attributes.append('result')
919
        if not trigger_instance_included:
920
            exclude_attributes.append('trigger_instance')
921
922
        return exclude_attributes
923
924
925
class ActionExecutionListCommand(ActionExecutionReadCommand):
926
    display_attributes = ['id', 'action.ref', 'context.user', 'status', 'start_timestamp',
927
                          'end_timestamp']
928
    attribute_transform_functions = {
929
        'start_timestamp': format_isodate_for_user_timezone,
930
        'end_timestamp': format_isodate_for_user_timezone,
931
        'parameters': format_parameters,
932
        'status': format_status
933
    }
934
935
    def __init__(self, resource, *args, **kwargs):
0 ignored issues
show
Comprehensibility Bug introduced by
resource is re-defining a name which is already available in the outer-scope (previously defined on line 30).

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...
936
        super(ActionExecutionListCommand, self).__init__(
937
            resource, 'list', 'Get the list of the 50 most recent %s.' %
938
            resource.get_plural_display_name().lower(),
939
            *args, **kwargs)
940
941
        self.group = self.parser.add_argument_group()
942
        self.parser.add_argument('-n', '--last', type=int, dest='last',
943
                                 default=50,
944
                                 help=('List N most recent %s.' %
945
                                       resource.get_plural_display_name().lower()))
946
        self.parser.add_argument('-s', '--sort', type=str, dest='sort_order',
947
                                 default='descending',
948
                                 help=('Sort %s by start timestamp, '
949
                                       'asc|ascending (earliest first) '
950
                                       'or desc|descending (latest first)' %
951
                                       resource.get_plural_display_name().lower()))
952
953
        # Filter options
954
        self.group.add_argument('--action', help='Action reference to filter the list.')
955
        self.group.add_argument('--status', help=('Only return executions with the provided status.'
956
                                                  ' Possible values are \'%s\', \'%s\', \'%s\','
957
                                                  '\'%s\' or \'%s\''
958
                                                  '.' % POSSIBLE_ACTION_STATUS_VALUES))
959
        self.group.add_argument('--trigger_instance',
960
                                help='Trigger instance id to filter the list.')
961
        self.parser.add_argument('-tg', '--timestamp-gt', type=str, dest='timestamp_gt',
962
                                 default=None,
963
                                 help=('Only return executions with timestamp '
964
                                       'greater than the one provided. '
965
                                       'Use time in the format "2000-01-01T12:00:00.000Z".'))
966
        self.parser.add_argument('-tl', '--timestamp-lt', type=str, dest='timestamp_lt',
967
                                 default=None,
968
                                 help=('Only return executions with timestamp '
969
                                       'lower than the one provided. '
970
                                       'Use time in the format "2000-01-01T12:00:00.000Z".'))
971
        self.parser.add_argument('-l', '--showall', action='store_true',
972
                                 help='')
973
974
        # Display options
975
        self.parser.add_argument('-a', '--attr', nargs='+',
976
                                 default=self.display_attributes,
977
                                 help=('List of attributes to include in the '
978
                                       'output. "all" will return all '
979
                                       'attributes.'))
980
        self.parser.add_argument('-w', '--width', nargs='+', type=int,
981
                                 default=None,
982
                                 help=('Set the width of columns in output.'))
983
984
    @add_auth_token_to_kwargs_from_cli
985
    def run(self, args, **kwargs):
986
        # Filtering options
987
        if args.action:
988
            kwargs['action'] = args.action
989
        if args.status:
990
            kwargs['status'] = args.status
991
        if args.trigger_instance:
992
            kwargs['trigger_instance'] = args.trigger_instance
993
        if not args.showall:
994
            # null is the magic string that translates to does not exist.
995
            kwargs['parent'] = 'null'
996
        if args.timestamp_gt:
997
            kwargs['timestamp_gt'] = args.timestamp_gt
998
        if args.timestamp_lt:
999
            kwargs['timestamp_lt'] = args.timestamp_lt
1000
        if args.sort_order:
1001
            if args.sort_order in ['asc', 'ascending']:
1002
                kwargs['sort_asc'] = True
1003
            elif args.sort_order in ['desc', 'descending']:
1004
                kwargs['sort_desc'] = True
1005
1006
        # We exclude "result" and "trigger_instance" attributes which can contain a lot of data
1007
        # since they are not displayed nor used which speeds the common operation substantially.
1008
        exclude_attributes = self._get_exclude_attributes(args=args)
1009
        exclude_attributes = ','.join(exclude_attributes)
1010
        kwargs['exclude_attributes'] = exclude_attributes
1011
1012
        return self.manager.query(limit=args.last, **kwargs)
1013
1014
    def run_and_print(self, args, **kwargs):
1015
        instances = format_wf_instances(self.run(args, **kwargs))
1016
1017
        if not args.json and not args.yaml:
1018
            # Include elapsed time for running executions
1019
            instances = format_execution_statuses(instances)
1020
1021
        self.print_output(reversed(instances), table.MultiColumnTable,
1022
                          attributes=args.attr, widths=args.width,
1023
                          json=args.json,
1024
                          yaml=args.yaml,
1025
                          attribute_transform_functions=self.attribute_transform_functions)
1026
1027
1028
class ActionExecutionGetCommand(ActionRunCommandMixin, ActionExecutionRunnerCommandMixin,
1029
                                ActionExecutionReadCommand):
1030
    display_attributes = ['id', 'action.ref', 'context.user', 'parameters', 'status',
1031
                          'start_timestamp', 'end_timestamp', 'result', 'liveaction']
1032
1033
    def __init__(self, resource, *args, **kwargs):
0 ignored issues
show
Comprehensibility Bug introduced by
resource is re-defining a name which is already available in the outer-scope (previously defined on line 30).

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...
1034
        super(ActionExecutionGetCommand, self).__init__(
1035
            resource, 'get',
1036
            'Get individual %s.' % resource.get_display_name().lower(),
1037
            *args, **kwargs)
1038
1039
        self.parser.add_argument('id',
1040
                                 help=('ID of the %s.' %
1041
                                       resource.get_display_name().lower()))
1042
1043
        self._add_common_options()
1044
1045
    @add_auth_token_to_kwargs_from_cli
1046
    def run(self, args, **kwargs):
1047
        # We exclude "result" and / or "trigger_instance" attribute if it's not explicitly
1048
        # requested by user either via "--attr" flag or by default.
1049
        exclude_attributes = self._get_exclude_attributes(args=args)
1050
        exclude_attributes = ','.join(exclude_attributes)
1051
1052
        kwargs['params'] = {'exclude_attributes': exclude_attributes}
1053
1054
        execution = self.get_resource_by_id(id=args.id, **kwargs)
1055
        return execution
1056
1057
    @add_auth_token_to_kwargs_from_cli
1058
    def run_and_print(self, args, **kwargs):
1059
        try:
1060
            execution = self.run(args, **kwargs)
1061
1062
            if not args.json and not args.yaml:
1063
                # Include elapsed time for running executions
1064
                execution = format_execution_status(execution)
1065
        except resource.ResourceNotFoundError:
1066
            self.print_not_found(args.id)
1067
            raise OperationFailureException('Execution %s not found.' % (args.id))
1068
        return self._print_execution_details(execution=execution, args=args, **kwargs)
1069
1070
1071
class ActionExecutionCancelCommand(resource.ResourceCommand):
1072
1073
    def __init__(self, resource, *args, **kwargs):
0 ignored issues
show
Comprehensibility Bug introduced by
resource is re-defining a name which is already available in the outer-scope (previously defined on line 30).

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...
1074
        super(ActionExecutionCancelCommand, self).__init__(
1075
            resource, 'cancel', 'Cancel %s.' %
1076
            resource.get_plural_display_name().lower(),
1077
            *args, **kwargs)
1078
1079
        self.parser.add_argument('ids',
1080
                                 nargs='+',
1081
                                 help=('IDs of the %ss to cancel.' %
1082
                                       resource.get_display_name().lower()))
1083
1084
    def run(self, args, **kwargs):
1085
        responses = []
1086
        for execution_id in args.ids:
1087
            response = self.manager.delete_by_id(execution_id)
1088
            responses.append([execution_id, response])
1089
1090
        return responses
1091
1092
    @add_auth_token_to_kwargs_from_cli
1093
    def run_and_print(self, args, **kwargs):
1094
        responses = self.run(args, **kwargs)
1095
1096
        for execution_id, response in responses:
1097
            self._print_result(execution_id=execution_id, response=response)
1098
1099
    def _print_result(self, execution_id, response):
1100
        if response and 'faultstring' in response:
1101
            message = response.get('faultstring', 'Cancellation requested for %s with id %s.' %
1102
                                   (self.resource.get_display_name().lower(), execution_id))
1103
1104
        elif response:
1105
            message = '%s with id %s canceled.' % (self.resource.get_display_name().lower(),
1106
                                                   execution_id)
1107
        else:
1108
            message = 'Cannot cancel %s with id %s.' % (self.resource.get_display_name().lower(),
1109
                                                        execution_id)
1110
        print(message)
1111
1112
1113
class ActionExecutionReRunCommand(ActionRunCommandMixin, ActionExecutionRunnerCommandMixin,
1114
                                  resource.ResourceCommand):
1115
    def __init__(self, resource, *args, **kwargs):
0 ignored issues
show
Comprehensibility Bug introduced by
resource is re-defining a name which is already available in the outer-scope (previously defined on line 30).

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...
1116
1117
        super(ActionExecutionReRunCommand, self).__init__(
1118
            resource, kwargs.pop('name', 're-run'),
1119
            'A command to re-run a particular action.',
1120
            *args, **kwargs)
1121
1122
        self.parser.add_argument('id', nargs='?',
1123
                                 metavar='id',
1124
                                 help='ID of action execution to re-run ')
1125
        self.parser.add_argument('parameters', nargs='*',
1126
                                 help='List of keyword args, positional args, '
1127
                                      'and optional args for the action.')
1128
        self.parser.add_argument('--tasks', nargs='*',
1129
                                 help='Name of the workflow tasks to re-run.')
1130
        self.parser.add_argument('--no-reset', dest='no_reset', nargs='*',
1131
                                 help='Name of the with-items tasks to not reset. This only '
1132
                                      'applies to Mistral workflows. By default, all iterations '
1133
                                      'for with-items tasks is rerun. If no reset, only failed '
1134
                                      ' iterations are rerun.')
1135
        self.parser.add_argument('-a', '--async',
1136
                                 action='store_true', dest='async',
1137
                                 help='Do not wait for action to finish.')
1138
        self.parser.add_argument('-e', '--inherit-env',
1139
                                 action='store_true', dest='inherit_env',
1140
                                 help='Pass all the environment variables '
1141
                                      'which are accessible to the CLI as "env" '
1142
                                      'parameter to the action. Note: Only works '
1143
                                      'with python, local and remote runners.')
1144
        self.parser.add_argument('-h', '--help',
1145
                                 action='store_true', dest='help',
1146
                                 help='Print usage for the given action.')
1147
1148
        self._add_common_options()
1149
1150
    @add_auth_token_to_kwargs_from_cli
1151
    def run(self, args, **kwargs):
1152
        existing_execution = self.manager.get_by_id(args.id, **kwargs)
1153
1154
        if not existing_execution:
1155
            raise resource.ResourceNotFoundError('Action execution with id "%s" cannot be found.' %
1156
                                                 (args.id))
1157
1158
        action_mgr = self.app.client.managers['Action']
1159
        runner_mgr = self.app.client.managers['RunnerType']
1160
        action_exec_mgr = self.app.client.managers['LiveAction']
1161
1162
        action_ref = existing_execution.action['ref']
1163
        action = action_mgr.get_by_ref_or_id(action_ref)
1164
        runner = runner_mgr.get_by_name(action.runner_type)
1165
1166
        action_parameters = self._get_action_parameters_from_args(action=action, runner=runner,
1167
                                                                  args=args)
1168
1169
        execution = action_exec_mgr.re_run(execution_id=args.id,
1170
                                           parameters=action_parameters,
1171
                                           tasks=args.tasks,
1172
                                           no_reset=args.no_reset,
1173
                                           **kwargs)
1174
1175
        execution = self._get_execution_result(execution=execution,
1176
                                               action_exec_mgr=action_exec_mgr,
1177
                                               args=args, **kwargs)
1178
1179
        return execution
1180