File: utils.py

package info (click to toggle)
python-haproxyadmin 0.2.4-3
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 436 kB
  • sloc: python: 1,500; makefile: 165; sh: 1
file content (640 lines) | stat: -rw-r--r-- 18,662 bytes parent folder | download
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
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
# pylint: disable=superfluous-parens
#
"""
haproxyadmin.utils
~~~~~~~~~~~~~~~~~~

This module provides utility functions and classes that are used within
haproxyadmin.

"""

import socket
import os
import stat
from functools import wraps
import six
import re

from haproxyadmin.exceptions import (CommandFailed, MultipleCommandResults,
                                     IncosistentData)
from haproxyadmin.command_status import (ERROR_OUTPUT_STRINGS,
        SUCCESS_OUTPUT_STRINGS, SUCCESS_STRING_PORT, SUCCESS_STRING_ADDRESS)

METRICS_SUM = [
    'CompressBpsIn',
    'CompressBpsOut',
    'CompressBpsRateLim',
    'ConnRate',
    'ConnRateLimit',
    'CumConns',
    'CumReq',
    'CumSslConns',
    'CurrConns',
    'CurrSslConns',
    'Hard_maxconn',
    'Idle_pct',
    'MaxConnRate',
    'MaxSessRate',
    'MaxSslConns',
    'MaxSslRate',
    'MaxZlibMemUsage',
    'Maxconn',
    'Maxpipes',
    'Maxsock',
    'Memmax_MB',
    'PipesFree',
    'PipesUsed',
    'Process_num',
    'Run_queue',
    'SessRate',
    'SessRateLimit',
    'SslBackendKeyRate',
    'SslBackendMaxKeyRate',
    'SslCacheLookups',
    'SslCacheMisses',
    'SslFrontendKeyRate',
    'SslFrontendMaxKeyRate',
    'SslFrontendSessionReuse_pct',
    'SslRate',
    'SslRateLimit',
    'Tasks',
    'Ulimit-n',
    'ZlibMemUsage',
    'bin',
    'bout',
    'chkdown',
    'chkfail',
    'comp_byp',
    'comp_in',
    'comp_out',
    'comp_rsp',
    'cli_abrt',
    'dreq',
    'dresp',
    'ereq',
    'eresp',
    'econ',
    'hrsp_1xx',
    'hrsp_2xx',
    'hrsp_3xx',
    'hrsp_4xx',
    'hrsp_5xx',
    'hrsp_other',
    'lbtot',
    'qcur',
    'qmax',
    'rate',
    'rate_lim',
    'rate_max',
    'req_rate',
    'req_rate_max',
    'req_tot',
    'scur',
    'slim',
    'srv_abrt',
    'smax',
    'stot',
    'wretr',
    'wredis',
]

METRICS_AVG = [
    'act',
    'bck',
    'check_duration',
    'ctime',
    'downtime',
    'lastchg',
    'lastsess',
    'qlimit',
    'qtime',
    'rtime',
    'throttle',
    'ttime',
    'weight',
]


def should_die(old_implementation):
    """Build a decorator to control exceptions.

    When a function raises an exception in some cases we don't care for the
    reason but only if the function run successfully or not. We add an extra
    argument to the decorated function with the name ``die`` to control this
    behavior. When it is set to ``True``, which is the default value, it
    raises any exception raised by the decorated function. When it is set to
    ``False`` it returns ``True`` if decorated function run successfully or
    ``False`` if an exception was raised.
    """
    @wraps(old_implementation)
    def new_implementation(*args, **kwargs):
        try:
            die = kwargs['die']
            del(kwargs['die'])
        except KeyError:
            die = True

        try:
            rv = old_implementation(*args, **kwargs)
            return rv
        except Exception as error:
            if die:
                raise error
            else:
                return False

    return new_implementation


def is_unix_socket(path):
    """Return ``True`` if path is a valid UNIX socket otherwise False.

    :param path: file name path
    :type path: ``string``
    :rtype: ``bool``
    """
    mode = os.stat(path).st_mode

    return stat.S_ISSOCK(mode)

def connected_socket(path, timeout):
    """Check if socket file is a valid HAProxy socket file.

    We send a 'show info' command to the socket, build a dictionary structure
    and check if 'Name' key is present in the dictionary to confirm that
    there is a HAProxy process connected to it.

    :param path: file name path
    :type path: ``string``
    :param timeout: timeout for the connection, in seconds
    :type timeout: ``float``
    :return: ``True`` is socket file is a valid HAProxy stats socket file False
      otherwise
    :rtype: ``bool``
    """
    try:
        unix_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        unix_socket.settimeout(timeout)
        unix_socket.connect(path)
        unix_socket.send(six.b('show info' + '\n'))
        file_handle = unix_socket.makefile()
    except (socket.timeout, OSError):
        return False
    else:
        try:
            data = file_handle.read().splitlines()
        except (socket.timeout, OSError):
            return False
        else:
            hap_info = info2dict(data)
    finally:
        unix_socket.close()

    try:
        return hap_info['Name'] in ['HAProxy', 'hapee-lb']
    except KeyError:
        return False


def cmd_across_all_procs(hap_objects, method, *arg, **kargs):
    """Return the result of a command executed in all HAProxy process.

    .. note::
        Objects must have a property with the name 'process_nb' which
        returns the HAProxy process number.

    :param hap_objects: a list of objects.
    :type hap_objects: ``list``
    :param method: a valid method for the objects.
    :return: list of 2-item tuple

      #. HAProxy process number
      #. what the method returned

    :rtype: ``list``
    """
    results = []
    for obj in hap_objects:
        results.append(
            (getattr(obj, 'process_nb'), getattr(obj, method)(*arg, **kargs))
        )

    return results


def elements_of_list_same(iterator):
    """Check is all elements of an iterator are equal.

    :param iterator: a iterator
    :type iterator: ``list``
    :rtype: ``bool``

    Usage::

      >>> from haproxyadmin import utils
      >>> iterator = ['OK', 'ok']
      >>> utils.elements_of_list_same(iterator)
      False
      >>> iterator = ['OK', 'OK']
      >>> utils.elements_of_list_same(iterator)
      True
      >>> iterator = [22, 22, 22]
      >>> utils.elements_of_list_same(iterator)
      True
      >>> iterator = [22, 22, 222]
      >>> utils.elements_of_list_same(iterator)
      False
    """
    return len(set(iterator)) == 1


def compare_values(values):
    """Run an intersection test across values returned by processes.

    It is possible that not all processes return the same value for certain
    keys(status, weight etc) due to various reasons. We must detect these cases
    and either return the value which is the same across all processes or
    raise :class:`<IncosistentData>`.

    :param values: a list of tuples with 2 elements.

        #. process number of HAProxy process returned the data
        #. value returned by HAProxy process.

    :type values: ``list``
    :return: value
    :rtype: ``string``
    :raise: :class:`.IncosistentData`.
    """
    if elements_of_list_same([msg[1] for msg in values]):
        return values[0][1]
    else:
        raise IncosistentData(values)


def check_output(output):
    """Check if output contains any error.

    Several commands return output which we need to return back to the caller.
    But, before we return anything back we want to perform a sanity check on
    on the output in order to catch wrong input as it is impossible to
    perform any sanitization on values/patterns which are passed as input to
    the command.

    :param output: output of the command.
    :type output: ``list``
    :return: ``True`` if no errors found in output otherwise ``False``.
    :rtype: ``bool``
    """
    # We only care about the 1st line as that one contains possible error
    # message
    first_line = output[0]
    if first_line in ERROR_OUTPUT_STRINGS:
        return False
    else:
        return True


def check_command(results):
    """Check if command was successfully executed.

    After a command is executed. We care about the following cases:

        * The same output is returned by all processes
        * If output matches to a list of outputs which indicate that
          command was valid

    :param results: a list of tuples with 2 elements.

          #. process number of HAProxy
          #. message returned by HAProxy

    :type results: ``list``
    :return: ``True`` if command was successfully executed otherwise ``False``.
    :rtype: ``bool``
    :raise: :class:`.MultipleCommandResults` when output differers.
    """
    if elements_of_list_same([msg[1] for msg in results]):
        msg = results[0][1]
        if msg in SUCCESS_OUTPUT_STRINGS:
            return True
        else:
            raise CommandFailed(msg)
    else:
        raise MultipleCommandResults(results)

def check_command_addr_port(change_type, results):
    """Check if command to set port or address was successfully executed.

    Unfortunately, haproxy returns many different combinations of output when
    we change the address or the port of the server and trying to determine
    if address or port was successfully changed isn't that trivial.

    So, after we change address or port, we check if the same output is
    returned by all processes and we also check if a collection of specific
    strings are part of the output. This is a suboptimal solution, but I
    couldn't come up with something more elegant.

    :param change_type: either ``addr`` or ``port``
    :type change_type: ``string``
    :param results: a list of tuples with 2 elements.

          #. process number of HAProxy
          #. message returned by HAProxy
    :type results: ``list``
    :return: ``True`` if command was successfully executed otherwise ``False``.
    :rtype: ``bool``
    :raise: :class:`.MultipleCommandResults`, :class:`.CommandFailed` and
      :class:`ValueError`.
    """
    if change_type == 'addr':
        _match = SUCCESS_STRING_ADDRESS
    elif change_type == 'port':
        _match = SUCCESS_STRING_PORT
    else:
        raise ValueError('invalid value for change_type')

    if elements_of_list_same([msg[1] for msg in results]):
        msg = results[0][1]
        if re.match(_match, msg):
            return True
        else:
            raise CommandFailed(msg)
    else:
        raise MultipleCommandResults(results)


def calculate(name, metrics):
    """Perform the appropriate calculation across a list of metrics.

    :param name: name of the metric.
    :type name: ``string``
    :param metrics: a list of metrics. Elements need to be either ``int``
      or ``float`` type number.
    :type metrics: ``list``
    :return: either the sum or the average of metrics.
    :rtype: ``integer``
    :raise: :class:`ValueError` when matric name has unknown type of
      calculation.
    """
    if not metrics:
        return 0

    if name in METRICS_SUM:
        return sum(metrics)
    elif name in METRICS_AVG:
        return int(sum(metrics)/len(metrics))
    else:
        # This is to catch the case where the caller forgets to check if
        # metric name is a valide metric for HAProxy.
        raise ValueError("Unknown type of calculation for {}".format(name))

def isint(value):
    """Check if input can be converted to an integer

    :param value: value to check
    :type value: a ``string`` or ``int``
    :return: ``True`` if value can be converted to an integer
    :rtype: ``bool``
    :raise: :class:`ValueError` when value can't be converted to an integer
    """
    try:
        int(value)
        return True
    except ValueError:
        return False

def converter(value):
    """Tries to convert input value to an integer.

    If input can be safely converted to number it returns an ``int`` type.
    If input is a valid string but not an empty one it returns that.
    In all other cases we return None, including the ones which an
    ``TypeError`` exception is raised by ``int()``.
    For floating point numbers, it truncates towards zero.

    Why are we doing this?
    HAProxy may return for a metric either a number or zero or string or an
    empty string.

    It is up to the caller to correctly use the returned value. If the returned
    value is passed to a function which does math operations the caller has to
    filtered out possible ``None`` values.

    :param value: a value to convert to int.
    :type value: ``string``
    :rtype: ``integer or ``string`` or ``None`` if value can't be converted
            to ``int`` or to ``string``.

    Usage::

      >>> from haproxyadmin import utils
      >>> utils.converter('0')
      0
      >>> utils.converter('13.5')
      13
      >>> utils.converter('13.5f')
      '13.5f'
      >>> utils.converter('')
      >>> utils.converter(' ')
      >>> utils.converter('UP')
      'UP'
      >>> utils.converter('UP 1/2')
      'UP 1/2'
      >>>
    """
    try:
        return int(float(value))
    except ValueError:
        # if it isn't an empty string return it otherwise return None
        return value.strip() or None
    except TypeError:
        # This is to catch the case where input value is a data structure or
        # object. It is very unlikely someone to pass those, but you never know.
        return None


class CSVLine(object):
    """An object that holds field/value of a CSV line.

    The field name becomes the attribute of the class.
    Needs the header line of CSV during instantiation.

    :param parts: A list with field values
    :type parts: list

    Usage::

      >>> from haproxyadmin import utils
      >>> heads = ['pxname', 'type', 'lbtol']
      >>> parts = ['foor', 'backend', '444']
      >>> utils.CSVLine.heads = heads
      >>> csvobj = utils.CSVLine(parts)
      >>> csvobj.pxname
      'foor'
      >>> csvobj.type
      'backend'
      >>> csvobj.lbtol
      '444'
      >>> csvobj.bar
      Traceback (most recent call last):
      File "<stdin>", line 1, in <module>
      File "/.../haproxyadmin/haproxyadmin/utils.py", line 341, in __getattr__
          _index = self.heads.index(attr)
      ValueError: 'bar' is not in list
    """
    # This holds the field names of the CSV
    heads = []

    def __init__(self, parts):
        self.parts = parts

    def __getattr__(self, attr):
        _index = self.heads.index(attr)
        setattr(self, attr, self.parts[_index])

        return self.parts[_index]


def info2dict(raw_info):
    """Build a dictionary structure from the output of 'show info' command.

    :param raw_info: data returned by 'show info' UNIX socket command
    :type raw_info: ``list``
    :return: A dictionary with the following keys/values(examples)

    .. code-block:: python

       {
           Name: HAProxy
           Version: 1.4.24
           Release_date: 2013/06/17
           Nbproc: 1
           Process_num: 1
           Pid: 1155
           Uptime: 5d 4h42m16s
           Uptime_sec: 448936
           Memmax_MB: 0
           Ulimit-n: 131902
           Maxsock: 131902
           Maxconn: 65536
           Maxpipes: 0
           CurrConns: 1
           PipesUsed: 0
           PipesFree: 0
           Tasks: 819
           Run_queue: 1
           node: node1
           description:
       }

    :rtype: ``dict``
    """
    info = {}
    for line in raw_info:
        line = line.lstrip()
        if ': ' in line:
            key, value = line.split(': ', 1)
            info[key] = value

    return info


def stat2dict(csv_data):
    """Build a nested dictionary structure.

    :param csv_data: data returned by 'show stat' command in a CSV format.
    :type csv_data: ``list``
    :return: a nested dictionary with all counters/settings found in the input.
      Following is a sample of the structure::

        {
            'backends': {
                'acq-misc': {
                                'stats': { _CSVLine object },
                                'servers': {
                                    'acqrdb-01': { _CSVLine object },
                                    'acqrdb-02': { _CSVLine object },
                                    ...
                                    }
                            },
                ...
                },
            'frontends': {
                'acq-misc': { _CSVLine object },
                ...
                },
            ...
        }

    :rtype: ``dict``
    """
    heads = []
    dicts = {
        'backends': {},
        'frontends': {}
    }

    # get the header line
    headers = csv_data.pop(0)
    # make a shiny list of heads
    heads = headers[2:].strip().split(',')
    # set for all _CSVLine object the header fields
    CSVLine.heads = heads

    # We need to parse the following
    # haproxy,FRONTEND,,,...
    # haproxy,BACKEND,0,0,0...
    # test,FRONTEND,,,0,0,10...
    # dummy,BACKEND,0,0,0,0,1..
    # app_com,FRONTEND,,,0...
    # app_com,appfe-103.foo.com,0,...
    # app_com,BACKEND,0,0,...
    # monapp_com,FRONTEND,,,....
    # monapp_com,monappfe-102.foo.com,0...
    # monapp_com,BACKEND,0,0...
    # app_api_com,FRONTEND,,,...
    # app_api_com,appfe-105.foo.com,0...
    # app_api_com,appfe-106.foo.com,0...
    # app_api_com,BACKEND,0,0,0,0,100000,0,0,0,0,0,,0,0,...

    # A line which holds frontend definition:
    #     <frontent_name>,FRONTEND,....
    # A line holds server definition:
    #     <backend_name>,<servername>,....
    # A line which holds backend definition:
    #     <backend_name>,BACKEND,....
    # NOTE: we can have a single line for a backend definition without any
    # lines for servers associated with for that backend
    for line in csv_data:
        line = line.strip()
        if line:
            # make list of parts
            parts = line.split(',')
            # each line is a distinct object
            csvline = CSVLine(parts)
            # parts[0] => pxname field, backend or frontend name
            # parts[1] => svname field, servername or BACKEND or FRONTEND
            if parts[1] == 'FRONTEND':
                # This is a frontend line.
                # Frontend definitions aren't spread across multiple lines.
                dicts['frontends'][parts[0]] = csvline
            elif (parts[1] == 'BACKEND' and parts[0] not in dicts['backends']):
                # I see this backend information for 1st time.
                dicts['backends'][parts[0]] = {}
                dicts['backends'][parts[0]]['servers'] = {}
                dicts['backends'][parts[0]]['stats'] = csvline
            else:
                if parts[0] not in dicts['backends']:
                    # This line holds server information for a backend I haven't
                    # seen before, thus create the backend structure and store
                    # server details.
                    dicts['backends'][parts[0]] = {}
                    dicts['backends'][parts[0]]['servers'] = {}
                if parts[1] == 'BACKEND':
                    dicts['backends'][parts[0]]['stats'] = csvline
                else:
                    dicts['backends'][parts[0]]['servers'][parts[1]] = csvline

    return dicts