From 086b9efcaf1c64693a2083dc3ef6115de7ae7b30 Mon Sep 17 00:00:00 2001 From: zreigz Date: Thu, 12 Apr 2018 13:33:53 +0200 Subject: [PATCH] Add alarm expression parser Change-Id: I197d49a6b27d9f02b5e9b10359051052112c2a15 Story: 2001837 Task: 12601 --- monasca_common/expression_parser/__init__.py | 0 .../expression_parser/alarm_expr_parser.py | 379 ++++++++++++++++++ 2 files changed, 379 insertions(+) create mode 100644 monasca_common/expression_parser/__init__.py create mode 100644 monasca_common/expression_parser/alarm_expr_parser.py diff --git a/monasca_common/expression_parser/__init__.py b/monasca_common/expression_parser/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/monasca_common/expression_parser/alarm_expr_parser.py b/monasca_common/expression_parser/alarm_expr_parser.py new file mode 100644 index 00000000..e9e5315d --- /dev/null +++ b/monasca_common/expression_parser/alarm_expr_parser.py @@ -0,0 +1,379 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# (C) Copyright 2015-2017 Hewlett Packard Enterprise LP +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import sys + +import pyparsing +import six + +_DETERMINISTIC_ASSIGNMENT_LEN = 3 +_DETERMINISTIC_ASSIGNMENT_SHORT_LEN = 1 +_DETERMINISTIC_ASSIGNMENT_VALUE_INDEX = 2 +_DEFAULT_PERIOD = 60 +_DEFAULT_PERIODS = 1 + + +class SubExpr(object): + + def __init__(self, tokens): + + if not tokens.func: + if tokens.relational_op.lower() in ['gte', 'gt', '>=', '>']: + self._func = "max" + else: + self._func = "min" + else: + self._func = tokens.func + self._metric_name = tokens.metric_name + self._dimensions = tokens.dimensions_list + self._operator = tokens.relational_op + self._threshold = float(tokens.threshold) + if tokens.period: + self._period = int(tokens.period) + else: + self._period = _DEFAULT_PERIOD + if tokens.periods: + self._periods = int(tokens.periods) + else: + self._periods = _DEFAULT_PERIODS + self._deterministic = tokens.deterministic + self._id = None + + @property + def fmtd_sub_expr_str(self): + """Get the entire sub expressions as a string with spaces.""" + result = u"{}({}".format(self.normalized_func, + self._metric_name) + + if self._dimensions is not None: + result += "{" + self.dimensions_str + "}" + + if self._period != _DEFAULT_PERIOD: + result += ", {}".format(self._period) + + result += ")" + + result += " {} {}".format(self._operator, + self._threshold) + + if self._periods != _DEFAULT_PERIODS: + result += " times {}".format(self._periods) + + return result + + @property + def dimensions_str(self): + """Get all the dimensions as a single comma delimited string.""" + return u",".join(self._dimensions) + + @property + def operands_list(self): + """Get this sub expression as a list.""" + return [self] + + @property + def func(self): + """Get the function as it appears in the orig expression.""" + return self._func + + @property + def normalized_func(self): + """Get the function upper-cased.""" + return self._func.upper() + + @property + def metric_name(self): + """Get the metric name as it appears in the orig expression.""" + return self._metric_name + + @property + def normalized_metric_name(self): + """Get the metric name lower-cased.""" + return self._metric_name.lower() + + @property + def dimensions(self): + """Get the dimensions.""" + return u",".join(self._dimensions) + + @property + def dimensions_as_list(self): + """Get the dimensions as a list.""" + if self._dimensions: + return self._dimensions + else: + return [] + + @property + def operator(self): + """Get the operator.""" + return self._operator + + @property + def threshold(self): + """Get the threshold value.""" + return self._threshold + + @property + def period(self): + """Get the period. Default is 60 seconds.""" + if self._period: + return self._period + else: + return u'60' + + @property + def periods(self): + """Get the periods. Default is 1.""" + if self._periods: + return self._periods + else: + return u'1' + + @property + def deterministic(self): + return True if self._deterministic else False + + @property + def normalized_operator(self): + """Get the operator as one of LT, GT, LTE, or GTE.""" + if self._operator.lower() == "lt" or self._operator == "<": + return u"LT" + elif self._operator.lower() == "gt" or self._operator == ">": + return u"GT" + elif self._operator.lower() == "lte" or self._operator == "<=": + return u"LTE" + elif self._operator.lower() == "gte" or self._operator == ">=": + return u"GTE" + + @property + def id(self): + """Get the id used to identify this sub expression in the repo.""" + return self._id + + @id.setter + def id(self, id): + """Set the d used to identify this sub expression in the repo.""" + self._id = id + + +class BinaryOp(object): + def __init__(self, tokens): + self.op = tokens[0][1] + self.operands = tokens[0][0::2] + + @property + def operands_list(self): + return ([sub_operand for operand in self.operands for sub_operand in + operand.operands_list]) + + +class AndSubExpr(BinaryOp): + """Expand later as needed.""" + pass + + +class OrSubExpr(BinaryOp): + """Expand later as needed.""" + pass + + +COMMA = pyparsing.Suppress(pyparsing.Literal(",")) +LPAREN = pyparsing.Suppress(pyparsing.Literal("(")) +RPAREN = pyparsing.Suppress(pyparsing.Literal(")")) +EQUAL = pyparsing.Literal("=") +LBRACE = pyparsing.Suppress(pyparsing.Literal("{")) +RBRACE = pyparsing.Suppress(pyparsing.Literal("}")) + + +def periodValidation(instr, loc, tokens): + period = int(tokens[0]) + if period == 0: + raise pyparsing.ParseFatalException(instr, loc, + "Period must not be 0") + + if (period % 60) != 0: + raise pyparsing.ParseFatalException(instr, loc, + "Period {} must be a multiple of 60" + .format(period)) + # Must return the string + return tokens[0] + + +def periodsValidation(instr, loc, tokens): + periods = int(tokens[0]) + if periods < 1: + raise pyparsing.ParseFatalException(instr, loc, + "Periods {} must be 1 or greater" + .format(periods)) + # Must return the string + return tokens[0] + +# Initialize non-ascii unicode code points in the Basic Multilingual Plane. +unicode_printables = u''.join( + six.unichr(c) for c in range(128, 65536) if not six.unichr(c).isspace()) + +# Does not like comma. No Literals from above allowed. +valid_identifier_chars = ( + (unicode_printables + pyparsing.alphanums + ".-_#!$%&'*+/:;?@[\\]^`|~")) + +metric_name = ( + pyparsing.Word(valid_identifier_chars, min=1, max=255)("metric_name")) +dimension_name = pyparsing.Word(valid_identifier_chars + ' ', min=1, max=255) +dimension_value = pyparsing.Word(valid_identifier_chars + ' ', min=1, max=255) + +MINUS = pyparsing.Literal('-') +integer_number = pyparsing.Word(pyparsing.nums) +decimal_number = (pyparsing.Optional(MINUS) + integer_number + + pyparsing.Optional("." + integer_number)) +decimal_number.setParseAction(lambda tokens: "".join(tokens)) + +max = pyparsing.CaselessLiteral("max") +min = pyparsing.CaselessLiteral("min") +avg = pyparsing.CaselessLiteral("avg") +count = pyparsing.CaselessLiteral("count") +sum = pyparsing.CaselessLiteral("sum") +last = pyparsing.CaselessLiteral("last") +func = (max | min | avg | count | sum | last)("func") + +less_than_op = ( + (pyparsing.CaselessLiteral("<") | pyparsing.CaselessLiteral("lt"))) +less_than_eq_op = ( + (pyparsing.CaselessLiteral("<=") | pyparsing.CaselessLiteral("lte"))) +greater_than_op = ( + (pyparsing.CaselessLiteral(">") | pyparsing.CaselessLiteral("gt"))) +greater_than_eq_op = ( + (pyparsing.CaselessLiteral(">=") | pyparsing.CaselessLiteral("gte"))) + +# Order is important. Put longer prefix first. +relational_op = ( + less_than_eq_op | less_than_op | greater_than_eq_op | greater_than_op)( + "relational_op") + +AND = pyparsing.CaselessLiteral("and") | pyparsing.CaselessLiteral("&&") +OR = pyparsing.CaselessLiteral("or") | pyparsing.CaselessLiteral("||") +logical_op = (AND | OR)("logical_op") + +times = pyparsing.CaselessLiteral("times") + +dimension = dimension_name + EQUAL + dimension_value +dimension.setParseAction(lambda tokens: "".join(tokens)) + +dimension_list = pyparsing.Group((LBRACE + pyparsing.Optional( + pyparsing.delimitedList(dimension)) + + RBRACE))("dimensions_list") + +metric = metric_name + pyparsing.Optional(dimension_list) +period = integer_number.copy().addParseAction(periodValidation)("period") +threshold = decimal_number("threshold") +periods = integer_number.copy().addParseAction(periodsValidation)("periods") + +deterministic = ( + pyparsing.CaselessLiteral('deterministic') +)('deterministic') + +function_and_metric = ( + func + LPAREN + metric + + pyparsing.Optional(COMMA + deterministic) + + pyparsing.Optional(COMMA + period) + + RPAREN +) + +expression = pyparsing.Forward() + +sub_expression = ((function_and_metric | metric) + relational_op + threshold + + pyparsing.Optional(times + periods) | + LPAREN + expression + RPAREN) +sub_expression.setParseAction(SubExpr) + +expression = ( + pyparsing.operatorPrecedence(sub_expression, + [(AND, 2, pyparsing.opAssoc.LEFT, AndSubExpr), + (OR, 2, pyparsing.opAssoc.LEFT, OrSubExpr)])) + + +class AlarmExprParser(object): + def __init__(self, expr): + self._expr = expr + + @property + def sub_expr_list(self): + # Remove all spaces before parsing. Simple, quick fix for whitespace + # issue with dimension list not allowing whitespace after comma. + parse_result = (expression + pyparsing.stringEnd).parseString( + self._expr) + sub_expr_list = parse_result[0].operands_list + return sub_expr_list + + +def main(): + """Used for development and testing.""" + + expr_list = [ + "max(-_.千幸福的笑脸{घोड़ा=馬, " + "dn2=dv2,千幸福的笑脸घ=千幸福的笑脸घ}) gte 100 " + "times 3 && " + "(min(ເຮືອນ{dn3=dv3,家=дом}) < 10 or sum(biz{dn5=dv5}) >99 and " + "count(fizzle) lt 0or count(baz) > 1)".decode('utf8'), + + "max(foo{hostname=mini-mon,千=千}, 120) > 100 and (max(bar)>100 " + " or max(biz)>100)".decode('utf8'), + + "max(foo)>=100", + + "test_metric{this=that, that = this} < 1", + + "max ( 3test_metric5 { this = that }) lt 5 times 3", + + "3test_metric5 lt 3", + + "ntp.offset > 1 or ntp.offset < -5", + + "max(3test_metric5{it's this=that's it}) lt 5 times 3", + + "count(log.error{test=1}, deterministic) > 1.0", + + "count(log.error{test=1}, deterministic, 120) > 1.0", + + "last(test_metric{hold=here}) < 13", + + "count(log.error{test=1}, deterministic, 130) > 1.0", + + "count(log.error{test=1}, deterministic) > 1.0 times 0", + ] + + for expr in expr_list: + print('orig expr: {}'.format(expr.encode('utf8'))) + sub_exprs = [] + try: + alarm_expr_parser = AlarmExprParser(expr) + sub_exprs = alarm_expr_parser.sub_expr_list + except Exception as ex: + print("Parse failed: {}".format(ex)) + for sub_expr in sub_exprs: + print('sub expr: {}'.format( + sub_expr.fmtd_sub_expr_str.encode('utf8'))) + print('sub_expr dimensions: {}'.format( + sub_expr.dimensions_str.encode('utf8'))) + print('sub_expr deterministic: {}'.format( + sub_expr.deterministic)) + print('sub_expr period: {}'.format( + sub_expr.period)) + print("") + print("") + + +if __name__ == "__main__": + sys.exit(main())