diff --git a/nova/api/openstack/placement/microversion.py b/nova/api/openstack/placement/microversion.py index 62c5e0f244..a0397402c6 100644 --- a/nova/api/openstack/placement/microversion.py +++ b/nova/api/openstack/placement/microversion.py @@ -17,6 +17,7 @@ # the code now used in microversion_parse library. import collections +import inspect import microversion_parse import webob @@ -29,6 +30,7 @@ from nova.i18n import _ SERVICE_TYPE = 'placement' MICROVERSION_ENVIRON = '%s.microversion' % SERVICE_TYPE +VERSIONED_METHODS = collections.defaultdict(list) # The Canonical Version List VERSIONS = [ @@ -117,6 +119,9 @@ class Version(collections.namedtuple('Version', 'major minor')): def __str__(self): return '%s.%s' % (self.major, self.minor) + def __float__(self): + return float(self.__str__()) + @property def max_version(self): if not self.MAX_VERSION: @@ -154,3 +159,84 @@ def extract_version(headers): if (str(request_version) in VERSIONS and request_version.matches()): return request_version raise ValueError('Unacceptable version header: %s' % version_string) + + +# From twisted +# https://github.com/twisted/twisted/blob/trunk/twisted/python/deprecate.py +def _fully_qualified_name(obj): + """Return the fully qualified name of a module, class, method or function. + + Classes and functions need to be module level ones to be correctly + qualified. + """ + try: + name = obj.__qualname__ + except AttributeError: + name = obj.__name__ + + if inspect.isclass(obj) or inspect.isfunction(obj): + moduleName = obj.__module__ + return "%s.%s" % (moduleName, name) + elif inspect.ismethod(obj): + try: + cls = obj.im_class + except AttributeError: + # Python 3 eliminates im_class, substitutes __module__ and + # __qualname__ to provide similar information. + return "%s.%s" % (obj.__module__, obj.__qualname__) + else: + className = _fully_qualified_name(cls) + return "%s.%s" % (className, name) + return name + + +def _find_method(f, version_float): + """Look in VERSIONED_METHODS for method with right name matching version. + + If no match is found raise a 404. + """ + qualified_name = _fully_qualified_name(f) + # A KeyError shouldn't be possible here, but let's be robust + # just in case. + method_list = VERSIONED_METHODS.get(qualified_name, []) + for min_version, max_version, func in method_list: + if min_version <= version_float <= max_version: + return func + + raise webob.exc.HTTPNotFound() + + +def version_handler(min_ver, max_ver=None): + """Decorator for versioning API methods. + + Add as a decorator to a placement API handler to constrain + the microversions at which it will run. Add after the + ``wsgify`` decorator. + + This does not check for version intersections. That's the + domain of tests. + + :param min_ver: A string of two numerals, X.Y indicating the + minimum version allowed for the decorated method. + :param min_ver: A string of two numerals, X.Y, indicating the + maximum version allowed for the decorated method. + """ + def decorator(f): + min_version_float = float(min_ver) + if max_ver: + max_version_float = float(max_ver) + else: + max_version_float = float(max_version_string()) + qualified_name = _fully_qualified_name(f) + VERSIONED_METHODS[qualified_name].append( + (min_version_float, max_version_float, f)) + + def decorated_func(req, *args, **kwargs): + version_float = float(req.environ[MICROVERSION_ENVIRON]) + return _find_method(f, version_float)(req, *args, **kwargs) + + # Sort highest min version to beginning of list. + VERSIONED_METHODS[qualified_name].sort(key=lambda x: x[0], + reverse=True) + return decorated_func + return decorator diff --git a/nova/tests/unit/api/openstack/placement/test_microversion.py b/nova/tests/unit/api/openstack/placement/test_microversion.py new file mode 100644 index 0000000000..f20364da0e --- /dev/null +++ b/nova/tests/unit/api/openstack/placement/test_microversion.py @@ -0,0 +1,107 @@ +# 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 placement microversion handling.""" + +import collections +import mock +import operator + +# import the handlers to load up handler decorators +import nova.api.openstack.placement.handler # noqa +from nova.api.openstack.placement import microversion +from nova import test + + +def handler(): + return True + + +class TestMicroversionDecoration(test.NoDBTestCase): + + @mock.patch('nova.api.openstack.placement.microversion.VERSIONED_METHODS', + new=collections.defaultdict(list)) + def test_methods_structure(self): + """Test that VERSIONED_METHODS gets data as expected.""" + self.assertEqual(0, len(microversion.VERSIONED_METHODS)) + fully_qualified_method = microversion._fully_qualified_name( + handler) + microversion.version_handler('1.0', '1.9')(handler) + microversion.version_handler('2.0')(handler) + + methods_data = microversion.VERSIONED_METHODS[fully_qualified_method] + + stored_method_data = methods_data[-1] + self.assertEqual(2, len(methods_data)) + self.assertEqual(1.0, stored_method_data[0]) + self.assertEqual(1.9, stored_method_data[1]) + self.assertEqual(handler, stored_method_data[2]) + self.assertEqual(2.0, methods_data[0][0]) + + +class TestMicroversionIntersection(test.NoDBTestCase): + """Test that there are no overlaps in the versioned handlers.""" + + # If you add versioned handlers you need to update this value to + # reflect the change. The value is the total number of methods + # with different names, not the total number overall. That is, + # if you add two different versions of method 'foobar' the + # number only goes up by one if no other version foobar yet + # exists. This operates as a simple sanity check. + TOTAL_VERSIONED_METHODS = 0 + + def test_methods_versioned(self): + methods_data = microversion.VERSIONED_METHODS + self.assertEqual(self.TOTAL_VERSIONED_METHODS, len(methods_data)) + + @staticmethod + def _check_intersection(method_info): + # See check_for_versions_intersection in + # nova.api.openstack.wsgi. + pairs = [] + counter = 0 + for min_ver, max_ver, func in method_info: + pairs.append((min_ver, 1, func)) + pairs.append((max_ver, -1, func)) + + pairs.sort(key=operator.itemgetter(0)) + + for p in pairs: + counter += p[1] + if counter > 1: + return True + return False + + @mock.patch('nova.api.openstack.placement.microversion.VERSIONED_METHODS', + new=collections.defaultdict(list)) + def test_faked_intersection(self): + microversion.version_handler('1.0', '1.9')(handler) + microversion.version_handler('1.8', '2.0')(handler) + + for method_info in microversion.VERSIONED_METHODS.values(): + self.assertTrue(self._check_intersection(method_info)) + + @mock.patch('nova.api.openstack.placement.microversion.VERSIONED_METHODS', + new=collections.defaultdict(list)) + def test_faked_non_intersection(self): + microversion.version_handler('1.0', '1.8')(handler) + microversion.version_handler('1.9', '2.0')(handler) + + for method_info in microversion.VERSIONED_METHODS.values(): + self.assertFalse(self._check_intersection(method_info)) + + def test_check_real_for_intersection(self): + """Check the real handlers to make sure there is no intersctions.""" + for method_name, method_info in microversion.VERSIONED_METHODS.items(): + self.assertFalse( + self._check_intersection(method_info), + 'method %s has intersecting versioned handlers' % method_name)