[placement] Add support for a version_handler decorator

Provide a decorator that allows Placement API handlers to
express a microversion window within which that method should be
used. This makes it possible to define N different methods (with the
same name) to present different behaviors dependent on what
microversion is requested by the user-agent.

If there is no match between a valid requested microversion and
available versioned handlers a 404 response is returned.

The mechanism for managing the versioned handlers is borrowed
from the compute api but adjusted to allow for the fact that
placement handlers are module level functions. Some code is
borrowed from twisted to get fully qualified names of methods for
the VERSION_METHODS dictionary.

Tests are added which validate that versioned handlers do not
intersect. In the rest of nova this is done at runtime, but
in the placement context this isn't necessary. Doing it at testime
is sufficient. Since there are currently no versioned handlers
in addition to the faked tests of the intersection handling I
also manually forced some tests with real handlers, just to be
sure.

Change-Id: I713c139ee12bb7f5301edd85951f8960fda84ac3
This commit is contained in:
Chris Dent
2016-10-18 16:47:32 +00:00
parent da12b6537f
commit 2ccf5fc6b2
2 changed files with 193 additions and 0 deletions

View File

@@ -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

View File

@@ -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)