From 59bf887ea92c191a37a6a8000fd61162c89bb079 Mon Sep 17 00:00:00 2001 From: Anthony Lee Date: Mon, 8 Dec 2014 06:52:18 -0800 Subject: [PATCH] Add driver filter and evaluator for scheduler This patch adds a new filter for the cinder scheduler that can interpret two new properties provided by backends, 'filter_function' and 'goodness_function'. A driver can rely on cinder.conf entries to define these properties for a backend or the driver can generate them some other way. An evaluator is used by the filter to parse the properties. The 'goodness_function' property is used to weigh qualified backends in case multiple ones pass the filter. More details can be found in the spec: https://review.openstack.org/#/c/129330/ Implements: blueprint filtering-weighing-with-driver-supplied-functions DocImpact: New optional backend properties in cinder.conf. New filter and weigher available for scheduler. Change-Id: I38408ab49b6ed869c1faae746ee64a3bae86be58 --- cinder/exception.py | 4 + cinder/scheduler/evaluator/__init__.py | 0 cinder/scheduler/evaluator/evaluator.py | 297 ++++++++++++++++++ cinder/scheduler/filters/driver_filter.py | 145 +++++++++ cinder/scheduler/weights/goodness.py | 143 +++++++++ .../tests/scheduler/test_goodness_weigher.py | 185 +++++++++++ cinder/tests/scheduler/test_host_filters.py | 264 ++++++++++++++++ cinder/tests/test_evaluator.py | 136 ++++++++ requirements.txt | 1 + setup.cfg | 2 + 10 files changed, 1177 insertions(+) create mode 100644 cinder/scheduler/evaluator/__init__.py create mode 100644 cinder/scheduler/evaluator/evaluator.py create mode 100644 cinder/scheduler/filters/driver_filter.py create mode 100644 cinder/scheduler/weights/goodness.py create mode 100644 cinder/tests/scheduler/test_goodness_weigher.py create mode 100644 cinder/tests/test_evaluator.py diff --git a/cinder/exception.py b/cinder/exception.py index 112d0f912..ffa9e9a49 100644 --- a/cinder/exception.py +++ b/cinder/exception.py @@ -633,6 +633,10 @@ class ExtendVolumeError(CinderException): message = _("Error extending volume: %(reason)s") +class EvaluatorParseException(Exception): + message = _("Error during evaluator parsing: %(reason)s") + + # Driver specific exceptions # Coraid class CoraidException(VolumeDriverException): diff --git a/cinder/scheduler/evaluator/__init__.py b/cinder/scheduler/evaluator/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/cinder/scheduler/evaluator/evaluator.py b/cinder/scheduler/evaluator/evaluator.py new file mode 100644 index 000000000..8d3542525 --- /dev/null +++ b/cinder/scheduler/evaluator/evaluator.py @@ -0,0 +1,297 @@ +# Copyright (c) 2014 Hewlett-Packard Development Company, L.P. +# All Rights Reserved. +# +# 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 operator +import re + +import pyparsing +import six + +from cinder import exception +from cinder.i18n import _ + + +def _operatorOperands(tokenList): + it = iter(tokenList) + while 1: + try: + op1 = next(it) + op2 = next(it) + yield(op1, op2) + except StopIteration: + break + + +class EvalConstant(object): + def __init__(self, toks): + self.value = toks[0] + + def eval(self): + result = self.value + if (isinstance(result, six.string_types) and + re.match("^[a-zA-Z_]+\.[a-zA-Z_]+$", result)): + (which_dict, entry) = result.split('.') + try: + result = _vars[which_dict][entry] + except KeyError as e: + msg = _("KeyError: %s") % e + raise exception.EvaluatorParseException(msg) + except TypeError as e: + msg = _("TypeError: %s") % e + raise exception.EvaluatorParseException(msg) + + try: + result = int(result) + except ValueError: + try: + result = float(result) + except ValueError as e: + msg = _("ValueError: %s") % e + raise exception.EvaluatorParseException(msg) + + return result + + +class EvalSignOp(object): + operations = { + '+': 1, + '-': -1, + } + + def __init__(self, toks): + self.sign, self.value = toks[0] + + def eval(self): + return self.operations[self.sign] * self.value.eval() + + +class EvalAddOp(object): + def __init__(self, toks): + self.value = toks[0] + + def eval(self): + sum = self.value[0].eval() + for op, val in _operatorOperands(self.value[1:]): + if op == '+': + sum += val.eval() + elif op == '-': + sum -= val.eval() + return sum + + +class EvalMultOp(object): + def __init__(self, toks): + self.value = toks[0] + + def eval(self): + prod = self.value[0].eval() + for op, val in _operatorOperands(self.value[1:]): + try: + if op == '*': + prod *= val.eval() + elif op == '/': + prod /= float(val.eval()) + except ZeroDivisionError as e: + msg = _("ZeroDivisionError: %s") % e + raise exception.EvaluatorParseException(msg) + return prod + + +class EvalPowerOp(object): + def __init__(self, toks): + self.value = toks[0] + + def eval(self): + prod = self.value[0].eval() + for op, val in _operatorOperands(self.value[1:]): + prod = pow(prod, val.eval()) + return prod + + +class EvalNegateOp(object): + def __init__(self, toks): + self.negation, self.value = toks[0] + + def eval(self): + return not self.value.eval() + + +class EvalComparisonOp(object): + operations = { + "<": operator.lt, + "<=": operator.le, + ">": operator.gt, + ">=": operator.ge, + "!=": operator.ne, + "==": operator.eq, + "<>": operator.ne, + } + + def __init__(self, toks): + self.value = toks[0] + + def eval(self): + val1 = self.value[0].eval() + for op, val in _operatorOperands(self.value[1:]): + fn = self.operations[op] + val2 = val.eval() + if not fn(val1, val2): + break + val1 = val2 + else: + return True + return False + + +class EvalTernaryOp(object): + def __init__(self, toks): + self.value = toks[0] + + def eval(self): + condition = self.value[0].eval() + if condition: + return self.value[2].eval() + else: + return self.value[4].eval() + + +class EvalFunction(object): + functions = { + "abs": abs, + "max": max, + "min": min, + } + + def __init__(self, toks): + self.func, self.value = toks[0] + + def eval(self): + args = self.value.eval() + if type(args) is list: + return self.functions[self.func](*args) + else: + return self.functions[self.func](args) + + +class EvalCommaSeperator(object): + def __init__(self, toks): + self.value = toks[0] + + def eval(self): + val1 = self.value[0].eval() + val2 = self.value[2].eval() + if type(val2) is list: + val_list = [] + val_list.append(val1) + for val in val2: + val_list.append(val) + return val_list + + return [val1, val2] + + +class EvalBoolAndOp(object): + def __init__(self, toks): + self.value = toks[0] + + def eval(self): + left = self.value[0].eval() + right = self.value[2].eval() + return left and right + + +class EvalBoolOrOp(object): + def __init__(self, toks): + self.value = toks[0] + + def eval(self): + left = self.value[0].eval() + right = self.value[2].eval() + return left or right + +_parser = None +_vars = {} + + +def _def_parser(): + # Enabling packrat parsing greatly speeds up the parsing. + pyparsing.ParserElement.enablePackrat() + + alphas = pyparsing.alphas + Combine = pyparsing.Combine + Forward = pyparsing.Forward + nums = pyparsing.nums + oneOf = pyparsing.oneOf + opAssoc = pyparsing.opAssoc + operatorPrecedence = pyparsing.operatorPrecedence + Word = pyparsing.Word + + integer = Word(nums) + real = Combine(Word(nums) + '.' + Word(nums)) + variable = Word(alphas + '_' + '.') + number = real | integer + expr = Forward() + fn = Word(alphas + '_' + '.') + operand = number | variable | fn + + signop = oneOf('+ -') + addop = oneOf('+ -') + multop = oneOf('* /') + comparisonop = oneOf(' '.join(EvalComparisonOp.operations.keys())) + ternaryop = ('?', ':') + boolandop = oneOf('AND and &&') + boolorop = oneOf('OR or ||') + negateop = oneOf('NOT not !') + + operand.setParseAction(EvalConstant) + expr = operatorPrecedence(operand, [ + (fn, 1, opAssoc.RIGHT, EvalFunction), + ("^", 2, opAssoc.RIGHT, EvalPowerOp), + (signop, 1, opAssoc.RIGHT, EvalSignOp), + (multop, 2, opAssoc.LEFT, EvalMultOp), + (addop, 2, opAssoc.LEFT, EvalAddOp), + (negateop, 1, opAssoc.RIGHT, EvalNegateOp), + (comparisonop, 2, opAssoc.LEFT, EvalComparisonOp), + (ternaryop, 3, opAssoc.LEFT, EvalTernaryOp), + (boolandop, 2, opAssoc.LEFT, EvalBoolAndOp), + (boolorop, 2, opAssoc.LEFT, EvalBoolOrOp), + (',', 2, opAssoc.RIGHT, EvalCommaSeperator), ]) + + return expr + + +def evaluate(expression, **kwargs): + """Evaluates an expression. + + Provides the facility to evaluate mathematical expressions, and to + substitute variables from dictionaries into those expressions. + + Supports both integer and floating point values, and automatic + promotion where necessary. + """ + global _parser + if _parser is None: + _parser = _def_parser() + + global _vars + _vars = kwargs + + try: + result = _parser.parseString(expression, parseAll=True)[0] + except pyparsing.ParseException as e: + msg = _("ParseException: %s") % e + raise exception.EvaluatorParseException(msg) + + return result.eval() diff --git a/cinder/scheduler/filters/driver_filter.py b/cinder/scheduler/filters/driver_filter.py new file mode 100644 index 000000000..f7b09a20b --- /dev/null +++ b/cinder/scheduler/filters/driver_filter.py @@ -0,0 +1,145 @@ +# Copyright (c) 2014 Hewlett-Packard Development Company, L.P. +# All Rights Reserved. +# +# 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 six + +from cinder.i18n import _LW +from cinder.openstack.common import log as logging +from cinder.openstack.common.scheduler import filters +from cinder.scheduler.evaluator import evaluator + + +LOG = logging.getLogger(__name__) + + +class DriverFilter(filters.BaseHostFilter): + """DriverFilter filters hosts based on a 'filter function' and metrics. + + DriverFilter filters based on volume host's provided 'filter function' + and metrics. + """ + + def host_passes(self, host_state, filter_properties): + """Determines whether a host has a passing filter_function or not.""" + stats = self._generate_stats(host_state, filter_properties) + + LOG.debug("Checking host '%s'", stats['host_stats']['host']) + result = self._check_filter_function(stats) + LOG.debug("Result: %s", result) + LOG.debug("Done checking host '%s'", stats['host_stats']['host']) + + return result + + def _check_filter_function(self, stats): + """Checks if a volume passes a host's filter function. + + Returns a tuple in the format (filter_passing, filter_invalid). + Both values are booleans. + """ + host_stats = stats['host_stats'] + extra_specs = stats['extra_specs'] + + # Check that the volume types match + if (extra_specs is None or 'volume_backend_name' not in extra_specs): + LOG.warning(_LW("No 'volume_backend_name' key in extra_specs. " + "Skipping volume backend name check.")) + elif (extra_specs['volume_backend_name'] != + host_stats['volume_backend_name']): + LOG.warning(_LW("Volume backend names do not match: '%(target)s' " + "vs '%(current)s' :: Skipping"), + {'target': extra_specs['volume_backend_name'], + 'current': host_stats['volume_backend_name']}) + return False + + if stats['filter_function'] is None: + LOG.warning(_LW("Filter function not set :: passing host")) + return True + + try: + filter_result = self._run_evaluator(stats['filter_function'], + stats) + except Exception as ex: + # Warn the admin for now that there is an error in the + # filter function. + LOG.warning(_LW("Error in filtering function " + "'%(function)s' : '%(error)s' :: failing host"), + {'function': stats['filter_function'], + 'error': ex, }) + return False + + return filter_result + + def _run_evaluator(self, func, stats): + """Evaluates a given function using the provided available stats.""" + host_stats = stats['host_stats'] + host_caps = stats['host_caps'] + extra_specs = stats['extra_specs'] + qos_specs = stats['qos_specs'] + volume_stats = stats['volume_stats'] + + result = evaluator.evaluate( + func, + extra=extra_specs, + stats=host_stats, + capabilities=host_caps, + volume=volume_stats, + qos=qos_specs) + + return result + + def _generate_stats(self, host_state, filter_properties): + """Generates statistics from host and volume data.""" + + host_stats = { + 'host': host_state.host, + 'volume_backend_name': host_state.volume_backend_name, + 'vendor_name': host_state.vendor_name, + 'driver_version': host_state.driver_version, + 'storage_protocol': host_state.storage_protocol, + 'QoS_support': host_state.QoS_support, + 'total_capacity_gb': host_state.total_capacity_gb, + 'allocated_capacity_gb': host_state.allocated_capacity_gb, + 'free_capacity_gb': host_state.free_capacity_gb, + 'reserved_percentage': host_state.reserved_percentage, + 'updated': host_state.updated, + } + + host_caps = host_state.capabilities + + filter_function = None + + if ('filter_function' in host_caps and + host_caps['filter_function'] is not None): + filter_function = six.text_type(host_caps['filter_function']) + + qos_specs = filter_properties.get('qos_specs', {}) + + volume_type = filter_properties.get('volume_type', {}) + extra_specs = volume_type.get('extra_specs', {}) + + request_spec = filter_properties.get('request_spec', {}) + volume_stats = request_spec.get('volume_properties', {}) + + stats = { + 'host_stats': host_stats, + 'host_caps': host_caps, + 'extra_specs': extra_specs, + 'qos_specs': qos_specs, + 'volume_stats': volume_stats, + 'volume_type': volume_type, + 'filter_function': filter_function, + } + + return stats diff --git a/cinder/scheduler/weights/goodness.py b/cinder/scheduler/weights/goodness.py new file mode 100644 index 000000000..9b4316cef --- /dev/null +++ b/cinder/scheduler/weights/goodness.py @@ -0,0 +1,143 @@ +# Copyright (C) 2014 Hewlett-Packard Development Company, L.P. +# +# 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 six + +from cinder.i18n import _LW +from cinder.openstack.common import log as logging +from cinder.openstack.common.scheduler import weights +from cinder.scheduler.evaluator import evaluator + + +LOG = logging.getLogger(__name__) + + +class GoodnessWeigher(weights.BaseHostWeigher): + """Goodness Weigher. Assign weights based on a host's goodness function. + + Goodness rating is the following: + + 0 -- host is a poor choice + ... + 50 -- host is a good choice + ... + 100 -- host is a perfect choice + """ + + def _weigh_object(self, host_state, weight_properties): + """Determine host's goodness rating based on a goodness_function.""" + stats = self._generate_stats(host_state, weight_properties) + LOG.debug("Checking host '%s'", stats['host_stats']['host']) + result = self._check_goodness_function(stats) + LOG.debug("Goodness: %s", result) + LOG.debug("Done checking host '%s'", stats['host_stats']['host']) + + return result + + def _check_goodness_function(self, stats): + """Gets a host's goodness rating based on its goodness function.""" + + goodness_rating = 0 + + if stats['goodness_function'] is None: + LOG.warning(_LW("Goodness function not set :: defaulting to " + "minimal goodness rating of 0")) + else: + try: + goodness_result = self._run_evaluator( + stats['goodness_function'], + stats) + except Exception as ex: + LOG.warning(_LW("Error in goodness_function function " + "'%(function)s' : '%(error)s' :: Defaulting " + "to a goodness of 0"), + {'function': stats['goodness_function'], + 'error': ex, }) + return goodness_rating + + if type(goodness_result) is bool: + if goodness_result: + goodness_rating = 100 + elif goodness_result < 0 or goodness_result > 100: + LOG.warning(_LW("Invalid goodness result. Result must be " + "between 0 and 100. Result generated: '%s' " + ":: Defaulting to a goodness of 0"), + goodness_result) + else: + goodness_rating = goodness_result + + return goodness_rating + + def _run_evaluator(self, func, stats): + """Evaluates a given function using the provided available stats.""" + host_stats = stats['host_stats'] + host_caps = stats['host_caps'] + extra_specs = stats['extra_specs'] + qos_specs = stats['qos_specs'] + volume_stats = stats['volume_stats'] + + result = evaluator.evaluate( + func, + extra=extra_specs, + stats=host_stats, + capabilities=host_caps, + volume=volume_stats, + qos=qos_specs) + + return result + + def _generate_stats(self, host_state, weight_properties): + """Generates statistics from host and volume data.""" + + host_stats = { + 'host': host_state.host, + 'volume_backend_name': host_state.volume_backend_name, + 'vendor_name': host_state.vendor_name, + 'driver_version': host_state.driver_version, + 'storage_protocol': host_state.storage_protocol, + 'QoS_support': host_state.QoS_support, + 'total_capacity_gb': host_state.total_capacity_gb, + 'allocated_capacity_gb': host_state.allocated_capacity_gb, + 'free_capacity_gb': host_state.free_capacity_gb, + 'reserved_percentage': host_state.reserved_percentage, + 'updated': host_state.updated, + } + + host_caps = host_state.capabilities + + goodness_function = None + + if ('goodness_function' in host_caps and + host_caps['goodness_function'] is not None): + goodness_function = six.text_type(host_caps['goodness_function']) + + qos_specs = weight_properties.get('qos_specs', {}) + + volume_type = weight_properties.get('volume_type', {}) + extra_specs = volume_type.get('extra_specs', {}) + + request_spec = weight_properties.get('request_spec', {}) + volume_stats = request_spec.get('volume_properties', {}) + + stats = { + 'host_stats': host_stats, + 'host_caps': host_caps, + 'extra_specs': extra_specs, + 'qos_specs': qos_specs, + 'volume_stats': volume_stats, + 'volume_type': volume_type, + 'goodness_function': goodness_function, + } + + return stats diff --git a/cinder/tests/scheduler/test_goodness_weigher.py b/cinder/tests/scheduler/test_goodness_weigher.py new file mode 100644 index 000000000..4555640de --- /dev/null +++ b/cinder/tests/scheduler/test_goodness_weigher.py @@ -0,0 +1,185 @@ +# Copyright (C) 2014 Hewlett-Packard Development Company, L.P. +# +# 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. +""" +Tests For Goodness Weigher. +""" + +from cinder.scheduler.weights.goodness import GoodnessWeigher +from cinder import test +from cinder.tests.scheduler import fakes + + +class GoodnessWeigherTestCase(test.TestCase): + def setUp(self): + super(GoodnessWeigherTestCase, self).setUp() + + def test_goodness_weigher_passing_host(self): + weigher = GoodnessWeigher() + host_state = fakes.FakeHostState('host1', { + 'host': 'host.example.com', + 'capabilities': { + 'goodness_function': '100' + } + }) + host_state_2 = fakes.FakeHostState('host2', { + 'host': 'host2.example.com', + 'capabilities': { + 'goodness_function': '0' + } + }) + host_state_3 = fakes.FakeHostState('host3', { + 'host': 'host3.example.com', + 'capabilities': { + 'goodness_function': '100 / 2' + } + }) + + weight_properties = {} + weight = weigher._weigh_object(host_state, weight_properties) + self.assertEqual(100, weight) + weight = weigher._weigh_object(host_state_2, weight_properties) + self.assertEqual(0, weight) + weight = weigher._weigh_object(host_state_3, weight_properties) + self.assertEqual(50, weight) + + def test_goodness_weigher_capabilities_substitution(self): + weigher = GoodnessWeigher() + host_state = fakes.FakeHostState('host1', { + 'host': 'host.example.com', + 'capabilities': { + 'foo': 50, + 'goodness_function': '10 + capabilities.foo' + } + }) + + weight_properties = {} + weight = weigher._weigh_object(host_state, weight_properties) + self.assertEqual(60, weight) + + def test_goodness_weigher_extra_specs_substitution(self): + weigher = GoodnessWeigher() + host_state = fakes.FakeHostState('host1', { + 'host': 'host.example.com', + 'capabilities': { + 'goodness_function': '10 + extra.foo' + } + }) + + weight_properties = { + 'volume_type': { + 'extra_specs': { + 'foo': 50 + } + } + } + weight = weigher._weigh_object(host_state, weight_properties) + self.assertEqual(60, weight) + + def test_goodness_weigher_volume_substitution(self): + weigher = GoodnessWeigher() + host_state = fakes.FakeHostState('host1', { + 'host': 'host.example.com', + 'capabilities': { + 'goodness_function': '10 + volume.foo' + } + }) + + weight_properties = { + 'request_spec': { + 'volume_properties': { + 'foo': 50 + } + } + } + weight = weigher._weigh_object(host_state, weight_properties) + self.assertEqual(60, weight) + + def test_goodness_weigher_qos_substitution(self): + weigher = GoodnessWeigher() + host_state = fakes.FakeHostState('host1', { + 'host': 'host.example.com', + 'capabilities': { + 'goodness_function': '10 + qos.foo' + } + }) + + weight_properties = { + 'qos_specs': { + 'foo': 50 + } + } + weight = weigher._weigh_object(host_state, weight_properties) + self.assertEqual(60, weight) + + def test_goodness_weigher_stats_substitution(self): + weigher = GoodnessWeigher() + host_state = fakes.FakeHostState('host1', { + 'host': 'host.example.com', + 'capabilities': { + 'goodness_function': 'stats.free_capacity_gb > 20' + }, + 'free_capacity_gb': 50 + }) + + weight_properties = {} + weight = weigher._weigh_object(host_state, weight_properties) + self.assertEqual(100, weight) + + def test_goodness_weigher_invalid_substitution(self): + weigher = GoodnessWeigher() + host_state = fakes.FakeHostState('host1', { + 'host': 'host.example.com', + 'capabilities': { + 'goodness_function': '10 + stats.my_val' + }, + 'foo': 50 + }) + + weight_properties = {} + weight = weigher._weigh_object(host_state, weight_properties) + self.assertEqual(0, weight) + + def test_goodness_weigher_host_rating_out_of_bounds(self): + weigher = GoodnessWeigher() + host_state = fakes.FakeHostState('host1', { + 'host': 'host.example.com', + 'capabilities': { + 'goodness_function': '-10' + } + }) + host_state_2 = fakes.FakeHostState('host2', { + 'host': 'host2.example.com', + 'capabilities': { + 'goodness_function': '200' + } + }) + + weight_properties = {} + weight = weigher._weigh_object(host_state, weight_properties) + self.assertEqual(0, weight) + weight = weigher._weigh_object(host_state_2, weight_properties) + self.assertEqual(0, weight) + + def test_goodness_weigher_invalid_goodness_function(self): + weigher = GoodnessWeigher() + host_state = fakes.FakeHostState('host1', { + 'host': 'host.example.com', + 'capabilities': { + 'goodness_function': '50 / 0' + } + }) + + weight_properties = {} + weight = weigher._weigh_object(host_state, weight_properties) + self.assertEqual(0, weight) \ No newline at end of file diff --git a/cinder/tests/scheduler/test_host_filters.py b/cinder/tests/scheduler/test_host_filters.py index 9736e093e..7ebca7f3e 100644 --- a/cinder/tests/scheduler/test_host_filters.py +++ b/cinder/tests/scheduler/test_host_filters.py @@ -317,3 +317,267 @@ class HostFiltersTestCase(test.TestCase): 'same_host': "NOT-a-valid-UUID", }} self.assertFalse(filt_cls.host_passes(host, filter_properties)) + + def test_driver_filter_passing_function(self): + filt_cls = self.class_map['DriverFilter']() + host1 = fakes.FakeHostState( + 'host1', { + 'volume_backend_name': 'fake', + 'capabilities': { + 'filter_function': '1 == 1', + } + }) + + filter_properties = { + 'volume_type': { + 'extra_specs': { + 'volume_backend_name': 'fake', + } + } + } + + self.assertTrue(filt_cls.host_passes(host1, filter_properties)) + + def test_driver_filter_failing_function(self): + filt_cls = self.class_map['DriverFilter']() + host1 = fakes.FakeHostState( + 'host1', { + 'volume_backend_name': 'fake', + 'capabilities': { + 'filter_function': '1 == 2', + } + }) + + filter_properties = { + 'volume_type': { + 'extra_specs': { + 'volume_backend_name': 'fake', + } + } + } + + self.assertFalse(filt_cls.host_passes(host1, filter_properties)) + + def test_driver_filter_no_filter_function(self): + filt_cls = self.class_map['DriverFilter']() + host1 = fakes.FakeHostState( + 'host1', { + 'volume_backend_name': 'fake', + 'capabilities': { + 'filter_function': None, + } + }) + + filter_properties = { + 'volume_type': { + 'extra_specs': { + 'volume_backend_name': 'fake', + } + } + } + + self.assertTrue(filt_cls.host_passes(host1, filter_properties)) + + def test_driver_filter_not_implemented(self): + filt_cls = self.class_map['DriverFilter']() + host1 = fakes.FakeHostState( + 'host1', { + 'volume_backend_name': 'fake', + 'capabilities': {} + }) + + filter_properties = { + 'volume_type': { + 'extra_specs': { + 'volume_backend_name': 'fake', + } + } + } + + self.assertTrue(filt_cls.host_passes(host1, filter_properties)) + + def test_driver_filter_no_volume_extra_specs(self): + filt_cls = self.class_map['DriverFilter']() + host1 = fakes.FakeHostState( + 'host1', { + 'volume_backend_name': 'fake', + 'capabilities': { + 'filter_function': '1 == 1', + } + }) + + filter_properties = {'volume_type': {}} + + self.assertTrue(filt_cls.host_passes(host1, filter_properties)) + + def test_driver_filter_volume_backend_name_different(self): + filt_cls = self.class_map['DriverFilter']() + host1 = fakes.FakeHostState( + 'host1', { + 'volume_backend_name': 'fake', + 'capabilities': { + 'filter_function': '1 == 1', + } + }) + + filter_properties = { + 'volume_type': { + 'extra_specs': { + 'volume_backend_name': 'fake2', + } + } + } + + self.assertFalse(filt_cls.host_passes(host1, filter_properties)) + + def test_driver_filter_function_extra_spec_replacement(self): + filt_cls = self.class_map['DriverFilter']() + host1 = fakes.FakeHostState( + 'host1', { + 'volume_backend_name': 'fake', + 'capabilities': { + 'filter_function': 'extra.var == 1', + } + }) + + filter_properties = { + 'volume_type': { + 'extra_specs': { + 'volume_backend_name': 'fake', + 'var': 1, + } + } + } + + self.assertTrue(filt_cls.host_passes(host1, filter_properties)) + + def test_driver_filter_function_stats_replacement(self): + filt_cls = self.class_map['DriverFilter']() + host1 = fakes.FakeHostState( + 'host1', { + 'volume_backend_name': 'fake', + 'total_capacity_gb': 100, + 'capabilities': { + 'filter_function': 'stats.total_capacity_gb < 200', + } + }) + + filter_properties = { + 'volume_type': { + 'extra_specs': { + 'volume_backend_name': 'fake', + } + } + } + + self.assertTrue(filt_cls.host_passes(host1, filter_properties)) + + def test_driver_filter_function_volume_replacement(self): + filt_cls = self.class_map['DriverFilter']() + host1 = fakes.FakeHostState( + 'host1', { + 'volume_backend_name': 'fake', + 'capabilities': { + 'filter_function': 'volume.size < 5', + } + }) + + filter_properties = { + 'volume_type': { + 'extra_specs': { + 'volume_backend_name': 'fake', + } + }, + 'request_spec': { + 'volume_properties': { + 'size': 1 + } + } + } + + self.assertTrue(filt_cls.host_passes(host1, filter_properties)) + + def test_driver_filter_function_qos_spec_replacement(self): + filt_cls = self.class_map['DriverFilter']() + host1 = fakes.FakeHostState( + 'host1', { + 'volume_backend_name': 'fake', + 'capabilities': { + 'filter_function': 'qos.var == 1', + } + }) + + filter_properties = { + 'volume_type': { + 'extra_specs': { + 'volume_backend_name': 'fake', + } + }, + 'qos_specs': { + 'var': 1 + } + } + + self.assertTrue(filt_cls.host_passes(host1, filter_properties)) + + def test_driver_filter_function_exception_caught(self): + filt_cls = self.class_map['DriverFilter']() + host1 = fakes.FakeHostState( + 'host1', { + 'volume_backend_name': 'fake', + 'capabilities': { + 'filter_function': '1 / 0 == 0', + } + }) + + filter_properties = { + 'volume_type': { + 'extra_specs': { + 'volume_backend_name': 'fake', + } + } + } + + self.assertFalse(filt_cls.host_passes(host1, filter_properties)) + + def test_driver_filter_function_empty_qos(self): + filt_cls = self.class_map['DriverFilter']() + host1 = fakes.FakeHostState( + 'host1', { + 'volume_backend_name': 'fake', + 'capabilities': { + 'filter_function': 'qos.maxiops == 1', + } + }) + + filter_properties = { + 'volume_type': { + 'extra_specs': { + 'volume_backend_name': 'fake', + } + }, + 'qos_specs': None + } + + self.assertFalse(filt_cls.host_passes(host1, filter_properties)) + + def test_driver_filter_capabilities(self): + filt_cls = self.class_map['DriverFilter']() + host1 = fakes.FakeHostState( + 'host1', { + 'volume_backend_name': 'fake', + 'capabilities': { + 'foo': 10, + 'filter_function': 'capabilities.foo == 10', + }, + }) + + filter_properties = { + 'volume_type': { + 'extra_specs': { + 'volume_backend_name': 'fake', + } + } + } + + self.assertTrue(filt_cls.host_passes(host1, filter_properties)) \ No newline at end of file diff --git a/cinder/tests/test_evaluator.py b/cinder/tests/test_evaluator.py new file mode 100644 index 000000000..821e7c709 --- /dev/null +++ b/cinder/tests/test_evaluator.py @@ -0,0 +1,136 @@ +# Copyright (c) 2014 Hewlett-Packard Development Company, L.P. +# All Rights Reserved. +# +# 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. + +from cinder import exception +from cinder.scheduler.evaluator.evaluator import evaluate +from cinder import test + + +class EvaluatorTestCase(test.TestCase): + def test_simple_integer(self): + self.assertEqual(2, evaluate("1+1")) + self.assertEqual(9, evaluate("2+3+4")) + self.assertEqual(23, evaluate("11+12")) + self.assertEqual(30, evaluate("5*6")) + self.assertEqual(2, evaluate("22/11")) + self.assertEqual(38, evaluate("109-71")) + self.assertEqual(493, evaluate("872 - 453 + 44 / 22 * 4 + 66")) + + def test_simple_float(self): + self.assertEqual(2.0, evaluate("1.0 + 1.0")) + self.assertEqual(2.5, evaluate("1.5 + 1.0")) + self.assertEqual(3.0, evaluate("1.5 * 2.0")) + + def test_int_float_mix(self): + self.assertEqual(2.5, evaluate("1.5 + 1")) + self.assertEqual(4.25, evaluate("8.5 / 2")) + self.assertEqual(5.25, evaluate("10/4+0.75 + 2")) + + def test_negative_numbers(self): + self.assertEqual(-2, evaluate("-2")) + self.assertEqual(-1, evaluate("-2+1")) + self.assertEqual(3, evaluate("5+-2")) + + def test_exponent(self): + self.assertEqual(8, evaluate("2^3")) + self.assertEqual(-8, evaluate("-2 ^ 3")) + self.assertEqual(15.625, evaluate("2.5 ^ 3")) + self.assertEqual(8, evaluate("4 ^ 1.5")) + + def test_function(self): + self.assertEqual(5, evaluate("abs(-5)")) + self.assertEqual(2, evaluate("abs(2)")) + self.assertEqual(1, evaluate("min(1, 100)")) + self.assertEqual(100, evaluate("max(1, 100)")) + + def test_parentheses(self): + self.assertEqual(1, evaluate("(1)")) + self.assertEqual(-1, evaluate("(-1)")) + self.assertEqual(2, evaluate("(1+1)")) + self.assertEqual(15, evaluate("(1+2) * 5")) + self.assertEqual(3, evaluate("(1+2)*(3-1)/((1+(2-1)))")) + self.assertEqual(-8.0, evaluate("((1.0 / 0.5) * (2)) *(-2)")) + + def test_comparisons(self): + self.assertEqual(True, evaluate("1 < 2")) + self.assertEqual(True, evaluate("2 > 1")) + self.assertEqual(True, evaluate("2 != 1")) + self.assertEqual(False, evaluate("1 > 2")) + self.assertEqual(False, evaluate("2 < 1")) + self.assertEqual(False, evaluate("2 == 1")) + self.assertEqual(True, evaluate("(1 == 1) == !(1 == 2)")) + + def test_logic_ops(self): + self.assertEqual(True, evaluate("(1 == 1) AND (2 == 2)")) + self.assertEqual(True, evaluate("(1 == 1) and (2 == 2)")) + self.assertEqual(True, evaluate("(1 == 1) && (2 == 2)")) + self.assertEqual(False, evaluate("(1 == 1) && (5 == 2)")) + + self.assertEqual(True, evaluate("(1 == 1) OR (5 == 2)")) + self.assertEqual(True, evaluate("(1 == 1) or (5 == 2)")) + self.assertEqual(True, evaluate("(1 == 1) || (5 == 2)")) + self.assertEqual(False, evaluate("(5 == 1) || (5 == 2)")) + + self.assertEqual(False, evaluate("(1 == 1) AND NOT (2 == 2)")) + self.assertEqual(False, evaluate("(1 == 1) AND not (2 == 2)")) + self.assertEqual(False, evaluate("(1 == 1) AND !(2 == 2)")) + self.assertEqual(True, evaluate("(1 == 1) AND NOT (5 == 2)")) + self.assertEqual(True, + evaluate("(1 == 1) OR NOT (2 == 2) AND (5 == 5)")) + + def test_ternary_conditional(self): + self.assertEqual(5, evaluate("(1 < 2) ? 5 : 10")) + self.assertEqual(10, evaluate("(1 > 2) ? 5 : 10")) + + def test_variables_dict(self): + stats = {'iops': 1000, 'usage': 0.65, 'count': 503, 'free_space': 407} + request = {'iops': 500, 'size': 4} + self.assertEqual(1500, evaluate("stats.iops + request.iops", + stats=stats, request=request)) + + def test_missing_var(self): + stats = {'iops': 1000, 'usage': 0.65, 'count': 503, 'free_space': 407} + request = {'iops': 500, 'size': 4} + self.assertRaises(exception.EvaluatorParseException, + evaluate, + "foo.bob + 5", + stats=stats, request=request) + self.assertRaises(exception.EvaluatorParseException, + evaluate, + "stats.bob + 5", + stats=stats, request=request) + self.assertRaises(exception.EvaluatorParseException, + evaluate, + "fake.var + 1", + stats=stats, request=request, fake=None) + + def test_bad_expression(self): + self.assertRaises(exception.EvaluatorParseException, + evaluate, + "1/*1") + + def test_nonnumber_comparison(self): + nonnumber = {'test': 'foo'} + request = {'test': 'bar'} + self.assertRaises( + exception.EvaluatorParseException, + evaluate, + "nonnumber.test != request.test", + nonnumber=nonnumber, request=request) + + def test_div_zero(self): + self.assertRaises(exception.EvaluatorParseException, + evaluate, + "7 / 0") diff --git a/requirements.txt b/requirements.txt index 949349422..46056a640 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,6 +26,7 @@ paramiko>=1.13.0 Paste PasteDeploy>=1.5.0 pycrypto>=2.6 +pyparsing>=2.0.1 python-barbicanclient>=2.1.0,!=3.0.0 python-glanceclient>=0.15.0 python-novaclient>=2.18.0 diff --git a/setup.cfg b/setup.cfg index bf120a66f..f943735f6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -31,6 +31,7 @@ cinder.scheduler.filters = CapabilitiesFilter = cinder.openstack.common.scheduler.filters.capabilities_filter:CapabilitiesFilter CapacityFilter = cinder.scheduler.filters.capacity_filter:CapacityFilter DifferentBackendFilter = cinder.scheduler.filters.affinity_filter:DifferentBackendFilter + DriverFilter = cinder.scheduler.filters.driver_filter:DriverFilter JsonFilter = cinder.openstack.common.scheduler.filters.json_filter:JsonFilter RetryFilter = cinder.openstack.common.scheduler.filters.ignore_attempted_hosts_filter:IgnoreAttemptedHostsFilter SameBackendFilter = cinder.scheduler.filters.affinity_filter:SameBackendFilter @@ -38,6 +39,7 @@ cinder.scheduler.weights = AllocatedCapacityWeigher = cinder.scheduler.weights.capacity:AllocatedCapacityWeigher CapacityWeigher = cinder.scheduler.weights.capacity:CapacityWeigher ChanceWeigher = cinder.scheduler.weights.chance:ChanceWeigher + GoodnessWeigher = cinder.scheduler.weights.goodness:GoodnessWeigher VolumeNumberWeigher = cinder.scheduler.weights.volume_number:VolumeNumberWeigher console_scripts = cinder-all = cinder.cmd.all:main -- 2.45.2