GitHub Access Token became invalid

It seems like the GitHub access token used for retrieving details about this repository from GitHub became invalid. This might prevent certain types of inspections from being run (in particular, everything related to pull requests).
Please ask an admin of your repository to re-new the access token on this website.
Passed
Push — develop-v1.3.1 ( 8fa207...a1cf9b )
by
unknown
06:11
created

Shell.get_client()   F

Complexity

Conditions 13

Size

Total Lines 77

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 13
dl 0
loc 77
rs 2.15
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

Complexity

Complex classes like Shell.get_client() often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

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
"""
17
Command-line interface to StackStorm.
18
"""
19
20
from __future__ import print_function
21
from __future__ import absolute_import
22
23
import os
24
import sys
25
import json
26
import time
27
import argparse
28
import calendar
29
import logging
30
import traceback
31
32
import six
33
import requests
34
35
from st2client import __version__
36
from st2client import models
37
from st2client.client import Client
38
from st2client.commands import auth
39
from st2client.commands import action
40
from st2client.commands import action_alias
41
from st2client.commands import keyvalue
42
from st2client.commands import policy
43
from st2client.commands import resource
44
from st2client.commands import sensor
45
from st2client.commands import trace
46
from st2client.commands import trigger
47
from st2client.commands import triggerinstance
48
from st2client.commands import webhook
49
from st2client.commands import rule
50
from st2client.commands import rule_enforcement
51
from st2client.config_parser import CLIConfigParser
52
from st2client.config_parser import ST2_CONFIG_DIRECTORY
53
from st2client.config_parser import ST2_CONFIG_PATH
54
from st2client.exceptions.operations import OperationFailureException
55
from st2client.utils.date import parse as parse_isotime
56
from st2client.utils.misc import merge_dicts
57
from st2client.utils.logging import LogLevelFilter, set_log_level_for_all_loggers
58
59
__all__ = [
60
    'Shell'
61
]
62
63
LOG = logging.getLogger(__name__)
64
65
CLI_DESCRIPTION = 'CLI for StackStorm event-driven automation platform. https://stackstorm.com'
66
67
# How many seconds before the token actual expiration date we should consider the token as
68
# expired. This is used to prevent the operation from failing durig the API request because the
69
# token was just about to expire.
70
TOKEN_EXPIRATION_GRACE_PERIOD_SECONDS = 15
71
72
CONFIG_OPTION_TO_CLIENT_KWARGS_MAP = {
73
    'base_url': ['general', 'base_url'],
74
    'auth_url': ['auth', 'url'],
75
    'api_url': ['api', 'url'],
76
    'api_version': ['general', 'api_version'],
77
    'cacert': ['general', 'cacert'],
78
    'debug': ['cli', 'debug']
79
}
80
81
# A list of command classes for which automatic authentication should be skipped.
82
from st2client.commands.auth import TokenCreateCommand
83
SKIP_AUTH_CLASSES = [
84
    TokenCreateCommand.__name__
85
]
86
87
88
class Shell(object):
89
90
    def __init__(self):
91
        # Set up of endpoints is delayed until program is run.
92
        self.client = None
93
94
        # Set up the main parser.
95
        self.parser = argparse.ArgumentParser(description=CLI_DESCRIPTION)
96
97
        # Set up general program options.
98
        self.parser.add_argument(
99
            '--version',
100
            action='version',
101
            version='%(prog)s {version}'.format(version=__version__))
102
103
        self.parser.add_argument(
104
            '--url',
105
            action='store',
106
            dest='base_url',
107
            default=None,
108
            help='Base URL for the API servers. Assumes all servers uses the '
109
                 'same base URL and default ports are used. Get ST2_BASE_URL'
110
                 'from the environment variables by default.'
111
        )
112
113
        self.parser.add_argument(
114
            '--auth-url',
115
            action='store',
116
            dest='auth_url',
117
            default=None,
118
            help='URL for the autentication service. Get ST2_AUTH_URL'
119
                 'from the environment variables by default.'
120
        )
121
122
        self.parser.add_argument(
123
            '--api-url',
124
            action='store',
125
            dest='api_url',
126
            default=None,
127
            help='URL for the API server. Get ST2_API_URL'
128
                 'from the environment variables by default.'
129
        )
130
131
        self.parser.add_argument(
132
            '--api-version',
133
            action='store',
134
            dest='api_version',
135
            default=None,
136
            help='API version to sue. Get ST2_API_VERSION'
137
                 'from the environment variables by default.'
138
        )
139
140
        self.parser.add_argument(
141
            '--cacert',
142
            action='store',
143
            dest='cacert',
144
            default=None,
145
            help='Path to the CA cert bundle for the SSL endpoints. '
146
                 'Get ST2_CACERT from the environment variables by default. '
147
                 'If this is not provided, then SSL cert will not be verified.'
148
        )
149
150
        self.parser.add_argument(
151
            '--config-file',
152
            action='store',
153
            dest='config_file',
154
            default=None,
155
            help='Path to the CLI config file'
156
        )
157
158
        self.parser.add_argument(
159
            '--print-config',
160
            action='store_true',
161
            dest='print_config',
162
            default=False,
163
            help='Parse the config file and print the values'
164
        )
165
166
        self.parser.add_argument(
167
            '--skip-config',
168
            action='store_true',
169
            dest='skip_config',
170
            default=False,
171
            help='Don\'t parse and use the CLI config file'
172
        )
173
174
        self.parser.add_argument(
175
            '--debug',
176
            action='store_true',
177
            dest='debug',
178
            default=False,
179
            help='Enable debug mode'
180
        )
181
182
        # Set up list of commands and subcommands.
183
        self.subparsers = self.parser.add_subparsers()
184
        self.commands = dict()
185
186
        self.commands['action'] = action.ActionBranch(
187
            'An activity that happens as a response to the external event.',
188
            self, self.subparsers)
189
190
        self.commands['action-alias'] = action_alias.ActionAliasBranch(
191
            'Action aliases.',
192
            self, self.subparsers)
193
194
        self.commands['auth'] = auth.TokenCreateCommand(
195
            models.Token, self, self.subparsers, name='auth')
196
197
        self.commands['api-key'] = auth.ApiKeyBranch(
198
            'API Keys.',
199
            self, self.subparsers)
200
201
        self.commands['execution'] = action.ActionExecutionBranch(
202
            'An invocation of an action.',
203
            self, self.subparsers)
204
205
        self.commands['key'] = keyvalue.KeyValuePairBranch(
206
            'Key value pair is used to store commonly used configuration '
207
            'for reuse in sensors, actions, and rules.',
208
            self, self.subparsers)
209
210
        self.commands['policy'] = policy.PolicyBranch(
211
            'Policy that is enforced on a resource.',
212
            self, self.subparsers)
213
214
        self.commands['policy-type'] = policy.PolicyTypeBranch(
215
            'Type of policy that can be applied to resources.',
216
            self, self.subparsers)
217
218
        self.commands['rule'] = rule.RuleBranch(
219
            'A specification to invoke an "action" on a "trigger" selectively '
220
            'based on some criteria.',
221
            self, self.subparsers)
222
223
        self.commands['run'] = action.ActionRunCommand(
224
            models.Action, self, self.subparsers, name='run', add_help=False)
225
226
        self.commands['runner'] = resource.ResourceBranch(
227
            models.RunnerType,
228
            'Runner is a type of handler for a specific class of actions.',
229
            self, self.subparsers, read_only=True)
230
231
        self.commands['sensor'] = sensor.SensorBranch(
232
            'An adapter which allows you to integrate StackStorm with external system ',
233
            self, self.subparsers)
234
235
        self.commands['trace'] = trace.TraceBranch(
236
            'A group of executions, rules and triggerinstances that are related.',
237
            self, self.subparsers)
238
239
        self.commands['trigger'] = trigger.TriggerTypeBranch(
240
            'An external event that is mapped to a st2 input. It is the '
241
            'st2 invocation point.',
242
            self, self.subparsers)
243
244
        self.commands['trigger-instance'] = triggerinstance.TriggerInstanceBranch(
245
            'Actual instances of triggers received by st2.',
246
            self, self.subparsers)
247
248
        self.commands['webhook'] = webhook.WebhookBranch(
249
            'Webhooks.',
250
            self, self.subparsers)
251
252
        self.commands['rule-enforcement'] = rule_enforcement.RuleEnforcementBranch(
253
            'Models that represent enforcement of rules.',
254
            self, self.subparsers)
255
256
    def get_client(self, args, debug=False):
257
        ST2_CLI_SKIP_CONFIG = os.environ.get('ST2_CLI_SKIP_CONFIG', 0)
258
        ST2_CLI_SKIP_CONFIG = int(ST2_CLI_SKIP_CONFIG)
259
260
        skip_config = args.skip_config
261
        skip_config = skip_config or ST2_CLI_SKIP_CONFIG
262
263
        # Note: Options provided as the CLI argument have the highest precedence
264
        # Precedence order: cli arguments > environment variables > rc file variables
265
        cli_options = ['base_url', 'auth_url', 'api_url', 'api_version', 'cacert']
266
        cli_options = {opt: getattr(args, opt) for opt in cli_options}
267
        config_file_options = self._get_config_file_options(args=args)
268
269
        kwargs = {}
270
271
        if not skip_config:
272
            # Config parsing is skipped
273
            kwargs = merge_dicts(kwargs, config_file_options)
274
275
        kwargs = merge_dicts(kwargs, cli_options)
276
        kwargs['debug'] = debug
277
278
        client = Client(**kwargs)
279
280
        if ST2_CLI_SKIP_CONFIG:
281
            # Config parsing is skipped
282
            LOG.info('Skipping parsing CLI config')
283
            return client
284
285
        # Ok to load config at this point.
286
        rc_config = self._parse_config_file(args=args)
287
288
        # Silence SSL warnings
289
        silence_ssl_warnings = rc_config.get('general', {}).get('silence_ssl_warnings', False)
290
        if silence_ssl_warnings:
291
            requests.packages.urllib3.disable_warnings()
292
293
        # We skip automatic authentication for some commands such as auth
294
        try:
295
            command_class_name = args.func.im_class.__name__
296
        except Exception:
297
            command_class_name = None
298
299
        if command_class_name in SKIP_AUTH_CLASSES:
300
            return client
301
302
        # We also skip automatic authentication if token is provided via the environment variable
303
        # or as a command line argument
304
        env_var_token = os.environ.get('ST2_AUTH_TOKEN', None)
305
        cli_argument_token = getattr(args, 'token', None)
306
        if env_var_token or cli_argument_token:
307
            return client
308
309
        # If credentials are provided in the CLI config use them and try to authenticate
310
        credentials = rc_config.get('credentials', {})
311
        username = credentials.get('username', None)
312
        password = credentials.get('password', None)
313
        cache_token = rc_config.get('cli', {}).get('cache_token', False)
314
315
        if username and password:
316
            # Credentials are provided, try to authenticate agaist the API
317
            try:
318
                token = self._get_auth_token(client=client, username=username, password=password,
319
                                             cache_token=cache_token)
320
            except requests.exceptions.ConnectionError as e:
321
                LOG.warn('Auth API server is not available, skipping authentication.')
322
                LOG.exception(e)
323
                return client
324
            except Exception as e:
325
                print('Failed to authenticate with credentials provided in the config.')
326
                raise e
327
328
            client.token = token
329
            # TODO: Hack, refactor when splitting out the client
330
            os.environ['ST2_AUTH_TOKEN'] = token
331
332
        return client
333
334
    def run(self, argv):
335
        debug = False
336
337
        if '--print-config' in argv:
338
            # Hack because --print-config requires no command to be specified
339
            argv = argv + ['action', 'list']
340
341
        # Parse command line arguments.
342
        args = self.parser.parse_args(args=argv)
343
344
        print_config = args.print_config
345
        if print_config:
346
            self._print_config(args=args)
347
            return 3
348
349
        try:
350
            debug = getattr(args, 'debug', False)
351
            if debug:
352
                set_log_level_for_all_loggers(level=logging.DEBUG)
353
354
            # Set up client.
355
            self.client = self.get_client(args=args, debug=debug)
356
357
            # Execute command.
358
            args.func(args)
359
360
            return 0
361
        except OperationFailureException as e:
362
            if debug:
363
                self._print_debug_info(args=args)
364
            return 2
365
        except Exception as e:
366
            # We allow exception to define custom exit codes
367
            exit_code = getattr(e, 'exit_code', 1)
368
369
            print('ERROR: %s\n' % e)
370
            if debug:
371
                self._print_debug_info(args=args)
372
373
            return exit_code
374
375
    def _print_config(self, args):
376
        config = self._parse_config_file(args=args)
377
378
        for section, options in six.iteritems(config):
379
            print('[%s]' % (section))
380
381
            for name, value in six.iteritems(options):
382
                print('%s = %s' % (name, value))
383
384
    def _print_debug_info(self, args):
385
        # Print client settings
386
        self._print_client_settings(args=args)
387
388
        # Print exception traceback
389
        traceback.print_exc()
390
391
    def _print_client_settings(self, args):
392
        client = self.client
393
394
        if not client:
395
            return
396
397
        config_file_path = self._get_config_file_path(args=args)
398
399
        print('CLI settings:')
400
        print('----------------')
401
        print('Config file path: %s' % (config_file_path))
402
        print('Client settings:')
403
        print('----------------')
404
        print('ST2_BASE_URL: %s' % (client.endpoints['base']))
405
        print('ST2_AUTH_URL: %s' % (client.endpoints['auth']))
406
        print('ST2_API_URL: %s' % (client.endpoints['api']))
407
        print('ST2_AUTH_TOKEN: %s' % (os.environ.get('ST2_AUTH_TOKEN')))
408
        print('')
409
        print('Proxy settings:')
410
        print('---------------')
411
        print('HTTP_PROXY: %s' % (os.environ.get('HTTP_PROXY', '')))
412
        print('HTTPS_PROXY: %s' % (os.environ.get('HTTPS_PROXY', '')))
413
        print('')
414
415
    def _get_auth_token(self, client, username, password, cache_token):
416
        """
417
        Retrieve a valid auth token.
418
419
        If caching is enabled, we will first try to retrieve cached token from a
420
        file system. If cached token is expired or not available, we will try to
421
        authenticate using the provided credentials and retrieve a new auth
422
        token.
423
424
        :rtype: ``str``
425
        """
426
        if cache_token:
427
            token = self._get_cached_auth_token(client=client, username=username,
428
                                                password=password)
429
        else:
430
            token = None
431
432
        if not token:
433
            # Token is either expired or not available
434
            token_obj = self._authenticate_and_retrieve_auth_token(client=client,
435
                                                                   username=username,
436
                                                                   password=password)
437
            self._cache_auth_token(token_obj=token_obj)
438
            token = token_obj.token
439
440
        return token
441
442
    def _get_cached_auth_token(self, client, username, password):
443
        """
444
        Retrieve cached auth token from the file in the config directory.
445
446
        :rtype: ``str``
447
        """
448
        if not os.path.isdir(ST2_CONFIG_DIRECTORY):
449
            os.makedirs(ST2_CONFIG_DIRECTORY)
450
451
        cached_token_path = self._get_cached_token_path_for_user(username=username)
452
        if not os.path.isfile(cached_token_path):
453
            return None
454
455
        if not os.access(ST2_CONFIG_DIRECTORY, os.R_OK):
456
            # We don't have read access to the file with a cached token
457
            message = ('Unable to retrieve cached token from "%s" (user %s doesn\'t have read '
458
                       'access to the parent directory). Subsequent requests won\'t use a '
459
                       'cached token meaning they may be slower.' % (cached_token_path,
460
                                                                     os.getlogin()))
461
            LOG.warn(message)
462
            return None
463
464
        if not os.access(cached_token_path, os.R_OK):
465
            # We don't have read access to the file with a cached token
466
            message = ('Unable to retrieve cached token from "%s" (user %s doesn\'t have read '
467
                       'access to this file). Subsequent requests won\'t use a cached token '
468
                       'meaning they may be slower.' % (cached_token_path, os.getlogin()))
469
            LOG.warn(message)
470
            return None
471
472
        with open(cached_token_path) as fp:
473
            data = fp.read()
474
475
        try:
476
            data = json.loads(data)
477
478
            token = data['token']
479
            expire_timestamp = data['expire_timestamp']
480
        except Exception as e:
481
            msg = ('File "%s" with cached token is corrupted or invalid (%s). Please delete '
482
                   ' this file' % (cached_token_path, str(e)))
483
            raise ValueError(msg)
484
485
        now = int(time.time())
486
        if (expire_timestamp - TOKEN_EXPIRATION_GRACE_PERIOD_SECONDS) < now:
487
            LOG.debug('Cached token from file "%s" has expired' % (cached_token_path))
488
            # Token has expired
489
            return None
490
491
        LOG.debug('Using cached token from file "%s"' % (cached_token_path))
492
        return token
493
494
    def _cache_auth_token(self, token_obj):
495
        """
496
        Cache auth token in the config directory.
497
498
        :param token_obj: Token object.
499
        :type token_obj: ``object``
500
        """
501
        if not os.path.isdir(ST2_CONFIG_DIRECTORY):
502
            os.makedirs(ST2_CONFIG_DIRECTORY)
503
504
        username = token_obj.user
505
        cached_token_path = self._get_cached_token_path_for_user(username=username)
506
507
        if not os.access(ST2_CONFIG_DIRECTORY, os.W_OK):
508
            # We don't have write access to the file with a cached token
509
            message = ('Unable to write token to "%s" (user %s doesn\'t have write'
510
                       'access to the parent directory). Subsequent requests won\'t use a '
511
                       'cached token meaning they may be slower.' % (cached_token_path,
512
                                                                     os.getlogin()))
513
            LOG.warn(message)
514
            return None
515
516
        if os.path.isfile(cached_token_path) and not os.access(cached_token_path, os.W_OK):
517
            # We don't have write access to the file with a cached token
518
            message = ('Unable to write token to "%s" (user %s doesn\'t have write'
519
                       'access to this file). Subsequent requests won\'t use a '
520
                       'cached token meaning they may be slower.' % (cached_token_path,
521
                                                                     os.getlogin()))
522
            LOG.warn(message)
523
            return None
524
525
        token = token_obj.token
526
        expire_timestamp = parse_isotime(token_obj.expiry)
527
        expire_timestamp = calendar.timegm(expire_timestamp.timetuple())
528
529
        data = {}
530
        data['token'] = token
531
        data['expire_timestamp'] = expire_timestamp
532
        data = json.dumps(data)
533
534
        # Note: We explictly use fdopen instead of open + chmod to avoid a security issue.
535
        # open + chmod are two operations which means that during a short time frame (between
536
        # open and chmod) when file can potentially be read by other users if the default
537
        # permissions used during create allow that.
538
        fd = os.open(cached_token_path, os.O_WRONLY | os.O_CREAT, 0600)
539
        with os.fdopen(fd, 'w') as fp:
540
            fp.write(data)
541
542
        LOG.debug('Token has been cached in "%s"' % (cached_token_path))
543
        return True
544
545
    def _authenticate_and_retrieve_auth_token(self, client, username, password):
546
        manager = models.ResourceManager(models.Token, client.endpoints['auth'],
547
                                         cacert=client.cacert, debug=client.debug)
548
        instance = models.Token()
549
        instance = manager.create(instance, auth=(username, password))
550
        return instance
551
552
    def _get_cached_token_path_for_user(self, username):
553
        """
554
        Retrieve cached token path for the provided username.
555
        """
556
        file_name = 'token-%s' % (username)
557
        result = os.path.abspath(os.path.join(ST2_CONFIG_DIRECTORY, file_name))
558
        return result
559
560
    def _get_config_file_path(self, args):
561
        """
562
        Retrieve path to the CLI configuration file.
563
564
        :rtype: ``str``
565
        """
566
        path = os.environ.get('ST2_CONFIG_FILE', ST2_CONFIG_PATH)
567
568
        if args.config_file:
569
            path = args.config_file
570
571
        path = os.path.abspath(path)
572
        if path != ST2_CONFIG_PATH and not os.path.isfile(path):
573
            raise ValueError('Config "%s" not found' % (path))
574
575
        return path
576
577
    def _parse_config_file(self, args):
578
        config_file_path = self._get_config_file_path(args=args)
579
580
        parser = CLIConfigParser(config_file_path=config_file_path, validate_config_exists=False)
581
        result = parser.parse()
582
        return result
583
584
    def _get_config_file_options(self, args):
585
        """
586
        Parse the config and return kwargs which can be passed to the Client
587
        constructor.
588
589
        :rtype: ``dict``
590
        """
591
        rc_options = self._parse_config_file(args=args)
592
593
        result = {}
594
        for kwarg_name, (section, option) in six.iteritems(CONFIG_OPTION_TO_CLIENT_KWARGS_MAP):
595
            result[kwarg_name] = rc_options.get(section, {}).get(option, None)
596
597
        return result
598
599
600
def setup_logging(argv):
601
    debug = '--debug' in argv
602
603
    root = LOG
604
    root.setLevel(logging.WARNING)
605
606
    handler = logging.StreamHandler(sys.stderr)
607
    handler.setLevel(logging.WARNING)
608
    formatter = logging.Formatter('%(asctime)s  %(levelname)s - %(message)s')
609
    handler.setFormatter(formatter)
610
611
    if not debug:
612
        handler.addFilter(LogLevelFilter(log_levels=[logging.ERROR]))
613
614
    root.addHandler(handler)
615
616
617
def main(argv=sys.argv[1:]):
618
    setup_logging(argv)
619
    return Shell().run(argv)
620
621
622
if __name__ == '__main__':
623
    sys.exit(main(sys.argv[1:]))
624