diff --git a/CODE_OF_CONDUCT.rst b/CODE_OF_CONDUCT.rst new file mode 100644 index 0000000..0cc0461 --- /dev/null +++ b/CODE_OF_CONDUCT.rst @@ -0,0 +1,86 @@ +Contributor Covenant Code of Conduct +==================================== + +Our Pledge +---------- + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +Our Standards +------------- + +Behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + + +Enforcement Responsibilities +---------------------------- + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + + +Scope +----- + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + + +Enforcement +----------- + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at `https://www.linuxfabrik.ch/kontakt `_. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + + +Enforcement Guidelines +---------------------- + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +1. Correction + Community Impact: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + + Consequence: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +2. Warning + Community Impact: A violation through a single incident or series of actions. + + Consequence: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +3. Temporary Ban + Community Impact: A serious violation of community standards, including sustained inappropriate behavior. + + Consequence: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +4. Permanent Ban + Community Impact: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + + Consequence: A permanent ban from any sort of public interaction within the community. + + +Attribution +----------- + +This Code of Conduct is adapted from the `Contributor Covenant, version 2.1 `_. + +Community Impact Guidelines were inspired by Mozilla's code of conduct enforcement ladder. + +For answers to common questions about this code of conduct, see the `FAQ `_. Translations are available `here `_. diff --git a/args2.py b/args2.py index 58fab6d..acdda92 100644 --- a/args2.py +++ b/args2.py @@ -12,7 +12,7 @@ """ __author__ = 'Linuxfabrik GmbH, Zurich/Switzerland' -__version__ = '2021050401' +__version__ = '2021082501' def csv(arg): @@ -26,7 +26,7 @@ def float_or_none(arg): """Returns None or float from a `float_or_none` input argument. """ - if arg is None or str(arg).lower() == 'none': + if arg is None or unicode(arg).lower() == 'none': return None return float(arg) @@ -35,7 +35,7 @@ def int_or_none(arg): """Returns None or int from a `int_or_none` input argument. """ - if arg is None or str(arg).lower() == 'none': + if arg is None or unicode(arg).lower() == 'none': return None return int(arg) @@ -51,6 +51,6 @@ def str_or_none(arg): """Returns None or str from a `str_or_none` input argument. """ - if arg is None or str(arg).lower() == 'none': + if arg is None or unicode(arg).lower() == 'none': return None - return str(arg) + return unicode(arg) diff --git a/args3.py b/args3.py index bce31cd..10ae4a3 100644 --- a/args3.py +++ b/args3.py @@ -12,20 +12,18 @@ """ __author__ = 'Linuxfabrik GmbH, Zurich/Switzerland' -__version__ = '2020043001' +__version__ = '2021082501' def csv(arg): """Returns a list from a `csv` input argument. """ - return [x.strip() for x in arg.split(',')] def float_or_none(arg): """Returns None or float from a `float_or_none` input argument. """ - if arg is None or str(arg).lower() == 'none': return None return float(arg) @@ -34,7 +32,6 @@ def float_or_none(arg): def int_or_none(arg): """Returns None or int from a `int_or_none` input argument. """ - if arg is None or str(arg).lower() == 'none': return None return int(arg) @@ -43,14 +40,12 @@ def int_or_none(arg): def range_or_none(arg): """Returns None or range from a `range_or_none` input argument. """ - return str_or_none(arg) def str_or_none(arg): """Returns None or str from a `str_or_none` input argument. """ - if arg is None or str(arg).lower() == 'none': return None return str(arg) diff --git a/base2.py b/base2.py index 4acdb38..3c8b1ca 100644 --- a/base2.py +++ b/base2.py @@ -12,11 +12,12 @@ """ __author__ = 'Linuxfabrik GmbH, Zurich/Switzerland' -__version__ = '2021061401' +__version__ = '2021101301' import collections import datetime import hashlib +import locale import math import numbers import operator @@ -27,8 +28,9 @@ import sys import time +from traceback import format_exc # pylint: disable=C0413 + from globals2 import STATE_OK, STATE_UNKNOWN, STATE_WARN, STATE_CRIT -import disk2 WINDOWS = os.name == "nt" @@ -138,10 +140,24 @@ def coe(result, state=STATE_UNKNOWN): if result[0]: # success return result[1] - print(result[1]) + codec = locale.getpreferredencoding() + if isinstance(result[1], str): + result[1] = result[1].decode(codec) + print(result[1].encode(codec, 'replace')) sys.exit(state) +def cu(): + """See you (cu) + + Prints a Stacktrace (replacing "<" and ">" to be printable in Web-GUIs), and exits with + STATE_UNKNOWN. + """ + codec = locale.getpreferredencoding() + print((format_exc().replace("<", "'").replace(">", "'")).encode(codec, 'replace')) + sys.exit(STATE_UNKNOWN) + + def epoch2iso(timestamp): """Returns the ISO representaton of a UNIX timestamp (epoch). @@ -152,6 +168,45 @@ def epoch2iso(timestamp): return time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(timestamp)) +def extract_str(s, from_txt, to_txt, include_fromto=False, be_tolerant=True): + """Extracts text between `from_txt` to `to_txt`. + If `include_fromto` is set to False (default), text is returned without both search terms, + otherwise `from_txt` and `to_txt` are included. + If `from_txt` is not found, always an empty string is returned. + If `to_txt` is not found and `be_tolerant` is set to True (default), text is returned from + `from_txt` til the end of input text. Otherwise an empty text is returned. + + >>> extract_text('abcde', 'x', 'y') + '' + >>> extract_text('abcde', 'b', 'x') + 'cde' + >>> extract_text('abcde', 'b', 'x', include_fromto=True) + 'bcde' + >>> extract_text('abcde', 'b', 'x', include_fromto=True, be_tolerant=False) + '' + >>> extract_text('abcde', 'b', 'd') + 'c' + >>> extract_text('abcde', 'b', 'd', include_fromto=True) + 'bcd' + """ + pos1 = s.find(from_txt) + if pos1 == -1: + # nothing found + return '' + pos2 = s.find(to_txt, pos1+len(from_txt)) + # to_txt not found: + if pos2 == -1 and be_tolerant and not include_fromto: + return s[pos1+len(from_txt):] + if pos2 == -1 and be_tolerant and include_fromto: + return s[pos1:] + if pos2 == -1 and not be_tolerant: + return '' + # from_txt and to_txt found: + if not include_fromto: + return s[pos1+len(from_txt):pos2-len(to_txt)+ 1] + return s[pos1:pos2+len(to_txt)] + + def filter_mltext(input, ignore): filtered_input = '' for line in input.splitlines(): @@ -164,8 +219,11 @@ def filter_mltext(input, ignore): def filter_str(s, charclass='a-zA-Z0-9_'): """Stripping everything except alphanumeric chars and '_' from a string - chars that are allowed everywhere in variables, database table or index names, etc. + + >>> filter_str('user@example.ch') + 'userexamplech' """ - regex = '[^{}]'.format(charclass) + regex = u'[^{}]'.format(charclass) return re.sub(regex, "", s) @@ -214,21 +272,21 @@ def get_owner(file): def get_perfdata(label, value, uom, warn, crit, min, max): """Returns 'label'=value[UOM];[warn];[crit];[min];[max] """ - msg = "'{}'={}".format(label, value) + msg = u"'{}'={}".format(label, value) if uom is not None: msg += uom msg += ';' if warn is not None: - msg += str(warn) + msg += unicode(warn) msg += ';' if crit is not None: - msg += str(crit) + msg += unicode(crit) msg += ';' if min is not None: - msg += str(min) + msg += unicode(min) msg += ';' if max is not None: - msg += str(max) + msg += unicode(max) msg += ' ' return msg @@ -237,9 +295,9 @@ def get_state(value, warn, crit, operator='ge'): """Returns the STATE by comparing `value` to the given thresholds using a comparison `operator`. `warn` and `crit` threshold may also be `None`. - >>> lib.base2.get_state(15, 10, 20, 'ge') + >>> get_state(15, 10, 20, 'ge') 1 (STATE_WARN) - >>> lib.base2.get_state(10, 10, 20, 'gt') + >>> get_state(10, 10, 20, 'gt') 0 (STATE_OK) Parameters @@ -251,12 +309,13 @@ def get_state(value, warn, crit, operator='ge'): crit : float Numeric critical threshold operator : string + `eq` = equal to `ge` = greater or equal `gt` = greater than `le` = less or equal `lt` = less than - `eq` = equal to `ne` = not equal to + `range` = match range Returns ------- @@ -319,19 +378,29 @@ def get_state(value, warn, crit, operator='ge'): return STATE_WARN return STATE_OK + if operator == 'range': + if crit is not None: + if not coe(match_range(value, crit)): + return STATE_CRIT + if warn is not None: + if not coe(match_range(value, warn)): + return STATE_WARN + return STATE_OK + return STATE_UNKNOWN -def get_table(data, keys, header=None, sort_by_key=None, sort_order_reverse=False): +def get_table(data, cols, header=None, strip=True, sort_by_key=None, sort_order_reverse=False): """Takes a list of dictionaries, formats the data, and returns the formatted data as a text table. Required Parameters: data - Data to process (list of dictionaries). (Type: List) - keys - List of keys in the dictionary. (Type: List) + cols - List of cols in the dictionary. (Type: List) Optional Parameters: header - The table header. (Type: List) + strip - Strip/Trim values or not. (Type: Boolean) sort_by_key - The key to sort by. (Type: String) sort_order_reverse - Default sort order is ascending, if True sort order will change to descending. (Type: bool) @@ -342,38 +411,59 @@ def get_table(data, keys, header=None, sort_by_key=None, sort_order_reverse=Fals if not data: return '' - # Sort the data if a sort key is specified (default sort order - # is ascending) + # Sort the data if a sort key is specified (default sort order is ascending) if sort_by_key: data = sorted(data, key=operator.itemgetter(sort_by_key), reverse=sort_order_reverse) - # If header is not empty, add header to data + # If header is not empty, create a list of dictionary from the cols and the header and + # insert it before first row of data if header: - # Get the length of each header and create a divider based - # on that length - header_divider = [] - for name in header: - header_divider.append('-' * len(name)) - - # Create a list of dictionary from the keys and the header and - # insert it at the beginning of the list. Do the same for the - # divider and insert below the header. - header_divider = dict(zip(keys, header_divider)) - data.insert(0, header_divider) - header = dict(zip(keys, header)) + header = dict(zip(cols, header)) data.insert(0, header) + # prepare data: Return the Unicode string version using UTF-8 Encoding, + # optionally strip values and get the maximum length per column column_widths = collections.OrderedDict() - for key in keys: - column_widths[key] = max(len(str(column[key])) for column in data) + for idx, row in enumerate(data): + for col in cols: + try: + if strip: + data[idx][col] = unicode(row[col]).strip() + else: + data[idx][col] = unicode(row[col]) + except: + return u'Unknown column "{}"'.format(col) + # get the maximum length + try: + column_widths[col] = max(column_widths[col], len(data[idx][col])) + except: + column_widths[col] = len(data[idx][col]) + if header: + # Get the length of each column and create a '---' divider based on that length + header_divider = [] + for col, width in column_widths.items(): + header_divider.append(u'-' * width) + + # Insert the header divider below the header row + header_divider = dict(zip(cols, header_divider)) + data.insert(1, header_divider) + + # create the output table = '' - for element in data: - for key, width in column_widths.items(): - table += '{:<{}} '.format(element[key], width) - table += '\n' + cnt = 0 + for row in data: + tmp = '' + for col, width in column_widths.items(): + if cnt != 1: + tmp += u'{:<{}} ! '.format(row[col], width) + else: + # header row + tmp += u'{:<{}}-+-'.format(row[col], width) + cnt += 1 + table += tmp[:-2] + '\n' return table @@ -425,7 +515,7 @@ def guess_type(v, consumer='python'): try: return float(v) except ValueError: - return str(v) + return unicode(v) if consumer == 'sqlite': if v is None: @@ -529,6 +619,28 @@ def match_range(value, spec): def parse_range(spec): """ Inspired by https://github.com/mpounsett/nagiosplugin/blob/master/nagiosplugin/range.py + + +--------+-------------------+-------------------+--------------------------------+ + | -w, -c | OK if result is | WARN/CRIT if | lib.base.parse_range() returns | + +--------+-------------------+-------------------+--------------------------------+ + | 10 | in (0..10) | not in (0..10) | (0, 10, False) | + +--------+-------------------+-------------------+--------------------------------+ + | -10 | in (-10..0) | not in (-10..0) | (0, -10, False) | + +--------+-------------------+-------------------+--------------------------------+ + | 10: | in (10..inf) | not in (10..inf) | (10, inf, False) | + +--------+-------------------+-------------------+--------------------------------+ + | : | in (0..inf) | not in (0..inf) | (0, inf, False) | + +--------+-------------------+-------------------+--------------------------------+ + | ~:10 | in (-inf..10) | not in (-inf..10) | (-inf, 10, False) | + +--------+-------------------+-------------------+--------------------------------+ + | 10:20 | in (10..20) | not in (10..20) | (10, 20, False) | + +--------+-------------------+-------------------+--------------------------------+ + | @10:20 | not in (10..20) | in 10..20 | (10, 20, True) | + +--------+-------------------+-------------------+--------------------------------+ + | @~:20 | not in (-inf..20) | in (-inf..20) | (-inf, 20, True) | + +--------+-------------------+-------------------+--------------------------------+ + | @ | not in (0..inf) | in (0..inf) | (0, inf, True) | + +--------+-------------------+-------------------+--------------------------------+ """ def parse_atom(atom, default): if atom == '': @@ -538,10 +650,10 @@ def parse_atom(atom, default): return int(atom) - if spec is None or str(spec).lower() == 'none': + if spec is None or unicode(spec).lower() == 'none': return (True, None) if not isinstance(spec, str): - spec = str(spec) + spec = unicode(spec) invert = False if spec.startswith('@'): invert = True @@ -563,7 +675,7 @@ def parse_atom(atom, default): return (True, (start, end, invert)) - if spec is None or str(spec).lower() == 'none': + if spec is None or unicode(spec).lower() == 'none': return (True, True) success, result = parse_range(spec) if not success: @@ -635,7 +747,7 @@ def number2human(n): return n millidx = max(0, min(len(millnames) - 1, int(math.floor(0 if n == 0 else math.log10(abs(n)) / 3)))) - return '{:.1f}{}'.format(n / 10**(3 * millidx), millnames[millidx]) + return u'{:.1f}{}'.format(n / 10**(3 * millidx), millnames[millidx]) def oao(msg, state=STATE_OK, perfdata='', always_ok=False): @@ -644,11 +756,39 @@ def oao(msg, state=STATE_OK, perfdata='', always_ok=False): Print the stripped plugin message. If perfdata is given, attach it by `|` and print it stripped. Exit with `state`, or with STATE_OK (0) if `always_ok` is set to `True`. + + Always use Unicode internally. Decode what you receive to unicode, and encode what you send. + str is text representation in bytes, unicode is text representation in characters. + You decode text from bytes to unicode and encode a unicode into bytes with some encoding. + (in Python 3, str was renamed to bytes, and unicode was renamed to str) + + On a Gnome-Terminal, we get + >>> sys.stdout.encoding + 'UTF-8' + >>> sys.getdefaultencoding() + 'ascii' + >>> import locale + >>> locale.getpreferredencoding() + 'UTF-8' + + In IcingaWeb, we get + >>> sys.stdout.encoding + 'None' + >>> sys.getdefaultencoding() + 'ascii' + >>> import locale + >>> locale.getpreferredencoding() + 'UTF-8' """ + codec = locale.getpreferredencoding() + if isinstance(msg, str): + msg = msg.decode(codec) if perfdata: - print(msg.strip() + '|' + perfdata.strip()) + if isinstance(perfdata, str): + perfdata = perfdata.decode(codec) + print((msg.strip() + '|' + perfdata.strip()).encode(codec, 'replace')) else: - print(msg.strip()) + print((msg.strip()).encode(codec, 'replace')) if always_ok: sys.exit(0) sys.exit(state) @@ -725,7 +865,7 @@ def seconds2human(seconds, keep_short=True, full_name=False): """ seconds = float(seconds) if seconds < 1: - return '{:.2f}s'.format(seconds) + return u'{:.2f}s'.format(seconds) if full_name: intervals = ( @@ -755,7 +895,7 @@ def seconds2human(seconds, keep_short=True, full_name=False): seconds -= value * count if full_name and value == 1: name = name.rstrip('s') # "days" becomes "day" - result.append('{:.0f}{}'.format(value, name)) + result.append(u'{:.0f}{}'.format(value, name)) if len(result) > 2 and keep_short: return ' '.join(result[:2]) @@ -809,11 +949,11 @@ def shell_exec(cmd, env=None, shell=False, stdin=''): sp = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, shell=True) except OSError as e: - return (False, 'OS Error "{} {}" calling command "{}"'.format(e.errno, e.strerror, cmd)) + return (False, u'OS Error "{} {}" calling command "{}"'.format(e.errno, e.strerror, cmd)) except ValueError as e: - return (False, 'Value Error "{}" calling command "{}"'.format(e, cmd)) + return (False, u'Value Error "{}" calling command "{}"'.format(e, cmd)) except e: - return (False, 'Unknown error "{}" while calling command "{}"'.format(e, cmd)) + return (False, u'Unknown error "{}" while calling command "{}"'.format(e, cmd)) if stdin: # provide stdin as input for the cmd @@ -836,11 +976,11 @@ def shell_exec(cmd, env=None, shell=False, stdin=''): sp = subprocess.Popen(args, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, shell=False) except OSError as e: - return (False, 'OS Error "{} {}" calling command "{}"'.format(e.errno, e.strerror, cmd)) + return (False, u'OS Error "{} {}" calling command "{}"'.format(e.errno, e.strerror, cmd)) except ValueError as e: - return (False, 'Value Error "{}" calling command "{}"'.format(e, cmd)) + return (False, u'Value Error "{}" calling command "{}"'.format(e, cmd)) except e: - return (False, 'Unknown error "{}" while calling command "{}"'.format(e, cmd)) + return (False, u'Unknown error "{}" while calling command "{}"'.format(e, cmd)) stdout, stderr = sp.communicate() retc = sp.returncode @@ -866,7 +1006,7 @@ def sort(array, reverse=True, sort_by_key=False): if isinstance(array, dict): if not sort_by_key: return sorted(array.items(), key=lambda x: x[1], reverse=reverse) - return sorted(array.items(), key=lambda x: str(x[0]).lower(), reverse=reverse) + return sorted(array.items(), key=lambda x: unicode(x[0]).lower(), reverse=reverse) return array @@ -922,7 +1062,7 @@ def str2state(string): >>> lib.base.str2state('warning') 1 """ - string = str(string).lower() + string = unicode(string).lower() if string == 'ok': return STATE_OK if string.startswith('warn'): @@ -952,34 +1092,17 @@ def state2str(state, empty_ok=True, prefix='', suffix=''): if state == STATE_OK and empty_ok: return '' if state == STATE_OK and not empty_ok: - return '{}[OK]{}'.format(prefix, suffix) + return u'{}[OK]{}'.format(prefix, suffix) if state == STATE_WARN: - return '{}[WARNING]{}'.format(prefix, suffix) + return u'{}[WARNING]{}'.format(prefix, suffix) if state == STATE_CRIT: - return '{}[CRITICAL]{}'.format(prefix, suffix) + return u'{}[CRITICAL]{}'.format(prefix, suffix) if state == STATE_UNKNOWN: - return '{}[UNKNOWN]{}'.format(prefix, suffix) + return u'{}[UNKNOWN]{}'.format(prefix, suffix) return state -def test(args): - """Enables unit testing of a check plugin. - - """ - if args[0] and os.path.isfile(args[0]): - success, stdout = disk2.read_file(args[0]) - else: - stdout = args[0] - if args[1] and os.path.isfile(args[1]): - success, stderr = disk2.read_file(args[1]) - else: - stderr = args[1] - retc = int(args[2]) - - return stdout, stderr, retc - - def timestr2datetime(timestr, pattern='%Y-%m-%d %H:%M:%S'): """Takes a string (default: ISO format) and returns a datetime object. @@ -1006,13 +1129,24 @@ def uniq(string): return ' '.join(sorted(set(words), key=words.index)) +def utc_offset(): + """Returns the current local UTC offset, for example '+0200'. + + utc_offset() + >>> '+0200' + """ + return time.strftime("%z") + + def version(v): - """Use this function to compare numerical but string-based version numbers. + """Use this function to compare string-based version numbers. - >>> lib.base2.version('3.0.7') < lib.base2.version('3.0.11') - True >>> '3.0.7' < '3.0.11' False + >>> lib.base2.version('3.0.7') < lib.base2.version('3.0.11') + True + >>> lib.base2.version('v3.0.7-2') < lib.base2.version('3.0.11') + True >>> lib.base2.version(psutil.__version__) >= lib.base2.version('5.3.0') True @@ -1026,6 +1160,10 @@ def version(v): tuple A tuple of version numbers. """ + # if we get something like "v0.10.7-2", remove everything except "." and "-", + # and convert "-" to "." + v = re.sub(r'[^0-9\.-]', '', v) + v = v.replace('-', '.') return tuple(map(int, (v.split(".")))) @@ -1034,10 +1172,51 @@ def version2float(v): >>> version2float('Version v17.3.2.0') 17.320 + >>> version2float('21.60-53-93285') + 21.605393285 """ - v = re.sub(r'[a-z\s]', '', v.lower()) + v = re.sub(r'[^0-9\.]', '', v) v = v.split('.') if len(v) > 1: - return float('{}.{}'.format(v[0], ''.join(v[1:]))) + return float(u'{}.{}'.format(v[0], ''.join(v[1:]))) else: return float(''.join(v)) + + +def yesterday(as_type='', tz_utc=False): + """Returns yesterday's date and time as UNIX time in seconds (default), or + as a datetime object. + + >>> lib.base2.yesterday() + 1626706723 + >>> lib.base2.yesterday(as_type='', tz_utc=False) + 1626706723 + >>> lib.base2.yesterday(as_type='', tz_utc=True) + 1626706723 + + >>> lib.base2.yesterday(as_type='datetime', tz_utc=False) + datetime.datetime(2021, 7, 19, 16, 58, 43, 11292) + >>> lib.base2.yesterday(as_type='datetime', tz_utc=True) + datetime.datetime(2021, 7, 19, 14, 58, 43, 11446, tzinfo=datetime.timezone.utc) + + >>> lib.base2.yesterday(as_type='iso', tz_utc=False) + '2021-07-19 16:58:43' + >>> lib.base2.yesterday(as_type='iso', tz_utc=True) + '2021-07-19T14:58:43Z' + """ + if tz_utc: + if as_type == 'datetime': + today = datetime.datetime.now(tz=datetime.timezone.utc) + return today - datetime.timedelta(days=1) + if as_type == 'iso': + today = datetime.datetime.now(tz=datetime.timezone.utc) + yesterday = today - datetime.timedelta(days=1) + return yesterday.isoformat(timespec='seconds').replace('+00:00', 'Z') + if as_type == 'datetime': + today = datetime.datetime.now() + return today - datetime.timedelta(days=1) + if as_type == 'iso': + today = datetime.datetime.now() + yesterday = today - datetime.timedelta(days=1) + return yesterday.strftime("%Y-%m-%d %H:%M:%S") + return int(time.time()-86400) diff --git a/base3.py b/base3.py index a6f21fb..d0aa599 100644 --- a/base3.py +++ b/base3.py @@ -12,7 +12,7 @@ """ __author__ = 'Linuxfabrik GmbH, Zurich/Switzerland' -__version__ = '2021061401' +__version__ = '2021091701' import collections import datetime @@ -27,8 +27,9 @@ import sys import time +from traceback import format_exc # pylint: disable=C0413 + from .globals3 import STATE_CRIT, STATE_OK, STATE_UNKNOWN, STATE_WARN -from . import disk3 WINDOWS = os.name == "nt" @@ -142,6 +143,16 @@ def coe(result, state=STATE_UNKNOWN): sys.exit(state) +def cu(): + """See you (cu) + + Prints a Stacktrace (replacing "<" and ">" to be printable in Web-GUIs), and exits with + STATE_UNKNOWN. + """ + print(format_exc().replace("<", "'").replace(">", "'")) + sys.exit(STATE_UNKNOWN) + + def epoch2iso(timestamp): """Returns the ISO representaton of a UNIX timestamp (epoch). @@ -152,6 +163,45 @@ def epoch2iso(timestamp): return time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(timestamp)) +def extract_str(s, from_txt, to_txt, include_fromto=False, be_tolerant=True): + """Extracts text between `from_txt` to `to_txt`. + If `include_fromto` is set to False (default), text is returned without both search terms, + otherwise `from_txt` and `to_txt` are included. + If `from_txt` is not found, always an empty string is returned. + If `to_txt` is not found and `be_tolerant` is set to True (default), text is returned from + `from_txt` til the end of input text. Otherwise an empty text is returned. + + >>> extract_text('abcde', 'x', 'y') + '' + >>> extract_text('abcde', 'b', 'x') + 'cde' + >>> extract_text('abcde', 'b', 'x', include_fromto=True) + 'bcde' + >>> extract_text('abcde', 'b', 'x', include_fromto=True, be_tolerant=False) + '' + >>> extract_text('abcde', 'b', 'd') + 'c' + >>> extract_text('abcde', 'b', 'd', include_fromto=True) + 'bcd' + """ + pos1 = s.find(from_txt) + if pos1 == -1: + # nothing found + return '' + pos2 = s.find(to_txt, pos1+len(from_txt)) + # to_txt not found: + if pos2 == -1 and be_tolerant and not include_fromto: + return s[pos1+len(from_txt):] + if pos2 == -1 and be_tolerant and include_fromto: + return s[pos1:] + if pos2 == -1 and not be_tolerant: + return '' + # from_txt and to_txt found: + if not include_fromto: + return s[pos1+len(from_txt):pos2-len(to_txt)+ 1] + return s[pos1:pos2+len(to_txt)] + + def filter_mltext(input, ignore): filtered_input = '' for line in input.splitlines(): @@ -164,6 +214,9 @@ def filter_mltext(input, ignore): def filter_str(s, charclass='a-zA-Z0-9_'): """Stripping everything except alphanumeric chars and '_' from a string - chars that are allowed everywhere in variables, database table or index names, etc. + + >>> filter_str('user@example.ch') + 'userexamplech' """ regex = '[^{}]'.format(charclass) return re.sub(regex, "", s) @@ -237,9 +290,9 @@ def get_state(value, warn, crit, operator='ge'): """Returns the STATE by comparing `value` to the given thresholds using a comparison `operator`. `warn` and `crit` threshold may also be `None`. - >>> lib.base3.get_state(15, 10, 20, 'ge') + >>> get_state(15, 10, 20, 'ge') 1 (STATE_WARN) - >>> lib.base3.get_state(10, 10, 20, 'gt') + >>> get_state(10, 10, 20, 'gt') 0 (STATE_OK) Parameters @@ -251,12 +304,13 @@ def get_state(value, warn, crit, operator='ge'): crit : float Numeric critical threshold operator : string + `eq` = equal to `ge` = greater or equal `gt` = greater than `le` = less or equal `lt` = less than - `eq` = equal to `ne` = not equal to + `range` = match range Returns ------- @@ -319,19 +373,29 @@ def get_state(value, warn, crit, operator='ge'): return STATE_WARN return STATE_OK + if operator == 'range': + if crit is not None: + if not coe(match_range(value, crit)): + return STATE_CRIT + if warn is not None: + if not coe(match_range(value, warn)): + return STATE_WARN + return STATE_OK + return STATE_UNKNOWN -def get_table(data, keys, header=None, sort_by_key=None, sort_order_reverse=False): +def get_table(data, cols, header=None, strip=True, sort_by_key=None, sort_order_reverse=False): """Takes a list of dictionaries, formats the data, and returns the formatted data as a text table. Required Parameters: data - Data to process (list of dictionaries). (Type: List) - keys - List of keys in the dictionary. (Type: List) + cols - List of cols in the dictionary. (Type: List) Optional Parameters: header - The table header. (Type: List) + strip - Strip/Trim values or not. (Type: Boolean) sort_by_key - The key to sort by. (Type: String) sort_order_reverse - Default sort order is ascending, if True sort order will change to descending. (Type: bool) @@ -342,38 +406,59 @@ def get_table(data, keys, header=None, sort_by_key=None, sort_order_reverse=Fals if not data: return '' - # Sort the data if a sort key is specified (default sort order - # is ascending) + # Sort the data if a sort key is specified (default sort order is ascending) if sort_by_key: data = sorted(data, key=operator.itemgetter(sort_by_key), reverse=sort_order_reverse) - # If header is not empty, add header to data + # If header is not empty, create a list of dictionary from the cols and the header and + # insert it before first row of data if header: - # Get the length of each header and create a divider based - # on that length - header_divider = [] - for name in header: - header_divider.append('-' * len(name)) - - # Create a list of dictionary from the keys and the header and - # insert it at the beginning of the list. Do the same for the - # divider and insert below the header. - header_divider = dict(zip(keys, header_divider)) - data.insert(0, header_divider) - header = dict(zip(keys, header)) + header = dict(zip(cols, header)) data.insert(0, header) + # prepare data: decode from (mostly) UTF-8 to Unicode, optionally strip values and get + # the maximum length per column column_widths = collections.OrderedDict() - for key in keys: - column_widths[key] = max(len(str(column[key])) for column in data) + for idx, row in enumerate(data): + for col in cols: + try: + if strip: + data[idx][col] = str(row[col]).strip() + else: + data[idx][col] = str(row[col]) + except: + return 'Unknown column "{}"'.format(col) + # get the maximum length + try: + column_widths[col] = max(column_widths[col], len(data[idx][col])) + except: + column_widths[col] = len(data[idx][col]) + + if header: + # Get the length of each column and create a '---' divider based on that length + header_divider = [] + for col, width in column_widths.items(): + header_divider.append('-' * width) + # Insert the header divider below the header row + header_divider = dict(zip(cols, header_divider)) + data.insert(1, header_divider) + + # create the output table = '' - for element in data: - for key, width in column_widths.items(): - table += '{:<{}} '.format(element[key], width) - table += '\n' + cnt = 0 + for row in data: + tmp = '' + for col, width in column_widths.items(): + if cnt != 1: + tmp += '{:<{}} ! '.format(row[col], width) + else: + # header row + tmp += '{:<{}}-+-'.format(row[col], width) + cnt += 1 + table += tmp[:-2] + '\n' return table @@ -529,6 +614,28 @@ def match_range(value, spec): def parse_range(spec): """ Inspired by https://github.com/mpounsett/nagiosplugin/blob/master/nagiosplugin/range.py + + +--------+-------------------+-------------------+--------------------------------+ + | -w, -c | OK if result is | WARN/CRIT if | lib.base.parse_range() returns | + +--------+-------------------+-------------------+--------------------------------+ + | 10 | in (0..10) | not in (0..10) | (0, 10, False) | + +--------+-------------------+-------------------+--------------------------------+ + | -10 | in (-10..0) | not in (-10..0) | (0, -10, False) | + +--------+-------------------+-------------------+--------------------------------+ + | 10: | in (10..inf) | not in (10..inf) | (10, inf, False) | + +--------+-------------------+-------------------+--------------------------------+ + | : | in (0..inf) | not in (0..inf) | (0, inf, False) | + +--------+-------------------+-------------------+--------------------------------+ + | ~:10 | in (-inf..10) | not in (-inf..10) | (-inf, 10, False) | + +--------+-------------------+-------------------+--------------------------------+ + | 10:20 | in (10..20) | not in (10..20) | (10, 20, False) | + +--------+-------------------+-------------------+--------------------------------+ + | @10:20 | not in (10..20) | in 10..20 | (10, 20, True) | + +--------+-------------------+-------------------+--------------------------------+ + | @~:20 | not in (-inf..20) | in (-inf..20) | (-inf, 20, True) | + +--------+-------------------+-------------------+--------------------------------+ + | @ | not in (0..inf) | in (0..inf) | (0, inf, True) | + +--------+-------------------+-------------------+--------------------------------+ """ def parse_atom(atom, default): if atom == '': @@ -963,23 +1070,6 @@ def state2str(state, empty_ok=True, prefix='', suffix=''): return state -def test(args): - """Enables unit testing of a check plugin. - - """ - if args[0] and os.path.isfile(args[0]): - success, stdout = disk3.read_file(args[0]) - else: - stdout = args[0] - if args[1] and os.path.isfile(args[1]): - success, stderr = disk3.read_file(args[1]) - else: - stderr = args[1] - retc = int(args[2]) - - return stdout, stderr, retc - - def timestr2datetime(timestr, pattern='%Y-%m-%d %H:%M:%S'): """Takes a string (default: ISO format) and returns a datetime object. @@ -1006,13 +1096,24 @@ def uniq(string): return ' '.join(sorted(set(words), key=words.index)) +def utc_offset(): + """Returns the current local UTC offset, for example '+0200'. + + utc_offset() + >>> '+0200' + """ + return time.strftime("%z") + + def version(v): - """Use this function to compare numerical but string-based version numbers. + """Use this function to compare string-based version numbers. - >>> lib.base3.version('3.0.7') < lib.base3.version('3.0.11') - True >>> '3.0.7' < '3.0.11' False + >>> lib.base3.version('3.0.7') < lib.base3.version('3.0.11') + True + >>> lib.base3.version('v3.0.7-2') < lib.base3.version('3.0.11') + True >>> lib.base3.version(psutil.__version__) >= lib.base3.version('5.3.0') True @@ -1026,6 +1127,10 @@ def version(v): tuple A tuple of version numbers. """ + # if we get something like "v0.10.7-2", remove everything except "." and "-", + # and convert "-" to "." + v = re.sub(r'[^0-9\.-]', '', v) + v = v.replace('-', '.') return tuple(map(int, (v.split(".")))) @@ -1034,10 +1139,51 @@ def version2float(v): >>> version2float('Version v17.3.2.0') 17.320 + >>> version2float('21.60-53-93285') + 21.605393285 """ - v = re.sub(r'[a-z\s]', '', v.lower()) + v = re.sub(r'[^0-9\.]', '', v) v = v.split('.') if len(v) > 1: return float('{}.{}'.format(v[0], ''.join(v[1:]))) else: return float(''.join(v)) + + +def yesterday(as_type='', tz_utc=False): + """Returns yesterday's date and time as UNIX time in seconds (default), or + as a datetime object. + + >>> lib.base3.yesterday() + 1626706723 + >>> lib.base3.yesterday(as_type='', tz_utc=False) + 1626706723 + >>> lib.base3.yesterday(as_type='', tz_utc=True) + 1626706723 + + >>> lib.base3.yesterday(as_type='datetime', tz_utc=False) + datetime.datetime(2021, 7, 19, 16, 58, 43, 11292) + >>> lib.base3.yesterday(as_type='datetime', tz_utc=True) + datetime.datetime(2021, 7, 19, 14, 58, 43, 11446, tzinfo=datetime.timezone.utc) + + >>> lib.base3.yesterday(as_type='iso', tz_utc=False) + '2021-07-19 16:58:43' + >>> lib.base3.yesterday(as_type='iso', tz_utc=True) + '2021-07-19T14:58:43Z' + """ + if tz_utc: + if as_type == 'datetime': + today = datetime.datetime.now(tz=datetime.timezone.utc) + return today - datetime.timedelta(days=1) + if as_type == 'iso': + today = datetime.datetime.now(tz=datetime.timezone.utc) + yesterday = today - datetime.timedelta(days=1) + return yesterday.isoformat(timespec='seconds').replace('+00:00', 'Z') + if as_type == 'datetime': + today = datetime.datetime.now() + return today - datetime.timedelta(days=1) + if as_type == 'iso': + today = datetime.datetime.now() + yesterday = today - datetime.timedelta(days=1) + return yesterday.strftime("%Y-%m-%d %H:%M:%S") + return int(time.time()-86400) diff --git a/cache2.py b/cache2.py index 78dff1d..82b96a4 100644 --- a/cache2.py +++ b/cache2.py @@ -25,13 +25,13 @@ """ __author__ = 'Linuxfabrik GmbH, Zurich/Switzerland' -__version__ = '2020051301' +__version__ = '2021100801' import base2 import db_sqlite2 -def get(key, as_dict=False): +def get(key, as_dict=False, path='', filename='linuxfabrik-plugin-cache.db'): """Get the value of key. If the key does not exist, `False` is returned. Parameters @@ -46,7 +46,7 @@ def get(key, as_dict=False): failure. """ - success, conn = db_sqlite2.connect(filename='linuxfabrik-plugin-cache.db') + success, conn = db_sqlite2.connect(path=path, filename=filename) if not success: return False @@ -71,7 +71,7 @@ def get(key, as_dict=False): data = {'key' : result['key']} success, result = db_sqlite2.delete( conn, - sql='DELETE FROM cache WHERE timestamp <= {};'.format(base2.now()) + sql=u'DELETE FROM cache WHERE timestamp <= {};'.format(base2.now()) ) success, result = db_sqlite2.commit(conn) db_sqlite2.close(conn) @@ -87,7 +87,7 @@ def get(key, as_dict=False): return result -def set(key, value, expire=0): +def set(key, value, expire=0, path='', filename='linuxfabrik-plugin-cache.db'): """Set key to hold the string value. Keys have to be unique. If the key already holds a value, it is @@ -109,7 +109,7 @@ def set(key, value, expire=0): `True` on success, `False` on failure. """ - success, conn = db_sqlite2.connect(filename='linuxfabrik-plugin-cache.db') + success, conn = db_sqlite2.connect(path=path, filename=filename) if not success: return False diff --git a/cache3.py b/cache3.py index c35befd..6ab4a10 100644 --- a/cache3.py +++ b/cache3.py @@ -25,13 +25,13 @@ """ __author__ = 'Linuxfabrik GmbH, Zurich/Switzerland' -__version__ = '2021031001' +__version__ = '2021100801' from . import base3 from . import db_sqlite3 -def get(key, as_dict=False): +def get(key, as_dict=False, path='', filename='linuxfabrik-plugin-cache.db'): """Get the value of key. If the key does not exist, `False` is returned. Parameters @@ -46,7 +46,7 @@ def get(key, as_dict=False): failure. """ - success, conn = db_sqlite3.connect(filename='linuxfabrik-plugin-cache.db') + success, conn = db_sqlite3.connect(path=path, filename=filename) if not success: return False @@ -71,7 +71,7 @@ def get(key, as_dict=False): data = {'key' : result['key']} success, result = db_sqlite3.delete( conn, - sql='DELETE FROM cache WHERE timestamp <= {};'.format(base.now()) + sql='DELETE FROM cache WHERE timestamp <= {};'.format(base3.now()) ) success, result = db_sqlite3.commit(conn) db_sqlite3.close(conn) @@ -87,7 +87,7 @@ def get(key, as_dict=False): return result -def set(key, value, expire=0): +def set(key, value, expire=0, path='', filename='linuxfabrik-plugin-cache.db'): """Set key to hold the string value. Keys have to be unique. If the key already holds a value, it is @@ -109,7 +109,7 @@ def set(key, value, expire=0): `True` on success, `False` on failure. """ - success, conn = db_sqlite3.connect(filename='linuxfabrik-plugin-cache.db') + success, conn = db_sqlite3.connect(path=path, filename=filename) if not success: return False diff --git a/db_mysql2.py b/db_mysql2.py index 8dd6ee1..aecb5e6 100644 --- a/db_mysql2.py +++ b/db_mysql2.py @@ -15,23 +15,28 @@ """ __author__ = 'Linuxfabrik GmbH, Zurich/Switzerland' -__version__ = '2020050101' +__version__ = '2021090701' + +from globals2 import * try: import mysql.connector except ImportError as e: - print('Python module "mysql.connector" is not installed.') - exit(3) + mysql_connector_found = False +else: + mysql_connector_found = True import base2 -import disk2 -if base2.version(mysql.connector.__version__) < base2.version('2.0.0'): +if mysql_connector_found and base2.version(mysql.connector.__version__) < base2.version('2.0.0'): try: import MySQLdb.cursors except ImportError as e: - print('Python module "MySQLdb.cursors" is not installed.') - exit(3) + mysql_cursors_found = False + else: + mysql_cursors_found = True +else: + mysql_cursors_found = None def close(conn): @@ -52,7 +57,7 @@ def commit(conn): try: conn.commit() except Exception as e: - return(False, 'Error: {}'.format(e)) + return(False, u'Error: {}'.format(e)) return (True, None) @@ -69,10 +74,15 @@ def connect(mysql_connection): >>> conn = connect(mysql_connection) """ + if mysql_connector_found is False: + base2.oao('Python module "mysql.connector" is not installed.', STATE_UNKNOWN) + if mysql_connector_found is False: + base2.oao('Python module "MySQLdb.cursors" is not installed.', STATE_UNKNOWN) + try: conn = mysql.connector.connect(**mysql_connection) except Exception as e: - return(False, 'Connecting to DB failed, Error: {}'.format(e)) + return(False, u'Connecting to DB failed, Error: {}'.format(e)) return (True, conn) @@ -83,6 +93,11 @@ def select(conn, sql, data={}, fetchone=False): database. """ + if mysql_connector_found is False: + base2.oao('Python module "mysql.connector" is not installed.', STATE_UNKNOWN) + if mysql_connector_found is False: + base2.oao('Python module "MySQLdb.cursors" is not installed.', STATE_UNKNOWN) + if base2.version(mysql.connector.__version__) >= base2.version('2.0.0'): cursor = conn.cursor(dictionary=True) else: @@ -97,4 +112,4 @@ def select(conn, sql, data={}, fetchone=False): return (True, [cursor.fetchone()]) return (True, cursor.fetchall()) except Exception as e: - return(False, 'Query failed: {}, Error: {}, Data: {}'.format(sql, e, data)) + return(False, u'Query failed: {}, Error: {}, Data: {}'.format(sql, e, data)) diff --git a/db_mysql3.py b/db_mysql3.py index cafeb19..a5dcc9e 100644 --- a/db_mysql3.py +++ b/db_mysql3.py @@ -15,23 +15,28 @@ """ __author__ = 'Linuxfabrik GmbH, Zurich/Switzerland' -__version__ = '2021031001' +__version__ = '2021090701' + +from .globals3 import * try: import mysql.connector except ImportError as e: - print('Python module "mysql.connector" is not installed.') - exit(3) + mysql_connector_found = False +else: + mysql_connector_found = True from . import base3 -from . import disk3 -if base3.version(mysql.connector.__version__) < base3.version('2.0.0'): +if mysql_connector_found and base3.version(mysql.connector.__version__) < base3.version('2.0.0'): try: import MySQLdb.cursors except ImportError as e: - print('Python module "MySQLdb.cursors" is not installed.') - exit(3) + mysql_cursors_found = False + else: + mysql_cursors_found = True +else: + mysql_cursors_found = None def close(conn): @@ -69,6 +74,11 @@ def connect(mysql_connection): >>> conn = connect(mysql_connection) """ + if mysql_connector_found is False: + base3.oao('Python module "mysql.connector" is not installed.', STATE_UNKNOWN) + if mysql_connector_found is False: + base3.oao('Python module "MySQLdb.cursors" is not installed.', STATE_UNKNOWN) + try: conn = mysql.connector.connect(**mysql_connection) except Exception as e: @@ -83,6 +93,11 @@ def select(conn, sql, data={}, fetchone=False): database. """ + if mysql_connector_found is False: + base3.oao('Python module "mysql.connector" is not installed.', STATE_UNKNOWN) + if mysql_connector_found is False: + base3.oao('Python module "MySQLdb.cursors" is not installed.', STATE_UNKNOWN) + if base3.version(mysql.connector.__version__) >= base3.version('2.0.0'): cursor = conn.cursor(dictionary=True) else: diff --git a/db_sqlite2.py b/db_sqlite2.py index fe1246d..d49132d 100644 --- a/db_sqlite2.py +++ b/db_sqlite2.py @@ -26,9 +26,10 @@ """ __author__ = 'Linuxfabrik GmbH, Zurich/Switzerland' -__version__ = '2021022401' +__version__ = '2021092901' import os +import re import sqlite3 import base2 @@ -55,7 +56,7 @@ def commit(conn): try: conn.commit() except Exception as e: - return(False, 'Error: {}'.format(e)) + return(False, u'Error: {}'.format(e)) return (True, None) @@ -93,8 +94,11 @@ def get_filename(path='', filename=''): conn = sqlite3.connect(db, timeout=1) # https://stackoverflow.com/questions/3300464/how-can-i-get-dict-from-sqlite-query conn.row_factory = sqlite3.Row + # https://stackoverflow.com/questions/3425320/sqlite3-programmingerror-you-must-not-use-8-bit-bytestrings-unless-you-use-a-te + conn.text_factory = str + conn.create_function("REGEXP", 2, regexp) except Exception as e: - return(False, 'Connecting to DB {} failed, Error: {}'.format(db, e)) + return(False, u'Connecting to DB {} failed, Error: {}'.format(db, e)) return (True, conn) @@ -104,20 +108,20 @@ def create_index(conn, column_list, table='perfdata', unique=False): table = base2.filter_str(table) - index_name = 'idx_{}'.format(base2.md5sum(table + column_list)) + index_name = u'idx_{}'.format(base2.md5sum(table + column_list)) c = conn.cursor() if unique: - sql = 'CREATE UNIQUE INDEX IF NOT EXISTS {} ON "{}" ({});'.format( + sql = u'CREATE UNIQUE INDEX IF NOT EXISTS {} ON "{}" ({});'.format( index_name, table, column_list ) else: - sql = 'CREATE INDEX IF NOT EXISTS {} ON "{}" ({});'.format( + sql = u'CREATE INDEX IF NOT EXISTS {} ON "{}" ({});'.format( index_name, table, column_list ) try: c.execute(sql) except Exception as e: - return(False, 'Query failed: {}, Error: {}'.format(sql, e)) + return(False, u'Query failed: {}, Error: {}'.format(sql, e)) return (True, True) @@ -138,11 +142,11 @@ def create_table(conn, definition, table='perfdata', drop_table_first=False): return (success, result) c = conn.cursor() - sql = 'CREATE TABLE IF NOT EXISTS "{}" ({});'.format(table, definition) + sql = u'CREATE TABLE IF NOT EXISTS "{}" ({});'.format(table, definition) try: c.execute(sql) except Exception as e: - return(False, 'Query failed: {}, Error: {}'.format(sql, e)) + return(False, u'Query failed: {}, Error: {}'.format(sql, e)) return (True, True) @@ -154,13 +158,13 @@ def cut(conn, table='perfdata', max=5): table = base2.filter_str(table) c = conn.cursor() - sql = '''DELETE FROM {table} WHERE rowid IN ( + sql = u'''DELETE FROM {table} WHERE rowid IN ( SELECT rowid FROM {table} ORDER BY rowid DESC LIMIT -1 OFFSET :max );'''.format(table=table) try: c.execute(sql, (max, )) except Exception as e: - return(False, 'Query failed: {}, Error: {}'.format(sql, e)) + return(False, u'Query failed: {}, Error: {}'.format(sql, e)) return (True, True) @@ -181,7 +185,7 @@ def delete(conn, sql, data={}, fetchone=False): else: return (True, c.execute(sql).rowcount) except Exception as e: - return(False, 'Query failed: {}, Error: {}, Data: {}'.format(sql, e, data)) + return(False, u'Query failed: {}, Error: {}, Data: {}'.format(sql, e, data)) def drop_table(conn, table='perfdata'): @@ -195,12 +199,12 @@ def drop_table(conn, table='perfdata'): table = base2.filter_str(table) c = conn.cursor() - sql = 'DROP TABLE IF EXISTS "{}";'.format(table) + sql = u'DROP TABLE IF EXISTS "{}";'.format(table) try: c.execute(sql) except Exception as e: - return(False, 'Query failed: {}, Error: {}'.format(sql, e)) + return(False, u'Query failed: {}, Error: {}'.format(sql, e)) return (True, True) @@ -212,12 +216,12 @@ def insert(conn, data, table='perfdata'): table = base2.filter_str(table) c = conn.cursor() - sql = 'INSERT INTO "{}" (COLS) VALUES (VALS);'.format(table) + sql = u'INSERT INTO "{}" (COLS) VALUES (VALS);'.format(table) keys, binds = '', '' for key in data.keys(): - keys += '{},'.format(key) - binds += ':{},'.format(key) + keys += u'{},'.format(key) + binds += u':{},'.format(key) keys = keys[:-1] binds = binds[:-1] sql = sql.replace('COLS', keys).replace('VALS', binds) @@ -225,11 +229,21 @@ def insert(conn, data, table='perfdata'): try: c.execute(sql, data) except Exception as e: - return(False, 'Query failed: {}, Error: {}, Data: {}'.format(sql, e, data)) + return(False, u'Query failed: {}, Error: {}, Data: {}'.format(sql, e, data)) return (True, True) +def regexp(expr, item): + """The SQLite engine does not support a REGEXP implementation by default. This has to be + done by the client. + For Python, you have to implement REGEXP using a Python function at runtime. + https://stackoverflow.com/questions/5365451/problem-with-regexp-python-and-sqlite/5365533#5365533 + """ + reg = re.compile(expr) + return reg.search(item) is not None + + def replace(conn, data, table='perfdata'): """The REPLACE command is an alias for the "INSERT OR REPLACE" variant of the INSERT command. When a UNIQUE or PRIMARY KEY constraint violation @@ -246,12 +260,12 @@ def replace(conn, data, table='perfdata'): table = base2.filter_str(table) c = conn.cursor() - sql = 'REPLACE INTO "{}" (COLS) VALUES (VALS);'.format(table) + sql = u'REPLACE INTO "{}" (COLS) VALUES (VALS);'.format(table) keys, binds = '', '' for key in data.keys(): - keys += '{},'.format(key) - binds += ':{},'.format(key) + keys += u'{},'.format(key) + binds += u':{},'.format(key) keys = keys[:-1] binds = binds[:-1] sql = sql.replace('COLS', keys).replace('VALS', binds) @@ -259,7 +273,7 @@ def replace(conn, data, table='perfdata'): try: c.execute(sql, data) except Exception as e: - return(False, 'Query failed: {}, Error: {}, Data: {}'.format(sql, e, data)) + return(False, u'Query failed: {}, Error: {}, Data: {}'.format(sql, e, data)) return (True, True) @@ -290,7 +304,7 @@ def select(conn, sql, data={}, fetchone=False, as_dict=True): return (True, c.fetchone()) return (True, c.fetchall()) except Exception as e: - return(False, 'Query failed: {}, Error: {}, Data: {}'.format(sql, e, data)) + return(False, u'Query failed: {}, Error: {}, Data: {}'.format(sql, e, data)) def get_tables(conn): @@ -322,7 +336,7 @@ def compute_load(conn, sensorcol, datacols, count, table='perfdata'): table = base2.filter_str(table) # count the number of different sensors in the perfdata table - sql = 'SELECT DISTINCT {sensorcol} FROM {table} ORDER BY {sensorcol} ASC;'.format( + sql = u'SELECT DISTINCT {sensorcol} FROM {table} ORDER BY {sensorcol} ASC;'.format( sensorcol=sensorcol, table=table ) success, sensors = select(conn, sql) @@ -339,7 +353,7 @@ def compute_load(conn, sensorcol, datacols, count, table='perfdata'): sensor_name = sensor[sensorcol] success, perfdata = select( conn, - 'SELECT * FROM {table} WHERE {sensorcol} = :{sensorcol} ' + u'SELECT * FROM {table} WHERE {sensorcol} = :{sensorcol} ' 'ORDER BY timestamp DESC;'.format( table=table, sensorcol=sensorcol ), diff --git a/db_sqlite3.py b/db_sqlite3.py index 2e9848b..a7cdf99 100644 --- a/db_sqlite3.py +++ b/db_sqlite3.py @@ -26,9 +26,10 @@ """ __author__ = 'Linuxfabrik GmbH, Zurich/Switzerland' -__version__ = '2021022401' +__version__ = '2021092901' import os +import re import sqlite3 from . import base3, disk3 @@ -92,6 +93,9 @@ def get_filename(path='', filename=''): conn = sqlite3.connect(db, timeout=1) # https://stackoverflow.com/questions/3300464/how-can-i-get-dict-from-sqlite-query conn.row_factory = sqlite3.Row + # https://stackoverflow.com/questions/3425320/sqlite3-programmingerror-you-must-not-use-8-bit-bytestrings-unless-you-use-a-te + conn.text_factory = str + conn.create_function("REGEXP", 2, regexp) except Exception as e: return(False, 'Connecting to DB {} failed, Error: {}'.format(db, e)) return (True, conn) @@ -229,6 +233,16 @@ def insert(conn, data, table='perfdata'): return (True, True) +def regexp(expr, item): + """The SQLite engine does not support a REGEXP implementation by default. This has to be + done by the client. + For Python, you have to implement REGEXP using a Python function at runtime. + https://stackoverflow.com/questions/5365451/problem-with-regexp-python-and-sqlite/5365533#5365533 + """ + reg = re.compile(expr) + return reg.search(item) is not None + + def replace(conn, data, table='perfdata'): """The REPLACE command is an alias for the "INSERT OR REPLACE" variant of the INSERT command. When a UNIQUE or PRIMARY KEY constraint violation diff --git a/disk2.py b/disk2.py index 5a4c59a..edee3ba 100644 --- a/disk2.py +++ b/disk2.py @@ -13,7 +13,7 @@ """ __author__ = 'Linuxfabrik GmbH, Zurich/Switzerland' -__version__ = '2021061401' +__version__ = '2021092901' import csv import os @@ -77,9 +77,9 @@ def grep_file(filename, pattern): with open(filename, 'r') as file: data = file.read() except IOError as e: - return (False, 'I/O error "{}" while opening or reading {}'.format(e.strerror, filename)) + return (False, u'I/O error "{}" while opening or reading {}'.format(e.strerror, filename)) except: - return (False, 'Unknown error opening or reading {}'.format(filename)) + return (False, u'Unknown error opening or reading {}'.format(filename)) else: match = re.search(pattern, data).group(1) return (True, match) @@ -97,18 +97,17 @@ def read_csv(filename, delimiter=',', quotechar='"', newline='', as_dict=False, else: reader = csv.DictReader(csvfile, delimiter=',', quotechar='"') data = [] - is_header_row = True for row in reader: # check if the list contains empty strings only - if skip_empty_rows and all('' == row or row.isspace() for row in l): + if skip_empty_rows and all('' == s or s.isspace() for s in row): continue data.append(row) except csv.Error as e: - return (False, 'CSV error in file {}, line {}: {}'.format(filename, reader.line_num, e)) + return (False, u'CSV error in file {}, line {}: {}'.format(filename, reader.line_num, e)) except IOError as e: - return (False, 'I/O error "{}" while opening or reading {}'.format(e.strerror, filename)) + return (False, u'I/O error "{}" while opening or reading {}'.format(e.strerror, filename)) except: - return (False, 'Unknown error opening or reading {}'.format(filename)) + return (False, u'Unknown error opening or reading {}'.format(filename)) return (True, data) @@ -118,13 +117,12 @@ def read_file(filename): """ try: - f = open(filename, 'r') - data = f.read() - f.close() + with open(filename, 'r') as f: + data = f.read() except IOError as e: - return (False, 'I/O error "{}" while opening or reading {}'.format(e.strerror, filename)) + return (False, u'I/O error "{}" while opening or reading {}'.format(e.strerror, filename)) except: - return (False, 'Unknown error opening or reading {}'.format(filename)) + return (False, u'Unknown error opening or reading {}'.format(filename)) return (True, data) @@ -138,9 +136,9 @@ def rm_file(filename): try: os.remove(filename) except OSError as e: - return (False, 'OS error "{}" while deleting {}'.format(e.strerror, filename)) + return (False, u'OS error "{}" while deleting {}'.format(e.strerror, filename)) except: - return (False, 'Unknown error deleting {}'.format(filename)) + return (False, u'Unknown error deleting {}'.format(filename)) return (True, None) @@ -191,7 +189,7 @@ def write_file(filename, content, append=False): f.write(content) f.close() except IOError as e: - return (False, 'I/O error "{}" while writing {}'.format(e.strerror, filename)) + return (False, u'I/O error "{}" while writing {}'.format(e.strerror, filename)) except: - return (False, 'Unknown error writing {}, or content is not a string'.format(filename)) + return (False, u'Unknown error writing {}, or content is not a string'.format(filename)) return (True, None) diff --git a/disk3.py b/disk3.py index 6708bb5..674d77a 100644 --- a/disk3.py +++ b/disk3.py @@ -13,7 +13,7 @@ """ __author__ = 'Linuxfabrik GmbH, Zurich/Switzerland' -__version__ = '2021061401' +__version__ = '2021092901' import csv import os @@ -97,10 +97,9 @@ def read_csv(filename, delimiter=',', quotechar='"', newline='', as_dict=False, else: reader = csv.DictReader(csvfile, delimiter=',', quotechar='"') data = [] - is_header_row = True for row in reader: # check if the list contains empty strings only - if skip_empty_rows and all('' == row or row.isspace() for row in l): + if skip_empty_rows and all('' == s or s.isspace() for s in row): continue data.append(row) except csv.Error as e: @@ -118,9 +117,8 @@ def read_file(filename): """ try: - f = open(filename, 'r') - data = f.read() - f.close() + with open(filename, 'r') as f: + data = f.read() except IOError as e: return (False, 'I/O error "{}" while opening or reading {}'.format(e.strerror, filename)) except: diff --git a/feedparser2.py b/feedparser2.py index c21cf1e..d7600bf 100644 --- a/feedparser2.py +++ b/feedparser2.py @@ -23,7 +23,7 @@ import url2 __author__ = 'Linuxfabrik GmbH, Zurich/Switzerland' -__version__ = '2021050601' +__version__ = '2021082501' def parse_atom(soup): @@ -100,5 +100,5 @@ def parse(feed_url, insecure=False, no_proxy=False, timeout=5, encoding='urlenco if is_rss is not None: return (True, parse_rss(soup)) - return (False, '{} does not seem to be an Atom or RSS feed I understand.'.format(feed_url)) + return (False, u'{} does not seem to be an Atom or RSS feed I understand.'.format(feed_url)) diff --git a/icinga2.py b/icinga2.py index fb901fe..9bbb386 100644 --- a/icinga2.py +++ b/icinga2.py @@ -12,7 +12,7 @@ """ __author__ = 'Linuxfabrik GmbH, Zurich/Switzerland' -__version__ = '2021022501' +__version__ = '2021082501' import base64 import time @@ -73,7 +73,7 @@ def get_service(url, username, password, servicename, attrs='state'): url = url + '/v1/objects/services' data = { - 'filter': 'match("{}", service.__name)'.format(servicename), + u'filter': 'match("{}", service.__name)'.format(servicename), 'attrs': ['name'] + attrs.split(','), } return api_post(url=url, username=username, password=password, @@ -96,7 +96,7 @@ def set_ack(url, username, password, objectname, type='service', url = url + '/v1/actions/acknowledge-problem' data = { 'type': type.capitalize(), - 'filter': 'match("{}", {}.__name)'.format(objectname, type.lower()), + 'filter': u'match("{}", {}.__name)'.format(objectname, type.lower()), 'author': author, 'comment': 'automatically acknowledged', 'notify': False, @@ -130,7 +130,7 @@ def set_downtime(url, username, password, objectname, type='service', url = url + '/v1/actions/schedule-downtime' data = { 'type': type.capitalize(), - 'filter': 'match("{}", {}.__name)'.format(objectname, type.lower()), + 'filter': u'match("{}", {}.__name)'.format(objectname, type.lower()), 'author': author, 'comment': 'automatic downtime', 'start_time': starttime, @@ -160,7 +160,7 @@ def remove_ack(url, username, password, objectname, type='service'): url = url + '/v1/actions/remove-acknowledgement' data = { 'type': type.capitalize(), - 'filter': 'match("{}", {}.__name)'.format(objectname, type.lower()), + 'filter': u'match("{}", {}.__name)'.format(objectname, type.lower()), } return api_post(url=url, username=username, password=password, data=data, insecure=True) diff --git a/jitsi3.py b/jitsi3.py new file mode 100644 index 0000000..21c23c6 --- /dev/null +++ b/jitsi3.py @@ -0,0 +1,42 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8; py-indent-offset: 4 -*- +# +# Author: Linuxfabrik GmbH, Zurich, Switzerland +# Contact: info (at) linuxfabrik (dot) ch +# https://www.linuxfabrik.ch/ +# License: The Unlicense, see LICENSE file. + +# https://git.linuxfabrik.ch/linuxfabrik-icinga-plugins/checks-linux/-/blob/master/CONTRIBUTING.md + +"""This library collects some Jitsi related functions that are +needed by more than one Jitsi plugin.""" + +__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland' +__version__ = '2021083001' + +import base64 # pylint: disable=C0413 + +from . import base3 +from . import url3 + + +def get_data(args, _type='json'): + """Calls args.URL, optionally using args.USERNAME and args.PASSWORD, + taking args.TIMEOUT into account, returning JSON (`type='json'`) or raw data (else). + """ + if args.USERNAME is None: + if _type == 'json': + success, result = url3.fetch_json(args.URL, timeout=args.TIMEOUT, insecure=True) + else: + success, result = url3.fetch(args.URL, timeout=args.TIMEOUT, insecure=True, extended=True) + else: + header = {} + header['Authorization'] = 'Basic {}'.format(base64.b64encode(args.USERNAME + ':' + args.PASSWORD)) + if _type == 'json': + success, result = url3.fetch_json(args.URL, header=header, timeout=args.TIMEOUT, insecure=True) + else: + success, result = url3.fetch(args.URL, header=header, timeout=args.TIMEOUT, insecure=True, extended=True) + + if not success: + return (success, result, False) + return (True, result) diff --git a/librenms2.py b/librenms2.py index 8a80fba..817dadb 100644 --- a/librenms2.py +++ b/librenms2.py @@ -12,7 +12,7 @@ needed by LibreNMS check plugins.""" __author__ = 'Linuxfabrik GmbH, Zurich/Switzerland' -__version__ = '2021042801' +__version__ = '2021082501' from globals2 import STATE_OK, STATE_UNKNOWN, STATE_WARN, STATE_CRIT @@ -29,7 +29,7 @@ def get_data(args, url=''): insecure=args.INSECURE, no_proxy=args.NO_PROXY, )) if result['status'] != 'ok': - base2.oao('Error fetching data: "{}"'.format(res), STATE_UNKNOWN, perfdata, always_ok=args.ALWAYS_OK) + base2.oao(u'Error fetching data: "{}"'.format(res), STATE_UNKNOWN, perfdata, always_ok=args.ALWAYS_OK) return result @@ -38,7 +38,7 @@ def get_prop(obj, prop, mytype='str'): if mytype == 'str': if prop in obj: if obj[prop] is not None: - return obj[prop].encode('utf-8') + return obj[prop].encode('utf-8', 'replace') return '' else: if prop in obj: diff --git a/librenms3.py b/librenms3.py index 9663cbe..76e3c9a 100644 --- a/librenms3.py +++ b/librenms3.py @@ -12,9 +12,9 @@ needed by LibreNMS check plugins.""" __author__ = 'Linuxfabrik GmbH, Zurich/Switzerland' -__version__ = '2021042801' +__version__ = '2021071501' -from globals3 import STATE_OK, STATE_UNKNOWN, STATE_WARN, STATE_CRIT +from .globals3 import STATE_CRIT, STATE_OK, STATE_UNKNOWN, STATE_WARN from . import base3 from . import url3 diff --git a/net2.py b/net2.py index e188002..f44a0e9 100644 --- a/net2.py +++ b/net2.py @@ -13,8 +13,9 @@ """ __author__ = 'Linuxfabrik GmbH, Zurich/Switzerland' -__version__ = '2021061501' +__version__ = '2021092901' +import random import re import socket try: @@ -121,35 +122,75 @@ ) +def fetch(host, port, msg=None, timeout=3, ipv6=False): + """Fetch data via a TCP/IP socket connection. You may optionally send a msg first. + Supports both IPv4 and IPv6. + Taken from https://docs.python.org/3/library/socket.html, enhanced. + """ + try: + if ipv6: + s = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) + else: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(int(timeout)) + s.connect((host, int(port))) + except: + return (False, 'Could not open socket.') + + if msg is not None: + try: + s.sendall(msg) + except: + return (False, u'Could not send payload "{}".'.format(msg)) + + fragments = [] + while True: + try: + chunk = s.recv(1024) + if not chunk: + break + fragments.append(chunk) + except socket.timeout as e: + # non-blocking behavior via a time out with socket.settimeout(n) + err = e.args[0] + # this next if/else is a bit redundant, but illustrates how the + # timeout exception is setup + if err == 'timed out': + return (False, 'Socket timed out.') + else: + return (False, u'Can\'t fetch data: {}'.format(e)) + except socket.error as e: + # Something else happened, handle error, exit, etc. + return (False, u'Can\'t fetch data: {}'.format(e)) + + try: + s.close() + except: + s = None + + return (True, ''.join(fragments)) + + def get_ip_public(): """Retrieve the public IP address from a list of online services. """ - # List of tuple (url, json, key), from fastest to slowest. - # - url: URL of the Web site - # - json: service return a JSON (True) or string (False) - # - key: key of the IP addresse in the JSON structure urls = [ - ('https://ip.42.pl/raw', False, None), - ('https://api.ipify.org/?format=json', True, 'ip'), - ('https://httpbin.org/ip', True, 'origin'), - ('https://jsonip.com', True, 'ip'), + 'http://ipv4.icanhazip.com', + 'http://ipecho.net/plain', + 'http://ipinfo.io/ip' ] + random.shuffle(urls) ip = None - for url, json, key in urls: - # Request the url service and put the result in the queue_target. - if json: - success, result = url2.fetch_json(url) - ip = result.getattr(key, None) - else: - success, ip = url2.fetch(url) - if ip: - break - - try: - return ip.decode() - except: - return ip + for url in urls: + success, ip = url2.fetch(url, timeout=2) + if success and ip: + ip = ip.strip() + try: + return (True, ip.decode('utf-8')) + except: + return (True, ip) + return (False, ip) def get_netinfo(): @@ -232,4 +273,3 @@ def is_valid_absolute_hostname(hostname): """ return not hostname.endswith(".") and is_valid_hostname(hostname) - diff --git a/net3.py b/net3.py index 7fface7..22914c0 100644 --- a/net3.py +++ b/net3.py @@ -13,8 +13,9 @@ """ __author__ = 'Linuxfabrik GmbH, Zurich/Switzerland' -__version__ = '2021061501' +__version__ = '2021092801' +import random import re import socket try: @@ -121,35 +122,75 @@ ) +def fetch(host, port, msg=None, timeout=3, ipv6=False): + """Fetch data via a TCP/IP socket connection. You may optionally send a msg first. + Supports both IPv4 and IPv6. + Taken from https://docs.python.org/3/library/socket.html, enhanced. + """ + try: + if ipv6: + s = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) + else: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(int(timeout)) + s.connect((host, int(port))) + except: + return (False, 'Could not open socket.') + + if msg is not None: + try: + s.sendall(msg) + except: + return (False, 'Could not send payload "{}".'.format(msg)) + + fragments = [] + while True: + try: + chunk = s.recv(1024) + if not chunk: + break + fragments.append(chunk) + except socket.timeout as e: + # non-blocking behavior via a time out with socket.settimeout(n) + err = e.args[0] + # this next if/else is a bit redundant, but illustrates how the + # timeout exception is setup + if err == 'timed out': + return (False, 'Socket timed out.') + else: + return (False, 'Can\'t fetch data: {}'.format(e)) + except socket.error as e: + # Something else happened, handle error, exit, etc. + return (False, 'Can\'t fetch data: {}'.format(e)) + + try: + s.close() + except: + s = None + + return (True, ''.join(fragments)) + + def get_ip_public(): """Retrieve the public IP address from a list of online services. """ - # List of tuple (url, json, key), from fastest to slowest. - # - url: URL of the Web site - # - json: service return a JSON (True) or string (False) - # - key: key of the IP addresse in the JSON structure urls = [ - ('https://ip.42.pl/raw', False, None), - ('https://api.ipify.org/?format=json', True, 'ip'), - ('https://httpbin.org/ip', True, 'origin'), - ('https://jsonip.com', True, 'ip'), + 'http://ipv4.icanhazip.com', + 'http://ipecho.net/plain', + 'http://ipinfo.io/ip' ] + random.shuffle(urls) ip = None - for url, json, key in urls: - # Request the url service and put the result in the queue_target. - if json: - success, result = url3.fetch_json(url) - ip = result.getattr(key, None) - else: - success, ip = url3.fetch(url) - if ip: - break - - try: - return ip.decode() - except: - return ip + for url in urls: + success, ip = url3.fetch(url, timeout=2) + if success and ip: + ip = ip.strip() + try: + return (True, ip.decode()) + except: + return (True, ip) + return (False, ip) def get_netinfo(): diff --git a/nodebb2.py b/nodebb2.py new file mode 100644 index 0000000..1c9ad34 --- /dev/null +++ b/nodebb2.py @@ -0,0 +1,33 @@ +#! /usr/bin/env python2 +# -*- coding: utf-8; py-indent-offset: 4 -*- +# +# Author: Linuxfabrik GmbH, Zurich, Switzerland +# Contact: info (at) linuxfabrik (dot) ch +# https://www.linuxfabrik.ch/ +# License: The Unlicense, see LICENSE file. + +# https://git.linuxfabrik.ch/linuxfabrik-icinga-plugins/checks-linux/-/blob/master/CONTRIBUTING.md + +"""This library collects some NodeBB related functions that are +needed by more than one NodeBB plugin.""" + +__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland' +__version__ = '2021090701' + +import base2 +import url2 + + +def get_data(args, url=''): + """Fetch json from the NodeBB API using an user token. For details have a look at + https://docs.nodebb.org/api/ + """ + return base2.coe(url2.fetch_json( + args.URL + url, + insecure=args.INSECURE, + timeout=args.TIMEOUT, + header={ + 'Accept': 'application/json', + 'Authorization': 'Bearer {}'.format(args.TOKEN), + }, + )) diff --git a/nodebb3.py b/nodebb3.py new file mode 100644 index 0000000..6aae8aa --- /dev/null +++ b/nodebb3.py @@ -0,0 +1,33 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8; py-indent-offset: 4 -*- +# +# Author: Linuxfabrik GmbH, Zurich, Switzerland +# Contact: info (at) linuxfabrik (dot) ch +# https://www.linuxfabrik.ch/ +# License: The Unlicense, see LICENSE file. + +# https://git.linuxfabrik.ch/linuxfabrik-icinga-plugins/checks-linux/-/blob/master/CONTRIBUTING.md + +"""This library collects some NodeBB related functions that are +needed by more than one NodeBB plugin.""" + +__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland' +__version__ = '2021090701' + +from . import base3 +from . import url3 + + +def get_data(args, url=''): + """Fetch json from the NodeBB API using an user token. For details have a look at + https://docs.nodebb.org/api/ + """ + return base3.coe(url3.fetch_json( + args.URL + url, + insecure=args.INSECURE, + timeout=args.TIMEOUT, + header={ + 'Accept': 'application/json', + 'Authorization': 'Bearer {}'.format(args.TOKEN), + }, + )) diff --git a/psutil2.py b/psutil2.py index 7135dc8..4108180 100644 --- a/psutil2.py +++ b/psutil2.py @@ -12,7 +12,7 @@ """ __author__ = 'Linuxfabrik GmbH, Zurich/Switzerland' -__version__ = '2021050301' +__version__ = '2021082501' import sys diff --git a/rocket2.py b/rocket2.py index 92bd738..c8d2eff 100644 --- a/rocket2.py +++ b/rocket2.py @@ -12,7 +12,7 @@ needed by more than one Rocket.Chat plugin.""" __author__ = 'Linuxfabrik GmbH, Zurich/Switzerland' -__version__ = '2020043001' +__version__ = '2021082501' import url2 @@ -37,7 +37,7 @@ def get_token(rc_url, user, password): if not success: return (success, result) if not result: - return (False, 'There was no result from {}.'.format(rc_url)) + return (False, u'There was no result from {}.'.format(rc_url)) if not 'authToken' in result['data']: return (False, 'Something went wrong, maybe user is unauthorized.') @@ -65,6 +65,6 @@ def get_stats(rc_url, auth_token, user_id): if not success: return (success, result) if not result: - return (False, 'There was no result from {}.'.format(rc_url)) + return (False, u'There was no result from {}.'.format(rc_url)) return (True, result) diff --git a/smb2.py b/smb2.py index 9841fbd..2e19eb0 100644 --- a/smb2.py +++ b/smb2.py @@ -12,7 +12,7 @@ """ __author__ = 'Linuxfabrik GmbH, Zurich/Switzerland' -__version__ = '2021021501' +__version__ = '2021082501' import warnings @@ -32,7 +32,7 @@ def open_file(path, username, password, timeout, encrypt=True): if missing_smb_lib: - return (False, 'Python module "{}" is not installed.'.format(missing_smb_lib)) + return (False, u'Python module "{}" is not installed.'.format(missing_smb_lib)) try: return (True, smbclient.open_file( path, @@ -45,14 +45,14 @@ def open_file(path, username, password, timeout, encrypt=True): except (smbprotocol.exceptions.SMBAuthenticationError, smbprotocol.exceptions.LogonFailure): return (False, 'Login failed') except smbprotocol.exceptions.SMBOSError as e: - return (False, 'I/O error "{}" while opening or reading {}'.format(e.strerror, path)) + return (False, u'I/O error "{}" while opening or reading {}'.format(e.strerror, path)) except Exception as e: - return (False, 'Unknown error opening or reading {}:\n{}'.format(path, e)) + return (False, u'Unknown error opening or reading {}:\n{}'.format(path, e)) def glob(path, username, password, timeout, pattern='*', encrypt=True): if missing_smb_lib: - return (False, 'Python module "{}" is not installed.'.format(missing_smb_lib)) + return (False, u'Python module "{}" is not installed.'.format(missing_smb_lib)) try: file_entry = smbclient._os.SMBDirEntry.from_path( path, @@ -80,6 +80,6 @@ def glob(path, username, password, timeout, pattern='*', encrypt=True): except smbprotocol.exceptions.SMBOSError as e: if e.strerror == 'No such file or directory': return (True, []) - return (False, 'I/O error "{}" while opening or reading {}'.format(e.strerror, path)) + return (False, u'I/O error "{}" while opening or reading {}'.format(e.strerror, path)) except Exception as e: - return (False, 'Unknown error opening or reading {}:\n{}'.format(path, e)) + return (False, u'Unknown error opening or reading {}:\n{}'.format(path, e)) diff --git a/test2.py b/test2.py new file mode 100644 index 0000000..f3fffbd --- /dev/null +++ b/test2.py @@ -0,0 +1,42 @@ +#! /usr/bin/env python2 +# -*- coding: utf-8; py-indent-offset: 4 -*- +# +# Author: Linuxfabrik GmbH, Zurich, Switzerland +# Contact: info (at) linuxfabrik (dot) ch +# https://www.linuxfabrik.ch/ +# License: The Unlicense, see LICENSE file. + +# https://git.linuxfabrik.ch/linuxfabrik-icinga-plugins/checks-linux/-/blob/master/CONTRIBUTING.md + +"""Provides test functions for unit tests. +""" + +import os + +import disk2 + + +__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland' +__version__ = '2021082501' + + +def test(args): + """Returns the content of two files as well as the provided return code. The first file stands + for STDOUT, the second for STDERR. The function can be used to enable unit tests. + + >>> test('path/to/stdout.txt', 'path/to/stderr.txt', 128) + """ + if args[0] and os.path.isfile(args[0]): + success, stdout = disk2.read_file(args[0]) + else: + stdout = args[0] + if args[1] and os.path.isfile(args[1]): + success, stderr = disk2.read_file(args[1]) + else: + stderr = args[1] + if args[2] == '': + retc = 0 + else: + retc = int(args[2]) + + return stdout, stderr, retc diff --git a/test3.py b/test3.py new file mode 100644 index 0000000..cc96269 --- /dev/null +++ b/test3.py @@ -0,0 +1,42 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8; py-indent-offset: 4 -*- +# +# Author: Linuxfabrik GmbH, Zurich, Switzerland +# Contact: info (at) linuxfabrik (dot) ch +# https://www.linuxfabrik.ch/ +# License: The Unlicense, see LICENSE file. + +# https://git.linuxfabrik.ch/linuxfabrik-icinga-plugins/checks-linux/-/blob/master/CONTRIBUTING.md + +"""Provides test functions for unit tests. +""" + +import os + +from . import disk3 + + +__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland' +__version__ = '2021082501' + + +def test(args): + """Returns the content of two files as well as the provided return code. The first file stands + for STDOUT, the second for STDERR. The function can be used to enable unit tests. + + >>> test('path/to/stdout.txt', 'path/to/stderr.txt', 128) + """ + if args[0] and os.path.isfile(args[0]): + success, stdout = disk3.read_file(args[0]) + else: + stdout = args[0] + if args[1] and os.path.isfile(args[1]): + success, stderr = disk3.read_file(args[1]) + else: + stderr = args[1] + if args[2] == '': + retc = 0 + else: + retc = int(args[2]) + + return stdout, stderr, retc diff --git a/url2.py b/url2.py index 1f2ecc8..c242188 100644 --- a/url2.py +++ b/url2.py @@ -12,7 +12,7 @@ """ __author__ = 'Linuxfabrik GmbH, Zurich/Switzerland' -__version__ = '2021050602' +__version__ = '2021083001' import json import re @@ -24,21 +24,22 @@ def fetch(url, insecure=False, no_proxy=False, timeout=8, header={}, data={}, encoding='urlencode', - digest_auth_user=None, digest_auth_password=None): + digest_auth_user=None, digest_auth_password=None, + extended=False): """Fetch any URL. + If using `extended=True`, the result is returned as a dict, also including the response header + and the HTTP status code. + Basic authentication: - >>> header = { - 'Authorization': "Basic {}".format( - base64.b64encode(username + ':' + password) - ) - } - >>> result = fetch(URL) + >>> auth = args.USERNAME + ':' + args.PASSWORD + >>> encoded_auth = base64.b64encode(auth.encode()).decode() + >>> result = lib.base3.coe(lib.url3.fetch(url, timeout=args.TIMEOUT, + header={'Authorization': 'Basic {}'.format(encoded_auth)})) POST: the HTTP request will be a POST instead of a GET when the data parameter is provided >>> result = fetch(URL, header=header, data={...}) """ - try: if digest_auth_user is not None and digest_auth_password is not None: # HTTP Digest Authentication @@ -63,6 +64,8 @@ def fetch(url, insecure=False, no_proxy=False, timeout=8, request.add_header(key, value) # close http connections by myself request.add_header('Connection', 'close') + # identify as Linuxfabrik Monitoring-Plugin + request.add_header('User-Agent', 'Linuxfabrik Monitoring Plugins') # SSL/TLS certificate validation # see: https://stackoverflow.com/questions/19268548/python-ignore-certificate-validation-urllib2 @@ -84,37 +87,41 @@ def fetch(url, insecure=False, no_proxy=False, timeout=8, except urllib2.HTTPError as e: # hide passwords url = re.sub(r'(token|password)=([^&]+)', r'\1********', url) - return (False, 'HTTP error "{} {}" while fetching {}'.format(e.code, e.reason, url)) + return (False, u'HTTP error "{} {}" while fetching {}'.format(e.code, e.reason, url)) except urllib2.URLError as e: # hide passwords url = re.sub(r'(token|password)=([^&]+)', r'\1********', url) - return (False, 'URL error "{}" for {}'.format(e.reason, url)) + return (False, u'URL error "{}" for {}'.format(e.reason, url)) except TypeError as e: - return (False, 'Type error "{}", data="{}"'.format(e, data)) + return (False, u'Type error "{}", data="{}"'.format(e, data)) except: # hide passwords url = re.sub(r'(token|password)=([^&]+)', r'\1********', url) - return (False, 'Unknown error while fetching {}, maybe timeout or ' + return (False, u'Unknown error while fetching {}, maybe timeout or ' 'error on webserver'.format(url)) else: try: - result = response.read() - except SSLError as e: - return (False, 'SSL error "{}" while fetching {}'.format(e, url)) + if not extended: + result = response.read() + else: + result = {} + result['response'] = response.read() + result['status_code'] = response.getcode() + result['response_header'] = response.info() except: - return (False, 'Unknown error while fetching {}, maybe timeout or ' + return (False, u'Unknown error while fetching {}, maybe timeout or ' 'error on webserver'.format(url)) return (True, result) def fetch_json(url, insecure=False, no_proxy=False, timeout=8, header={}, data={}, encoding='urlencode', - digest_auth_user=None, digest_auth_password=None): + digest_auth_user=None, digest_auth_password=None, + extended=False): """Fetch JSON from an URL. >>> fetch_json('https://1.2.3.4/api/v2/?resource=cpu') """ - success, jsonst = fetch(url, insecure=insecure, no_proxy=no_proxy, timeout=timeout, header=header, data=data, encoding=encoding, digest_auth_user=digest_auth_user, digest_auth_password=digest_auth_password) @@ -127,13 +134,36 @@ def fetch_json(url, insecure=False, no_proxy=False, timeout=8, return (True, result) +def fetch_json_ext(url, insecure=False, no_proxy=False, timeout=8, + header={}, data={}, encoding='urlencode', + digest_auth_user=None, digest_auth_password=None): + """Fetch JSON from an URL, extended version of fetch_json(). + Returns the response body plus response header. + + >>> success, result, response_header = url2.fetch_json_ext( + args.URL, header=header, data=data, timeout=timeout, insecure=True) + >>> print(response_header['X-RestSvcSessionId']) + NGY5NzI2MDgtMjU3My00MmEzLThiNDEtOWYxZmJkNzI2ZDZl + """ + success, jsonst, response_header = fetch_ext( + url, insecure=insecure, no_proxy=no_proxy, timeout=timeout, + header=header, data=data, encoding=encoding, + digest_auth_user=digest_auth_user, digest_auth_password=digest_auth_password) + if not success: + return (False, jsonst, False) + try: + result = json.loads(jsonst) + except: + return (False, 'ValueError: No JSON object could be decoded', False) + return (True, result, response_header) + + def get_latest_version_from_github(user, repo, key='tag_name'): """Get the newest release tag from a GitHub repo. >>> get_latest_version_from_github('matomo-org', 'matomo') """ - - github_url = 'https://api.github.com/repos/{}/{}/releases/latest'.format(user, repo) + github_url = u'https://api.github.com/repos/{}/{}/releases/latest'.format(user, repo) success, result = fetch_json(github_url) if not success: return (success, result) @@ -147,5 +177,4 @@ def get_latest_version_from_github(user, repo, key='tag_name'): def strip_tags(html): """Tries to return a string with all HTML tags stripped from a given string. """ - return re.sub(r'<[^<]+?>', '', html) diff --git a/url3.py b/url3.py index a80c4d3..152100a 100644 --- a/url3.py +++ b/url3.py @@ -12,7 +12,7 @@ """ __author__ = 'Linuxfabrik GmbH, Zurich/Switzerland' -__version__ = '2021050602' +__version__ = '2021083001' import json import re @@ -24,9 +24,13 @@ def fetch(url, insecure=False, no_proxy=False, timeout=8, header={}, data={}, encoding='urlencode', - digest_auth_user=None, digest_auth_password=None): + digest_auth_user=None, digest_auth_password=None, + extended=False): """Fetch any URL. + If using `extended=True`, the result is returned as a dict, also including the response header + and the HTTP status code. + Basic authentication: >>> auth = args.USERNAME + ':' + args.PASSWORD >>> encoded_auth = base64.b64encode(auth.encode()).decode() @@ -36,7 +40,6 @@ def fetch(url, insecure=False, no_proxy=False, timeout=8, POST: the HTTP request will be a POST instead of a GET when the data parameter is provided >>> result = fetch(URL, header=header, data={...}) """ - try: if digest_auth_user is not None and digest_auth_password is not None: # HTTP Digest Authentication @@ -62,6 +65,8 @@ def fetch(url, insecure=False, no_proxy=False, timeout=8, request.add_header(key, value) # close http connections by myself request.add_header('Connection', 'close') + # identify as Linuxfabrik Monitoring-Plugin + request.add_header('User-Agent', 'Linuxfabrik Monitoring Plugins') # SSL/TLS certificate validation # see: https://stackoverflow.com/questions/19268548/python-ignore-certificate-validation-urllib2 @@ -97,9 +102,13 @@ def fetch(url, insecure=False, no_proxy=False, timeout=8, 'error on webserver'.format(url)) else: try: - result = response.read() - except SSLError as e: - return (False, 'SSL error "{}" while fetching {}'.format(e, url)) + if not extended: + result = response.read() + else: + result = {} + result['response'] = response.read() + result['status_code'] = response.getcode() + result['response_header'] = response.info() except: return (False, 'Unknown error while fetching {}, maybe timeout or ' 'error on webserver'.format(url)) @@ -108,12 +117,12 @@ def fetch(url, insecure=False, no_proxy=False, timeout=8, def fetch_json(url, insecure=False, no_proxy=False, timeout=8, header={}, data={}, encoding='urlencode', - digest_auth_user=None, digest_auth_password=None): + digest_auth_user=None, digest_auth_password=None, + extended=False): """Fetch JSON from an URL. >>> fetch_json('https://1.2.3.4/api/v2/?resource=cpu') """ - success, jsonst = fetch(url, insecure=insecure, no_proxy=no_proxy, timeout=timeout, header=header, data=data, encoding=encoding, digest_auth_user=digest_auth_user, digest_auth_password=digest_auth_password) @@ -126,12 +135,35 @@ def fetch_json(url, insecure=False, no_proxy=False, timeout=8, return (True, result) +def fetch_json_ext(url, insecure=False, no_proxy=False, timeout=8, + header={}, data={}, encoding='urlencode', + digest_auth_user=None, digest_auth_password=None): + """Fetch JSON from an URL, extended version of fetch_json(). + Returns the response body plus response header. + + >>> success, result, response_header = url2.fetch_json_ext( + args.URL, header=header, data=data, timeout=timeout, insecure=True) + >>> print(response_header['X-RestSvcSessionId']) + NGY5NzI2MDgtMjU3My00MmEzLThiNDEtOWYxZmJkNzI2ZDZl + """ + success, jsonst, response_header = fetch_ext( + url, insecure=insecure, no_proxy=no_proxy, timeout=timeout, + header=header, data=data, encoding=encoding, + digest_auth_user=digest_auth_user, digest_auth_password=digest_auth_password) + if not success: + return (False, jsonst, False) + try: + result = json.loads(jsonst) + except: + return (False, 'ValueError: No JSON object could be decoded', False) + return (True, result, response_header) + + def get_latest_version_from_github(user, repo, key='tag_name'): """Get the newest release tag from a GitHub repo. >>> get_latest_version_from_github('matomo-org', 'matomo') """ - github_url = 'https://api.github.com/repos/{}/{}/releases/latest'.format(user, repo) success, result = fetch_json(github_url) if not success: @@ -146,5 +178,4 @@ def get_latest_version_from_github(user, repo, key='tag_name'): def strip_tags(html): """Tries to return a string with all HTML tags stripped from a given string. """ - return re.sub(r'<[^<]+?>', '', html) diff --git a/veeam2.py b/veeam2.py new file mode 100644 index 0000000..8178f6d --- /dev/null +++ b/veeam2.py @@ -0,0 +1,50 @@ +#! /usr/bin/env python2 +# -*- coding: utf-8; py-indent-offset: 4 -*- +# +# Author: Linuxfabrik GmbH, Zurich, Switzerland +# Contact: info (at) linuxfabrik (dot) ch +# https://www.linuxfabrik.ch/ +# License: The Unlicense, see LICENSE file. + +# https://git.linuxfabrik.ch/linuxfabrik-icinga-plugins/checks-linux/-/blob/master/CONTRIBUTING.md + +"""This library interacts with the Veeam Enterprise Manager API. +Credits go to https://github.com/surfer190/veeam/blob/master/veeam/client.py.""" + +__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland' +__version__ = '2021082501' + +import base64 + +import url2 + + +def get_token(args): + """Login like + `curl --request POST + --header "Authorization: Basic $(echo -n 'user:password' | base64)" + --header "Accept: application/json" + --header "Content-Length: 0" + https://veeam:9398/api/sessionMngr/?v=latest` + and return allowed methods and the `X-RestSvcSessionId` token + (looks like `ZWIwMDkzODMtM2YzNy00MDJjLThlNzMtZDEwY2E4ZmU5MzYx`). + """ + url = args.URL + '/api/sessionMngr/?v=latest' + header = {} + # Basic authentication + header['Authorization'] = u"Basic {}".format( + base64.b64encode(args.USERNAME + ':' + args.PASSWORD)) + header['Accept'] = 'application/json' + header['Content-Length'] = 0 + # make this a POST request by filling data with anything + data = {'make-this': 'a-post-request'} + + success, result, response_header = url2.fetch_json_ext(url, header=header, data=data, + timeout=args.TIMEOUT, insecure=True) + if not success: + return (success, result, False) + if not result: + return (False, u'There was no result from {}.'.format(url), False) + if not 'X-RestSvcSessionId' in response_header: + return (False, 'Something went wrong, maybe user is unauthorized.', False) + return (True, result, response_header['X-RestSvcSessionId']) diff --git a/veeam3.py b/veeam3.py new file mode 100644 index 0000000..82f862c --- /dev/null +++ b/veeam3.py @@ -0,0 +1,50 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8; py-indent-offset: 4 -*- +# +# Author: Linuxfabrik GmbH, Zurich, Switzerland +# Contact: info (at) linuxfabrik (dot) ch +# https://www.linuxfabrik.ch/ +# License: The Unlicense, see LICENSE file. + +# https://git.linuxfabrik.ch/linuxfabrik-icinga-plugins/checks-linux/-/blob/master/CONTRIBUTING.md + +"""This library interacts with the Veeam Enterprise Manager API. +Credits go to https://github.com/surfer190/veeam/blob/master/veeam/client.py.""" + +__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland' +__version__ = '2021072801' + +import base64 + +from . import url3 + + +def get_token(args): + """Login like + `curl --request POST + --header "Authorization: Basic $(echo -n 'user:password' | base64)" + --header "Accept: application/json" + --header "Content-Length: 0" + https://veeam:9398/api/sessionMngr/?v=latest` + and return allowed methods and the `X-RestSvcSessionId` token + (looks like `ZWIwMDkzODMtM2YzNy00MDJjLThlNzMtZDEwY2E4ZmU5MzYx`). + """ + url = args.URL + '/api/sessionMngr/?v=latest' + header = {} + # Basic authentication + auth = args.USERNAME + ':' + args.PASSWORD + encoded_auth = base64.b64encode(auth.encode()).decode() + header['Authorization'] = 'Basic {}'.format(encoded_auth) + header['Accept'] = 'application/json' + header['Content-Length'] = 0 + # make this a POST request by filling data with anything + data = {'make-this': 'a-post-request'} + success, result, response_header = url3.fetch_json_ext(url, header=header, data=data, + timeout=args.TIMEOUT, insecure=True) + if not success: + return (success, result, False) + if not result: + return (False, 'There was no result from {}.'.format(url), False) + if not 'X-RestSvcSessionId' in response_header: + return (False, 'Something went wrong, maybe user is unauthorized.', False) + return (True, result, response_header['X-RestSvcSessionId']) diff --git a/wildfly2.py b/wildfly2.py index 89b8504..c05e92e 100644 --- a/wildfly2.py +++ b/wildfly2.py @@ -12,7 +12,7 @@ needed by more than one WildFly/JBoss plugin.""" __author__ = 'Linuxfabrik GmbH, Zurich/Switzerland' -__version__ = '2021041901' +__version__ = '2021082501' import base2 import url2 @@ -21,7 +21,7 @@ def get_data(args, data, url=''): url = args.URL + '/management' + url if args.MODE == 'domain': - url = '/host/{}/server/{}'.format(args.NODE, args.INSTANCE) + url + url = u'/host/{}/server/{}'.format(args.NODE, args.INSTANCE) + url header = {'Content-Type': 'application/json'} result = base2.coe(url2.fetch_json( url, timeout=args.TIMEOUT, @@ -30,7 +30,7 @@ def get_data(args, data, url=''): encoding='serialized-json' )) if result['outcome'] != 'success': - base2.oao('Error fetching data: "{}"'.format(res), STATE_UNKNOWN, perfdata, always_ok=args.ALWAYS_OK) + base2.oao(u'Error fetching data: "{}"'.format(res), STATE_UNKNOWN, perfdata, always_ok=args.ALWAYS_OK) return result['result']