Passed
Push — master ( 97ccf0...a6290c )
by
unknown
03:27
created

BaseCLIApp   B

Complexity

Total Complexity 47

Size/Duplication

Total Lines 325
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
dl 0
loc 325
rs 8.439
c 0
b 0
f 0
wmc 47

12 Methods

Rating   Name   Duplication   Size   Complexity  
B _get_auth_token() 0 26 3
A _print_config() 0 8 3
A _authenticate_and_retrieve_auth_token() 0 6 1
A _print_debug_info() 0 6 1
D _get_cached_auth_token() 0 63 9
A _get_config_file_path() 0 16 4
A _get_config_file_options() 0 13 2
A _parse_config_file() 0 6 1
A _get_cached_token_path_for_user() 0 7 1
B _cache_auth_token() 0 50 6
F get_client() 0 78 14
B _print_client_settings() 0 24 2

How to fix   Complexity   

Complex Class

Complex classes like BaseCLIApp 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
import os
17
import pwd
18
import json
19
import logging
20
import time
21
import calendar
22
import traceback
23
24
import six
25
import requests
26
27
from st2client import models
28
from st2client.config_parser import CLIConfigParser
29
from st2client.config_parser import ST2_CONFIG_DIRECTORY
30
from st2client.config_parser import ST2_CONFIG_PATH
31
from st2client.client import Client
32
from st2client.config import get_config
33
from st2client.utils.date import parse as parse_isotime
34
from st2client.utils.misc import merge_dicts
35
36
__all__ = [
37
    'BaseCLIApp'
38
]
39
40
# Fix for "os.getlogin()) OSError: [Errno 2] No such file or directory"
41
os.getlogin = lambda: pwd.getpwuid(os.getuid())[0]
42
43
# How many seconds before the token actual expiration date we should consider the token as
44
# expired. This is used to prevent the operation from failing durig the API request because the
45
# token was just about to expire.
46
TOKEN_EXPIRATION_GRACE_PERIOD_SECONDS = 15
47
48
CONFIG_OPTION_TO_CLIENT_KWARGS_MAP = {
49
    'base_url': ['general', 'base_url'],
50
    'auth_url': ['auth', 'url'],
51
    'stream_url': ['stream', 'url'],
52
    'api_url': ['api', 'url'],
53
    'api_version': ['general', 'api_version'],
54
    'api_key': ['credentials', 'api_key'],
55
    'cacert': ['general', 'cacert'],
56
    'debug': ['cli', 'debug']
57
}
58
59
60
class BaseCLIApp(object):
61
    """
62
    Base class for StackStorm CLI apps.
63
    """
64
65
    LOG = logging.getLogger(__name__)  # logger instance to use
66
    client = None  # st2client instance
67
68
    # A list of command classes for which automatic authentication should be skipped.
69
    SKIP_AUTH_CLASSES = []
70
71
    def get_client(self, args, debug=False):
72
        ST2_CLI_SKIP_CONFIG = os.environ.get('ST2_CLI_SKIP_CONFIG', 0)
73
        ST2_CLI_SKIP_CONFIG = int(ST2_CLI_SKIP_CONFIG)
74
75
        skip_config = args.skip_config
76
        skip_config = skip_config or ST2_CLI_SKIP_CONFIG
77
78
        # Note: Options provided as the CLI argument have the highest precedence
79
        # Precedence order: cli arguments > environment variables > rc file variables
80
        cli_options = ['base_url', 'auth_url', 'api_url', 'stream_url', 'api_version', 'cacert']
81
        cli_options = {opt: getattr(args, opt, None) for opt in cli_options}
82
        config_file_options = self._get_config_file_options(args=args)
83
84
        kwargs = {}
85
86
        if not skip_config:
87
            # Config parsing is not skipped
88
            kwargs = merge_dicts(kwargs, config_file_options)
89
90
        kwargs = merge_dicts(kwargs, cli_options)
91
        kwargs['debug'] = debug
92
93
        client = Client(**kwargs)
94
95
        if skip_config:
96
            # Config parsing is skipped
97
            self.LOG.info('Skipping parsing CLI config')
98
            return client
99
100
        # Ok to use config at this point
101
        rc_config = get_config()
102
103
        # Silence SSL warnings
104
        silence_ssl_warnings = rc_config.get('general', {}).get('silence_ssl_warnings', False)
105
        if silence_ssl_warnings:
106
            requests.packages.urllib3.disable_warnings()
107
108
        # We skip automatic authentication for some commands such as auth
109
        try:
110
            command_class_name = args.func.im_class.__name__
111
        except Exception:
112
            command_class_name = None
113
114
        if command_class_name in self.SKIP_AUTH_CLASSES:
115
            return client
116
117
        # We also skip automatic authentication if token is provided via the environment variable
118
        # or as a command line argument
119
        env_var_token = os.environ.get('ST2_AUTH_TOKEN', None)
120
        cli_argument_token = getattr(args, 'token', None)
121
        env_var_api_key = os.environ.get('ST2_API_KEY', None)
122
        cli_argument_api_key = getattr(args, 'api_key', None)
123
        if env_var_token or cli_argument_token or env_var_api_key or cli_argument_api_key:
124
            return client
125
126
        # If credentials are provided in the CLI config use them and try to authenticate
127
        credentials = rc_config.get('credentials', {})
128
        username = credentials.get('username', None)
129
        password = credentials.get('password', None)
130
        cache_token = rc_config.get('cli', {}).get('cache_token', False)
131
132
        if username:
133
            # Credentials are provided, try to authenticate agaist the API
134
            try:
135
                token = self._get_auth_token(client=client, username=username, password=password,
136
                                             cache_token=cache_token)
137
            except requests.exceptions.ConnectionError as e:
138
                self.LOG.warn('Auth API server is not available, skipping authentication.')
139
                self.LOG.exception(e)
140
                return client
141
            except Exception as e:
142
                print('Failed to authenticate with credentials provided in the config.')
143
                raise e
144
            client.token = token
145
            # TODO: Hack, refactor when splitting out the client
146
            os.environ['ST2_AUTH_TOKEN'] = token
147
148
        return client
149
150
    def _get_config_file_options(self, args):
151
        """
152
        Parse the config and return kwargs which can be passed to the Client
153
        constructor.
154
155
        :rtype: ``dict``
156
        """
157
        rc_options = self._parse_config_file(args=args)
158
        result = {}
159
        for kwarg_name, (section, option) in six.iteritems(CONFIG_OPTION_TO_CLIENT_KWARGS_MAP):
160
            result[kwarg_name] = rc_options.get(section, {}).get(option, None)
161
162
        return result
163
164
    def _parse_config_file(self, args):
165
        config_file_path = self._get_config_file_path(args=args)
166
167
        parser = CLIConfigParser(config_file_path=config_file_path, validate_config_exists=False)
168
        result = parser.parse()
169
        return result
170
171
    def _get_config_file_path(self, args):
172
        """
173
        Retrieve path to the CLI configuration file.
174
175
        :rtype: ``str``
176
        """
177
        path = os.environ.get('ST2_CONFIG_FILE', ST2_CONFIG_PATH)
178
179
        if args.config_file:
180
            path = args.config_file
181
182
        path = os.path.abspath(os.path.expanduser(path))
183
        if path != ST2_CONFIG_PATH and not os.path.isfile(path):
184
            raise ValueError('Config "%s" not found' % (path))
185
186
        return path
187
188
    def _get_auth_token(self, client, username, password, cache_token):
189
        """
190
        Retrieve a valid auth token.
191
192
        If caching is enabled, we will first try to retrieve cached token from a
193
        file system. If cached token is expired or not available, we will try to
194
        authenticate using the provided credentials and retrieve a new auth
195
        token.
196
197
        :rtype: ``str``
198
        """
199
        if cache_token:
200
            token = self._get_cached_auth_token(client=client, username=username,
201
                                                password=password)
202
        else:
203
            token = None
204
        if not token:
205
            # Token is either expired or not available
206
            token_obj = self._authenticate_and_retrieve_auth_token(client=client,
207
                                                                   username=username,
208
                                                                   password=password)
209
210
            self._cache_auth_token(token_obj=token_obj)
211
            token = token_obj.token
212
213
        return token
214
215
    def _get_cached_auth_token(self, client, username, password):
216
        """
217
        Retrieve cached auth token from the file in the config directory.
218
219
        :rtype: ``str``
220
        """
221
        if not os.path.isdir(ST2_CONFIG_DIRECTORY):
222
            os.makedirs(ST2_CONFIG_DIRECTORY)
223
224
        cached_token_path = self._get_cached_token_path_for_user(username=username)
225
226
        if not os.access(ST2_CONFIG_DIRECTORY, os.R_OK):
227
            # We don't have read access to the file with a cached token
228
            message = ('Unable to retrieve cached token from "%s" (user %s doesn\'t have read '
229
                       'access to the parent directory). Subsequent requests won\'t use a '
230
                       'cached token meaning they may be slower.' % (cached_token_path,
231
                                                                     os.getlogin()))
232
            self.LOG.warn(message)
233
            return None
234
235
        if not os.path.isfile(cached_token_path):
236
            return None
237
238
        if not os.access(cached_token_path, os.R_OK):
239
            # We don't have read access to the file with a cached token
240
            message = ('Unable to retrieve cached token from "%s" (user %s doesn\'t have read '
241
                       'access to this file). Subsequent requests won\'t use a cached token '
242
                       'meaning they may be slower.' % (cached_token_path, os.getlogin()))
243
            self.LOG.warn(message)
244
            return None
245
246
        # Safety check for too permissive permissions
247
        file_st_mode = oct(os.stat(cached_token_path).st_mode & 0777)
248
        others_st_mode = int(file_st_mode[-1])
249
250
        if others_st_mode >= 4:
251
            # Every user has access to this file which is dangerous
252
            message = ('Permissions (%s) for cached token file "%s" are to permissive. Please '
253
                       'restrict the permissions and make sure only your own user can read '
254
                       'from the file' % (file_st_mode, cached_token_path))
255
            self.LOG.warn(message)
256
257
        with open(cached_token_path) as fp:
258
            data = fp.read()
259
260
        try:
261
            data = json.loads(data)
262
263
            token = data['token']
264
            expire_timestamp = data['expire_timestamp']
265
        except Exception as e:
266
            msg = ('File "%s" with cached token is corrupted or invalid (%s). Please delete '
267
                   ' this file' % (cached_token_path, str(e)))
268
            raise ValueError(msg)
269
270
        now = int(time.time())
271
        if (expire_timestamp - TOKEN_EXPIRATION_GRACE_PERIOD_SECONDS) < now:
272
            self.LOG.debug('Cached token from file "%s" has expired' % (cached_token_path))
273
            # Token has expired
274
            return None
275
276
        self.LOG.debug('Using cached token from file "%s"' % (cached_token_path))
277
        return token
278
279
    def _cache_auth_token(self, token_obj):
280
        """
281
        Cache auth token in the config directory.
282
283
        :param token_obj: Token object.
284
        :type token_obj: ``object``
285
        """
286
        if not os.path.isdir(ST2_CONFIG_DIRECTORY):
287
            os.makedirs(ST2_CONFIG_DIRECTORY)
288
289
        username = token_obj.user
290
        cached_token_path = self._get_cached_token_path_for_user(username=username)
291
292
        if not os.access(ST2_CONFIG_DIRECTORY, os.W_OK):
293
            # We don't have write access to the file with a cached token
294
            message = ('Unable to write token to "%s" (user %s doesn\'t have write '
295
                       'access to the parent directory). Subsequent requests won\'t use a '
296
                       'cached token meaning they may be slower.' % (cached_token_path,
297
                                                                     os.getlogin()))
298
            self.LOG.warn(message)
299
            return None
300
301
        if os.path.isfile(cached_token_path) and not os.access(cached_token_path, os.W_OK):
302
            # We don't have write access to the file with a cached token
303
            message = ('Unable to write token to "%s" (user %s doesn\'t have write '
304
                       'access to this file). Subsequent requests won\'t use a '
305
                       'cached token meaning they may be slower.' % (cached_token_path,
306
                                                                     os.getlogin()))
307
            self.LOG.warn(message)
308
            return None
309
310
        token = token_obj.token
311
        expire_timestamp = parse_isotime(token_obj.expiry)
312
        expire_timestamp = calendar.timegm(expire_timestamp.timetuple())
313
314
        data = {}
315
        data['token'] = token
316
        data['expire_timestamp'] = expire_timestamp
317
        data = json.dumps(data)
318
319
        # Note: We explictly use fdopen instead of open + chmod to avoid a security issue.
320
        # open + chmod are two operations which means that during a short time frame (between
321
        # open and chmod) when file can potentially be read by other users if the default
322
        # permissions used during create allow that.
323
        fd = os.open(cached_token_path, os.O_WRONLY | os.O_CREAT, 0600)
324
        with os.fdopen(fd, 'w') as fp:
325
            fp.write(data)
326
327
        self.LOG.debug('Token has been cached in "%s"' % (cached_token_path))
328
        return True
329
330
    def _authenticate_and_retrieve_auth_token(self, client, username, password):
331
        manager = models.ResourceManager(models.Token, client.endpoints['auth'],
332
                                         cacert=client.cacert, debug=client.debug)
333
        instance = models.Token()
334
        instance = manager.create(instance, auth=(username, password))
335
        return instance
336
337
    def _get_cached_token_path_for_user(self, username):
338
        """
339
        Retrieve cached token path for the provided username.
340
        """
341
        file_name = 'token-%s' % (username)
342
        result = os.path.abspath(os.path.join(ST2_CONFIG_DIRECTORY, file_name))
343
        return result
344
345
    def _print_config(self, args):
346
        config = self._parse_config_file(args=args)
347
348
        for section, options in six.iteritems(config):
349
            print('[%s]' % (section))
350
351
            for name, value in six.iteritems(options):
352
                print('%s = %s' % (name, value))
353
354
    def _print_debug_info(self, args):
355
        # Print client settings
356
        self._print_client_settings(args=args)
357
358
        # Print exception traceback
359
        traceback.print_exc()
360
361
    def _print_client_settings(self, args):
362
        client = self.client
363
364
        if not client:
365
            return
366
367
        config_file_path = self._get_config_file_path(args=args)
368
369
        print('CLI settings:')
370
        print('----------------')
371
        print('Config file path: %s' % (config_file_path))
372
        print('Client settings:')
373
        print('----------------')
374
        print('ST2_BASE_URL: %s' % (client.endpoints['base']))
375
        print('ST2_AUTH_URL: %s' % (client.endpoints['auth']))
376
        print('ST2_API_URL: %s' % (client.endpoints['api']))
377
        print('ST2_STREAM_URL: %s' % (client.endpoints['stream']))
378
        print('ST2_AUTH_TOKEN: %s' % (os.environ.get('ST2_AUTH_TOKEN')))
379
        print('')
380
        print('Proxy settings:')
381
        print('---------------')
382
        print('HTTP_PROXY: %s' % (os.environ.get('HTTP_PROXY', '')))
383
        print('HTTPS_PROXY: %s' % (os.environ.get('HTTPS_PROXY', '')))
384
        print('')
385