nova/nova/api/openstack/placement/microversion.py

272 lines
10 KiB
Python

# 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.
"""Microversion handling."""
# NOTE(cdent): This code is taken from enamel:
# https://github.com/jaypipes/enamel and was the original source of
# the code now used in microversion_parse library.
import collections
import inspect
import microversion_parse
import webob
# NOTE(cdent): avoid cyclical import conflict between util and
# microversion
import nova.api.openstack.placement.util
from nova.i18n import _
SERVICE_TYPE = 'placement'
MICROVERSION_ENVIRON = '%s.microversion' % SERVICE_TYPE
VERSIONED_METHODS = collections.defaultdict(list)
# The Canonical Version List
VERSIONS = [
'1.0',
'1.1', # initial support for aggregate.get_aggregates and set_aggregates
'1.2', # Adds /resource_classes resource endpoint
'1.3', # Adds 'member_of' query parameter to get resource providers
# that are members of any of the listed aggregates
'1.4', # Adds resources query string parameter in GET /resource_providers
'1.5', # Adds DELETE /resource_providers/{uuid}/inventories
'1.6', # Adds /traits and /resource_providers{uuid}/traits resource
# endpoints
'1.7', # PUT /resource_classes/{name} is bodiless create or update
'1.8', # Adds 'project_id' and 'user_id' required request parameters to
# PUT /allocations
'1.9', # Adds GET /usages
'1.10', # Adds GET /allocation_candidates resource endpoint
'1.11', # Adds 'allocations' link to the GET /resource_providers response
'1.12', # Add project_id and user_id to GET /allocations/{consumer_uuid}
# and PUT to /allocations/{consumer_uuid} in the same dict form
# as GET. The 'allocation_requests' format in GET
# /allocation_candidates is updated to be the same as well.
'1.13', # Adds POST /allocations to set allocations for multiple consumers
'1.14', # Adds parent and root provider UUID on resource provider
# representation and 'in_tree' filter on GET /resource_providers
'1.15', # Include last-modified and cache-control headers
'1.16', # Add 'limit' query parameter to GET /allocation_candidates
'1.17', # Add 'required' query parameter to GET /allocation_candidates and
# return traits in the provider summary.
'1.18', # Support ?required=<traits> queryparam on GET /resource_providers
'1.19', # Include generation and conflict detection in provider aggregates
# APIs
'1.20', # Return 200 with provider payload from POST /resource_providers
]
def max_version_string():
return VERSIONS[-1]
def min_version_string():
return VERSIONS[0]
def parse_version_string(version_string):
"""Turn a version string into a Version
:param version_string: A string of two numerals, X.Y, or 'latest'
:returns: a Version
:raises: TypeError
"""
if version_string == 'latest':
version_string = max_version_string()
try:
# The combination of int and a limited split with the
# named tuple means that this incantation will raise
# ValueError or TypeError when the incoming data is
# poorly formed but will, however, naturally adapt to
# extraneous whitespace.
return Version(*(int(value) for value
in version_string.split('.', 1)))
except (ValueError, TypeError) as exc:
raise TypeError(
_('invalid version string: %(version_string)s; %(exc)s') %
{'version_string': version_string, 'exc': exc})
class MicroversionMiddleware(object):
"""WSGI middleware for getting microversion info."""
def __init__(self, application):
self.application = application
@webob.dec.wsgify
def __call__(self, req):
util = nova.api.openstack.placement.util
try:
microversion = extract_version(req.headers)
except ValueError as exc:
raise webob.exc.HTTPNotAcceptable(
_('Invalid microversion: %(error)s') % {'error': exc},
json_formatter=util.json_error_formatter)
except TypeError as exc:
raise webob.exc.HTTPBadRequest(
_('Invalid microversion: %(error)s') % {'error': exc},
json_formatter=util.json_error_formatter)
req.environ[MICROVERSION_ENVIRON] = microversion
microversion_header = '%s %s' % (SERVICE_TYPE, microversion)
try:
response = req.get_response(self.application)
except webob.exc.HTTPError as exc:
# If there was an error in the application we still need
# to send the microversion header, so add the header and
# re-raise the exception.
exc.headers.add(Version.HEADER, microversion_header)
raise exc
response.headers.add(Version.HEADER, microversion_header)
response.headers.add('vary', Version.HEADER)
return response
class Version(collections.namedtuple('Version', 'major minor')):
"""A namedtuple containing major and minor values.
Since it is a tuple is automatically comparable.
"""
HEADER = 'OpenStack-API-Version'
MIN_VERSION = None
MAX_VERSION = None
def __str__(self):
return '%s.%s' % (self.major, self.minor)
@property
def max_version(self):
if not self.MAX_VERSION:
self.MAX_VERSION = parse_version_string(max_version_string())
return self.MAX_VERSION
@property
def min_version(self):
if not self.MIN_VERSION:
self.MIN_VERSION = parse_version_string(min_version_string())
return self.MIN_VERSION
def matches(self, min_version=None, max_version=None):
if min_version is None:
min_version = self.min_version
if max_version is None:
max_version = self.max_version
return min_version <= self <= max_version
def extract_version(headers):
"""Extract the microversion from Version.HEADER
There may be multiple headers and some which don't match our
service.
"""
found_version = microversion_parse.get_version(headers,
service_type=SERVICE_TYPE)
version_string = found_version or min_version_string()
request_version = parse_version_string(version_string)
# We need a version that is in VERSION and within MIX and MAX.
# This gives us the option to administratively disable a
# version if we really need to.
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, status_code):
"""Look in VERSIONED_METHODS for method with right name matching version.
If no match is found a HTTPError corresponding to status_code will
be returned.
"""
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 <= max_version:
return func
raise webob.exc.status_map[status_code]
def version_handler(min_ver, max_ver=None, status_code=404):
"""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 max_ver: A string of two numerals, X.Y, indicating the
maximum version allowed for the decorated method.
:param status_code: A status code to indicate error, 404 by default
"""
def decorator(f):
min_version = parse_version_string(min_ver)
if max_ver:
max_version = parse_version_string(max_ver)
else:
max_version = parse_version_string(max_version_string())
qualified_name = _fully_qualified_name(f)
VERSIONED_METHODS[qualified_name].append(
(min_version, max_version, f))
def decorated_func(req, *args, **kwargs):
version = req.environ[MICROVERSION_ENVIRON]
return _find_method(f, version, status_code)(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