X-Git-Url: https://code.grnet.gr/git/ganeti-local/blobdiff_plain/bc385fa02c767b49fcc634af3266cfbcd21f4d3b..7142485ab5d515165b44fef36a67426ec0426a64:/lib/qlang.py diff --git a/lib/qlang.py b/lib/qlang.py index ef1ee46..bd2a3fd 100644 --- a/lib/qlang.py +++ b/lib/qlang.py @@ -31,6 +31,18 @@ converted to callable functions by L{query._CompileFilter}. """ +import re +import string # pylint: disable=W0402 +import logging + +import pyparsing as pyp + +from ganeti import errors +from ganeti import netutils +from ganeti import utils +from ganeti import compat + + # Logic operators with one or more operands, each of which is a filter on its # own OP_OR = "|" @@ -50,6 +62,13 @@ OP_REGEXP = "=~" OP_CONTAINS = "=[]" +#: Characters used for detecting user-written filters (see L{_CheckFilter}) +FILTER_DETECTION_CHARS = frozenset("()=/!~'\"\\" + string.whitespace) + +#: Characters used to detect globbing filters (see L{_CheckGlobbing}) +GLOB_DETECTION_CHARS = frozenset("*?") + + def MakeSimpleFilter(namefield, values): """Builds simple a filter. @@ -61,3 +80,245 @@ def MakeSimpleFilter(namefield, values): return [OP_OR] + [[OP_EQUAL, namefield, i] for i in values] return None + + +def _ConvertLogicOp(op): + """Creates parsing action function for logic operator. + + @type op: string + @param op: Operator for data structure, e.g. L{OP_AND} + + """ + def fn(toks): + """Converts parser tokens to query operator structure. + + @rtype: list + @return: Query operator structure, e.g. C{[OP_AND, ["=", "foo", "bar"]]} + + """ + operands = toks[0] + + if len(operands) == 1: + return operands[0] + + # Build query operator structure + return [[op] + operands.asList()] + + return fn + + +_KNOWN_REGEXP_DELIM = "/#^|" +_KNOWN_REGEXP_FLAGS = frozenset("si") + + +def _ConvertRegexpValue(_, loc, toks): + """Regular expression value for condition. + + """ + (regexp, flags) = toks[0] + + # Ensure only whitelisted flags are used + unknown_flags = (frozenset(flags) - _KNOWN_REGEXP_FLAGS) + if unknown_flags: + raise pyp.ParseFatalException("Unknown regular expression flags: '%s'" % + "".join(unknown_flags), loc) + + if flags: + re_flags = "(?%s)" % "".join(sorted(flags)) + else: + re_flags = "" + + re_cond = re_flags + regexp + + # Test if valid + try: + re.compile(re_cond) + except re.error, err: + raise pyp.ParseFatalException("Invalid regular expression (%s)" % err, loc) + + return [re_cond] + + +def BuildFilterParser(): + """Builds a parser for query filter strings. + + @rtype: pyparsing.ParserElement + + """ + field_name = pyp.Word(pyp.alphas, pyp.alphanums + "_/.") + + # Integer + num_sign = pyp.Word("-+", exact=1) + number = pyp.Combine(pyp.Optional(num_sign) + pyp.Word(pyp.nums)) + number.setParseAction(lambda toks: int(toks[0])) + + quoted_string = pyp.quotedString.copy().setParseAction(pyp.removeQuotes) + + # Right-hand-side value + rval = (number | quoted_string) + + # Boolean condition + bool_cond = field_name.copy() + bool_cond.setParseAction(lambda (fname, ): [[OP_TRUE, fname]]) + + # Simple binary conditions + binopstbl = { + "==": OP_EQUAL, + "!=": OP_NOT_EQUAL, + } + + binary_cond = (field_name + pyp.oneOf(binopstbl.keys()) + rval) + binary_cond.setParseAction(lambda (lhs, op, rhs): [[binopstbl[op], lhs, rhs]]) + + # "in" condition + in_cond = (rval + pyp.Suppress("in") + field_name) + in_cond.setParseAction(lambda (value, field): [[OP_CONTAINS, field, value]]) + + # "not in" condition + not_in_cond = (rval + pyp.Suppress("not") + pyp.Suppress("in") + field_name) + not_in_cond.setParseAction(lambda (value, field): [[OP_NOT, [OP_CONTAINS, + field, value]]]) + + # Regular expression, e.g. m/foobar/i + regexp_val = pyp.Group(pyp.Optional("m").suppress() + + pyp.MatchFirst([pyp.QuotedString(i, escChar="\\") + for i in _KNOWN_REGEXP_DELIM]) + + pyp.Optional(pyp.Word(pyp.alphas), default="")) + regexp_val.setParseAction(_ConvertRegexpValue) + regexp_cond = (field_name + pyp.Suppress("=~") + regexp_val) + regexp_cond.setParseAction(lambda (field, value): [[OP_REGEXP, field, value]]) + + not_regexp_cond = (field_name + pyp.Suppress("!~") + regexp_val) + not_regexp_cond.setParseAction(lambda (field, value): + [[OP_NOT, [OP_REGEXP, field, value]]]) + + # Globbing, e.g. name =* "*.site" + glob_cond = (field_name + pyp.Suppress("=*") + quoted_string) + glob_cond.setParseAction(lambda (field, value): + [[OP_REGEXP, field, + utils.DnsNameGlobPattern(value)]]) + + not_glob_cond = (field_name + pyp.Suppress("!*") + quoted_string) + not_glob_cond.setParseAction(lambda (field, value): + [[OP_NOT, [OP_REGEXP, field, + utils.DnsNameGlobPattern(value)]]]) + + # All possible conditions + condition = (binary_cond ^ bool_cond ^ + in_cond ^ not_in_cond ^ + regexp_cond ^ not_regexp_cond ^ + glob_cond ^ not_glob_cond) + + # Associativity operators + filter_expr = pyp.operatorPrecedence(condition, [ + (pyp.Keyword("not").suppress(), 1, pyp.opAssoc.RIGHT, + lambda toks: [[OP_NOT, toks[0][0]]]), + (pyp.Keyword("and").suppress(), 2, pyp.opAssoc.LEFT, + _ConvertLogicOp(OP_AND)), + (pyp.Keyword("or").suppress(), 2, pyp.opAssoc.LEFT, + _ConvertLogicOp(OP_OR)), + ]) + + parser = pyp.StringStart() + filter_expr + pyp.StringEnd() + parser.parseWithTabs() + + # Originally C{parser.validate} was called here, but there seems to be some + # issue causing it to fail whenever the "not" operator is included above. + + return parser + + +def ParseFilter(text, parser=None): + """Parses a query filter. + + @type text: string + @param text: Query filter + @type parser: pyparsing.ParserElement + @param parser: Pyparsing object + @rtype: list + + """ + logging.debug("Parsing as query filter: %s", text) + + if parser is None: + parser = BuildFilterParser() + + try: + return parser.parseString(text)[0] + except pyp.ParseBaseException, err: + raise errors.QueryFilterParseError("Failed to parse query filter" + " '%s': %s" % (text, err), err) + + +def _IsHostname(text): + """Checks if a string could be a hostname. + + @rtype: bool + + """ + try: + netutils.Hostname.GetNormalizedName(text) + except errors.OpPrereqError: + return False + else: + return True + + +def _CheckFilter(text): + """CHecks if a string could be a filter. + + @rtype: bool + + """ + return bool(frozenset(text) & FILTER_DETECTION_CHARS) + + +def _CheckGlobbing(text): + """Checks if a string could be a globbing pattern. + + @rtype: bool + + """ + return bool(frozenset(text) & GLOB_DETECTION_CHARS) + + +def _MakeFilterPart(namefield, text): + """Generates filter for one argument. + + """ + if _CheckGlobbing(text): + return [OP_REGEXP, namefield, utils.DnsNameGlobPattern(text)] + else: + return [OP_EQUAL, namefield, text] + + +def MakeFilter(args, force_filter): + """Try to make a filter from arguments to a command. + + If the name could be a filter it is parsed as such. If it's just a globbing + pattern, e.g. "*.site", such a filter is constructed. As a last resort the + names are treated just as a plain name filter. + + @type args: list of string + @param args: Arguments to command + @type force_filter: bool + @param force_filter: Whether to force treatment as a full-fledged filter + @rtype: list + @return: Query filter + + """ + if (force_filter or + (args and len(args) == 1 and _CheckFilter(args[0]))): + try: + (filter_text, ) = args + except (TypeError, ValueError): + raise errors.OpPrereqError("Exactly one argument must be given as a" + " filter") + + result = ParseFilter(filter_text) + elif args: + result = [OP_OR] + map(compat.partial(_MakeFilterPart, "name"), args) + else: + result = None + + return result