]> review.fuel-infra Code Review - openstack-build/cinder-build.git/commitdiff
Add driver filter and evaluator for scheduler
authorAnthony Lee <anthony.mic.lee@hp.com>
Mon, 8 Dec 2014 14:52:18 +0000 (06:52 -0800)
committerAnthony Lee <anthony.mic.lee@hp.com>
Tue, 6 Jan 2015 23:28:47 +0000 (23:28 +0000)
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
cinder/scheduler/evaluator/__init__.py [new file with mode: 0644]
cinder/scheduler/evaluator/evaluator.py [new file with mode: 0644]
cinder/scheduler/filters/driver_filter.py [new file with mode: 0644]
cinder/scheduler/weights/goodness.py [new file with mode: 0644]
cinder/tests/scheduler/test_goodness_weigher.py [new file with mode: 0644]
cinder/tests/scheduler/test_host_filters.py
cinder/tests/test_evaluator.py [new file with mode: 0644]
requirements.txt
setup.cfg

index 112d0f912254e0bb219e96f5c09b1580f2660552..ffa9e9a49ed0ae6cc528226c7a3a00e5d9ab619a 100644 (file)
@@ -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 (file)
index 0000000..e69de29
diff --git a/cinder/scheduler/evaluator/evaluator.py b/cinder/scheduler/evaluator/evaluator.py
new file mode 100644 (file)
index 0000000..8d35425
--- /dev/null
@@ -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 (file)
index 0000000..f7b09a2
--- /dev/null
@@ -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 (file)
index 0000000..9b4316c
--- /dev/null
@@ -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 (file)
index 0000000..4555640
--- /dev/null
@@ -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
index 9736e093ecb7f80603a2128eb2e560700582c74f..7ebca7f3e98da61ede4e3099b66d0c80bcea4ae2 100644 (file)
@@ -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 (file)
index 0000000..821e7c7
--- /dev/null
@@ -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")
index 949349422680250c66565aa4e10624f412f76619..46056a6405f493e683ac218e528a074fcd38c347 100644 (file)
@@ -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
index bf120a66fd41793b7977c1096d46b5d16310be24..f943735f6ece8ad8c526fc829aedd6f05e60c01f 100644 (file)
--- 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