File: utils.py

package info (click to toggle)
ceph-iscsi 3.9-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 792 kB
  • sloc: python: 9,236; makefile: 23
file content (517 lines) | stat: -rw-r--r-- 17,948 bytes parent folder | download | duplicates (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
import requests
from requests import Response
import sys
import re
import os
import subprocess


from rtslib_fb.utils import normalize_wwn, RTSLibError

from ceph_iscsi_config.client import GWClient
import ceph_iscsi_config.settings as settings
from ceph_iscsi_config.utils import (resolve_ip_addresses, CephiSCSIError,
                                     this_host)

__author__ = 'Paul Cuzner'


class Colors(object):

    map = {'green': '\x1b[32;1m',
           'red': '\x1b[31;1m',
           'yellow': '\x1b[33;1m',
           'blue': '\x1b[34;1m'}


def readcontents(filename):
    with open(filename, 'r') as input_file:
        content = input_file.read().rstrip()
    return content


def get_config():
    """
    use the /config api to return the current gateway configuration
    :return: (dict) of the config object
    """

    http_mode = "https" if settings.config.api_secure else "http"
    api_rqst = "{}://localhost:{}/api/config".format(http_mode,
                                                     settings.config.api_port)
    api = APIRequest(api_rqst)
    api.get()

    if api.response.status_code == 200:
        try:
            return api.response.json()
        except Exception:
            pass

    return {}


def valid_gateway(target_iqn, gw_name, gw_ips, config):
    """
    validate the request for a new gateway
    :param gw_name: (str) host (shortname) of the gateway
    :param gw_ips: (str) ip addresses on the gw that will be used for iSCSI
    :param config: (dict) current config
    :return: (str) "ok" or error description
    """

    http_mode = 'https' if settings.config.api_secure else "http"

    # if the gateway request already exists in the config, computer says "no"
    target_config = config['targets'][target_iqn]
    if gw_name in target_config['portals']:
        return "Gateway name {} already defined".format(gw_name)

    for gw_ip in gw_ips:
        if gw_ip in target_config.get('ip_list', []):
            return "IP address already defined to the configuration"

    # validate the gateway name is resolvable
    if not resolve_ip_addresses(gw_name):
        return ("Gateway '{}' is not resolvable to an IP address".format(gw_name))

    # validate the ip_address is valid ip
    for gw_ip in gw_ips:
        if not resolve_ip_addresses(gw_ip):
            return ("IP address provided is not usable (name doesn't"
                    " resolve, or not a valid IPv4/IPv6 address)")

    # At this point the request seems reasonable, so lets check a bit deeper

    gw_api = '{}://{}:{}/api'.format(http_mode,
                                     gw_name,
                                     settings.config.api_port)

    # check the intended host actually has the requested IP available
    api = APIRequest(gw_api + '/sysinfo/ip_addresses')
    api.get()

    if api.response.status_code != 200:
        return ("ip_addresses query to {} failed - check "
                "rbd-target-api log. Is the API server "
                "running and in the right mode (http/https)?".format(gw_name))

    try:
        target_ips = api.response.json()['data']
    except Exception:
        return "Malformed REST API response"

    for gw_ip in gw_ips:
        if gw_ip not in target_ips:
            return ("IP address of {} is not available on {}. Valid "
                    "IPs are :{}".format(gw_ip,
                                         gw_name,
                                         ','.join(target_ips)))

    # check that config file on the new gateway matches the local machine
    api = APIRequest(gw_api + '/sysinfo/checkconf')
    api.get()
    if api.response.status_code != 200:
        return ("checkconf API call to {} failed with "
                "code {}".format(gw_name, api.response.status_code))

    # compare the hash of the new gateways conf file with the local one
    local_hash = settings.config.hash()
    try:
        remote_hash = str(api.response.json()['data'])
    except Exception:
        remote_hash = None

    if local_hash != remote_hash:
        return ("/etc/ceph/iscsi-gateway.cfg on {} does "
                "not match the local version. Correct and "
                "retry request".format(gw_name))

    # Check for package version dependencies
    api = APIRequest(gw_api + '/sysinfo/checkversions')
    api.get()
    if api.response.status_code != 200:
        try:
            errors = api.response.json()['data']
        except Exception:
            return "Malformed REST API response"

        return ("{} failed package validation checks - "
                "{}".format(gw_name,
                            ','.join(errors)))

    # At this point the gateway seems valid
    return "ok"


def get_remote_gateways(config, logger, local_gw_required=True):
    """
    Return the list of remote gws.
    :param: config: Config object with gws setup.
    :param: logger: Logger object
    :param: local_gw_required: Check if local_gw is defined within gateways configuration
    :return: A list of gw names, or CephiSCSIError if not run on a gw in the
             config
    """

    local_gw = this_host()
    logger.debug("this host is {}".format(local_gw))
    gateways = [key for key in config
                if isinstance(config[key], dict)]
    logger.debug("all gateways - {}".format(gateways))
    if local_gw_required and local_gw not in gateways:
        raise CephiSCSIError("{} cannot be used to perform this operation "
                             "because it is not defined within the gateways "
                             "configuration".format(local_gw))
    if local_gw in gateways:
        gateways.remove(local_gw)
    logger.debug("remote gateways: {}".format(gateways))
    return gateways


def valid_credentials(username, password, mutual_username, mutual_password):
    """
    Returns `None` if credentials are acceptable, otherwise return an error message

    username / mutual_username is 8-64 chars long containing any alphanumeric in
    [0-9a-zA-Z] and '.' ':' '@' '_' '-'

    password / mutual_password is 12-16 chars long containing any alphanumeric in
    [0-9a-zA-Z] and '@' '-' '_' '/'
    """

    usr_regex = re.compile(r"^[\w\\.\:\@\_\-]{8,64}$")
    pw_regex = re.compile(r"^[\w\@\-\_\/]{12,16}$")

    if username and not password:
        return 'Password is required'

    if not username and (password or mutual_username):
        return 'Username is required'

    if mutual_username and not mutual_password:
        return 'Mutual password is required'

    if not mutual_username and mutual_password:
        return 'Mutual username is required'

    if username and len(username) < 8:
        return 'Minimum length of username is 8 characters'

    if username and len(username) > 64:
        return 'Maximum length of username is 64 characters'

    if username and not usr_regex.search(username):
        return 'Invalid username'

    if mutual_username and len(mutual_username) < 8:
        return 'Minimum length of mutual username is 8 characters'

    if mutual_username and len(mutual_username) > 64:
        return 'Maximum length of mutual username is 64 characters'

    if mutual_username and not usr_regex.search(mutual_username):
        return 'Invalid mutual username'

    if password and len(password) < 12:
        return 'Minimum length of password is 12 characters'

    if password and len(password) > 16:
        return 'Maximum length of password is 16 characters'

    if password and not pw_regex.search(password):
        return 'Invalid password'

    if mutual_password and len(mutual_password) < 12:
        return 'Minimum length of mutual password is 12 characters'

    if mutual_password and len(mutual_password) > 16:
        return 'Maximum length of mutual password is 16 characters'

    if mutual_password and not pw_regex.search(mutual_password):
        return 'Invalid mutual password'

    return None


def valid_client(**kwargs):
    """
    validate a client create or update request, based on mode.
    :param kwargs: 'mode' is the key field used to determine process flow
    :return: 'ok' or an error description (str)
    """

    valid_modes = ['create', 'delete', 'auth', 'disk']
    parms_passed = set(kwargs.keys())

    if 'mode' in kwargs:
        if kwargs['mode'] not in valid_modes:
            return ("Invalid client validation mode request - "
                    "asked for {}, available {}".format(kwargs['mode'],
                                                        valid_modes))
    else:
        return "Invalid call to valid_client - mode is needed"

    # at this point we have a mode to work with

    mode = kwargs['mode']
    client_iqn = kwargs['client_iqn']
    target_iqn = kwargs['target_iqn']
    config = get_config()
    if not config:
        return "Unable to query the local API for the current config"
    target_config = config['targets'][target_iqn]

    if mode == 'create':
        # iqn must be valid
        try:
            normalize_wwn(['iqn'], client_iqn)
        except RTSLibError:
            return ("Invalid IQN name for iSCSI")

        # iqn must not already exist
        if client_iqn in target_config['clients']:
            return ("A client with the name '{}' is "
                    "already defined".format(client_iqn))

        # Mixing TPG/target auth with ACL is not supported
        target_username = target_config['auth']['username']
        target_password = target_config['auth']['password']
        target_auth_enabled = (target_username and target_password)
        if target_auth_enabled:
            return "Cannot create client because target CHAP authentication is enabled"

        # Creates can only be done with a minimum number of gw's in place
        num_gws = len([gw_name for gw_name in config['gateways']
                       if isinstance(config['gateways'][gw_name], dict)])
        if num_gws < settings.config.minimum_gateways:
            return ("Clients can not be defined until a HA configuration "
                    "has been defined "
                    "(>{} gateways)".format(settings.config.minimum_gateways))

        # at this point pre-req's look good
        return 'ok'

    elif mode == 'delete':

        # client must exist in the configuration
        if client_iqn not in target_config['clients']:
            return ("{} is not defined yet - nothing to "
                    "delete".format(client_iqn))

        this_client = target_config['clients'].get(client_iqn)
        if this_client.get('group_name', None):
            return ("Unable to delete '{}' - it belongs to "
                    "group {}".format(client_iqn,
                                      this_client.get('group_name')))

        # client to delete must not be logged in - we're just checking locally,
        # since *all* nodes are set up the same, and a client login request
        # would normally login to each gateway
        client_info = GWClient.get_client_info(target_iqn, client_iqn)
        if client_info['state'] == 'LOGGED_IN':
            return ("Client '{}' is logged in to {}- unable to delete until"
                    " it's logged out".format(client_iqn, target_iqn))

        # at this point, the client looks ok for a DELETE operation
        return 'ok'

    elif mode == 'auth':
        # client iqn must exist
        if client_iqn not in target_config['clients']:
            return ("Client '{}' does not exist".format(client_iqn))

        username = kwargs['username']
        password = kwargs['password']
        mutual_username = kwargs['mutual_username']
        mutual_password = kwargs['mutual_password']

        error_msg = valid_credentials(username, password, mutual_username, mutual_password)
        if error_msg:
            return error_msg

        return 'ok'

    elif mode == 'disk':

        this_client = target_config['clients'].get(client_iqn)
        if this_client.get('group_name', None):
            return ("Unable to manage disks for '{}' - it belongs to "
                    "group {}".format(client_iqn,
                                      this_client.get('group_name')))

        if 'image_list' not in parms_passed:
            return ("Disk changes require 'image_list' to be set, containing"
                    " a comma separated str of rbd images (pool/image)")

        rqst_disks = set(kwargs['image_list'].split(','))
        mapped_disks = set(target_config['clients'][client_iqn]['luns'].keys())
        current_disks = set(config['disks'].keys())

        if len(rqst_disks) > len(mapped_disks):
            # this is an add operation

            # ensure the image list is 'complete' not just a single disk
            if not mapped_disks.issubset(rqst_disks):
                return ("Invalid image list - it must contain existing "
                        "disks AND any additions")

            # ensure new disk(s) exist - must yield a result since rqst>mapped
            new_disks = rqst_disks.difference(mapped_disks)
            if not new_disks.issubset(current_disks):
                # disks provided are not currently defined
                return ("Invalid image list - it defines new disks that do "
                        "not current exist")

            return 'ok'

        else:

            # this is a disk removal operation
            if kwargs['image_list']:
                if not rqst_disks.issubset(mapped_disks):
                    return ("Invalid image list ({})".format(rqst_disks))

            return 'ok'

    return 'Unknown error in valid_client function'


def valid_snapshot_name(name):
    regex = re.compile("^[^/@]+$")
    if not regex.search(name):
        return False
    return True


def refresh_control_values(control_values, controls, def_settings):
    for key, setting in def_settings.items():
        val = controls.get(setting.name)
        if val is not None:
            # config values may be normalized or raw
            val = setting.to_str(setting.normalize(val))

        def_val = setting.to_str(getattr(settings.config, key))

        if val is None or val == def_val:
            control_values[setting.name] = def_val
        else:
            control_values[setting.name] = "{} (override)".format(val)


class GatewayError(Exception):
    pass


class GatewayAPIError(GatewayError):
    pass


class GatewayLIOError(GatewayError):
    pass


class APIRequest(object):

    def __init__(self, *args, **kwargs):
        self.args = args
        self.kwargs = kwargs

        # Establish defaults for the API connection
        if 'auth' not in self.kwargs:
            self.kwargs['auth'] = (settings.config.api_user,
                                   settings.config.api_password)
        if 'verify' not in self.kwargs:
            self.kwargs['verify'] = settings.config.api_ssl_verify

        self.http_methods = ['get', 'put', 'delete']
        self.data = None

    def _get_response(self):
        return self.data

    def __getattr__(self, name):
        if name in self.http_methods:
            request_method = getattr(requests, name)
            try:
                self.data = request_method(*self.args, **self.kwargs)
            except requests.ConnectionError:
                msg = ("Unable to connect to api endpoint @ "
                       "{}".format(self.args[0]))
                self.data = Response()
                self.data.status_code = 500
                self.data._content = '{{"message": "{}" }}'.format(msg).encode('utf-8')
                return self._get_response
            except Exception:
                raise GatewayAPIError("Unknown error connecting to "
                                      "{}".format(self.args[0]))
            else:
                # since the attribute is a callable, we must return with
                # a callable
                return self._get_response
        raise AttributeError()

    response = property(_get_response,
                        doc="get http response output")


def progress_message(text, color='green'):

    sys.stdout.write("{}{}{}\r".format(Colors.map[color],
                                       text,
                                       '\x1b[0m'))
    sys.stdout.flush()


def console_message(text, color='green'):

    color_needed = getattr(settings.config, 'interactive', True)

    if color_needed:
        print("{}{}{}".format(Colors.map[color],
                              text,
                              '\x1b[0m'))
    else:
        print(text)


def cmd_exists(command):
    return any(
        os.access(os.path.join(path, command), os.X_OK)
        for path in os.environ["PATH"].split(os.pathsep)
    )


def os_cmd(command):
    """
    Issue a command to the OS and return the output. NB. check_output default
    is shell=False
    :param command: (str) OS command
    :return: (str) command response (lines terminated with \n)
    """
    cmd_list = command.split(' ')
    if cmd_exists(cmd_list[0]):
        cmd_output = subprocess.check_output(cmd_list,
                                             stderr=subprocess.STDOUT).rstrip()
        return cmd_output
    else:
        return ''


def response_message(response, logger=None):
    """
    Attempts to retrieve the "message" value from a JSON-encoded response
    message. If the JSON fails to parse, the response will be returned
    as-is.
    :param response: (requests.Response) response
    :param logger: optional logger
    :return: (str) response message
    """
    try:
        return response.json()['message']
    except Exception:
        if logger:
            logger.debug("Failed API request: {} {}\n{}".format(response.request.method,
                                                                response.request.url,
                                                                response.text))
        return "{} {}".format(response.status_code, response.reason)