diff --git a/cinder/api/__init__.py b/cinder/api/__init__.py index 53712d1fe..083e1c519 100644 --- a/cinder/api/__init__.py +++ b/cinder/api/__init__.py @@ -28,11 +28,7 @@ LOG = logging.getLogger(__name__) def root_app_factory(loader, global_conf, **local_conf): if CONF.enable_v1_api: - LOG.warning(_LW('The v1 api is deprecated and will be removed in the ' - 'Liberty release. You should set enable_v1_api=false ' - 'and enable_v2_api=true in your cinder.conf file.')) - else: - del local_conf['/v1'] - if not CONF.enable_v2_api: - del local_conf['/v2'] + LOG.warning(_LW('The v1 api is deprecated and is not under active ' + 'development. You should set enable_v1_api=false ' + 'and enable_v3_api=true in your cinder.conf file.')) return paste.urlmap.urlmap_factory(loader, global_conf, **local_conf) diff --git a/cinder/api/common.py b/cinder/api/common.py index c7fc51568..17e7dddb4 100644 --- a/cinder/api/common.py +++ b/cinder/api/common.py @@ -38,6 +38,16 @@ api_common_opts = [ help='Base URL that will be presented to users in links ' 'to the OpenStack Volume API', deprecated_name='osapi_compute_link_prefix'), + cfg.ListOpt('query_volume_filters', + default=['name', 'status', 'metadata', + 'availability_zone', + 'bootable'], + help="Volume filter options which " + "non-admin user could use to " + "query volumes. Default values " + "are: ['name', 'status', " + "'metadata', 'availability_zone' ," + "'bootable']") ] CONF = cfg.CONF diff --git a/cinder/api/openstack/api_version_request.py b/cinder/api/openstack/api_version_request.py new file mode 100644 index 000000000..175cb2284 --- /dev/null +++ b/cinder/api/openstack/api_version_request.py @@ -0,0 +1,164 @@ +# Copyright 2014 IBM Corp. +# Copyright 2015 Clinton Knight +# 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 re + +from cinder.api.openstack import versioned_method +from cinder import exception +from cinder.i18n import _ +from cinder import utils + +# Define the minimum and maximum version of the API across all of the +# REST API. The format of the version is: +# X.Y where: +# +# - X will only be changed if a significant backwards incompatible API +# change is made which affects the API as whole. That is, something +# that is only very very rarely incremented. +# +# - Y when you make any change to the API. Note that this includes +# semantic changes which may not affect the input or output formats or +# even originate in the API code layer. We are not distinguishing +# between backwards compatible and backwards incompatible changes in +# the versioning system. It must be made clear in the documentation as +# to what is a backwards compatible change and what is a backwards +# incompatible one. + +# +# You must update the API version history string below with a one or +# two line description as well as update rest_api_version_history.rst +REST_API_VERSION_HISTORY = """ + + REST API Version History: + + * 3.0 - Includes all V2 APIs and extensions. V1 API is still supported. + * 3.0 - Versions API updated to reflect beginning of microversions epoch. + +""" + +# The minimum and maximum versions of the API supported +# The default api version request is defined to be the +# the minimum version of the API supported. +# Explicitly using /v1 or /v2 enpoints will still work +_MIN_API_VERSION = "3.0" +_MAX_API_VERSION = "3.0" +_LEGACY_API_VERSION1 = "1.0" +_LEGACY_API_VERSION2 = "2.0" + + +# NOTE(cyeoh): min and max versions declared as functions so we can +# mock them for unittests. Do not use the constants directly anywhere +# else. +def min_api_version(): + return APIVersionRequest(_MIN_API_VERSION) + + +def max_api_version(): + return APIVersionRequest(_MAX_API_VERSION) + + +def legacy_api_version1(): + return APIVersionRequest(_LEGACY_API_VERSION1) + + +def legacy_api_version2(): + return APIVersionRequest(_LEGACY_API_VERSION2) + + +class APIVersionRequest(utils.ComparableMixin): + """This class represents an API Version Request. + + This class includes convenience methods for manipulation + and comparison of version numbers as needed to implement + API microversions. + """ + + def __init__(self, version_string=None, experimental=False): + """Create an API version request object.""" + self._ver_major = None + self._ver_minor = None + + if version_string is not None: + match = re.match(r"^([1-9]\d*)\.([1-9]\d*|0)$", + version_string) + if match: + self._ver_major = int(match.group(1)) + self._ver_minor = int(match.group(2)) + else: + raise exception.InvalidAPIVersionString(version=version_string) + + def __str__(self): + """Debug/Logging representation of object.""" + return ("API Version Request Major: %(major)s, Minor: %(minor)s" + % {'major': self._ver_major, 'minor': self._ver_minor}) + + def is_null(self): + return self._ver_major is None and self._ver_minor is None + + def _cmpkey(self): + """Return the value used by ComparableMixin for rich comparisons.""" + return self._ver_major, self._ver_minor + + def matches_versioned_method(self, method): + """Compares this version to that of a versioned method.""" + + if type(method) != versioned_method.VersionedMethod: + msg = _('An API version request must be compared ' + 'to a VersionedMethod object.') + raise exception.InvalidParameterValue(err=msg) + + return self.matches(method.start_version, + method.end_version, + method.experimental) + + def matches(self, min_version, max_version, experimental=False): + """Compares this version to the specified min/max range. + + Returns whether the version object represents a version + greater than or equal to the minimum version and less than + or equal to the maximum version. + + If min_version is null then there is no minimum limit. + If max_version is null then there is no maximum limit. + If self is null then raise ValueError. + + :param min_version: Minimum acceptable version. + :param max_version: Maximum acceptable version. + :param experimental: Whether to match experimental APIs. + :returns: boolean + """ + + if self.is_null(): + raise ValueError + if max_version.is_null() and min_version.is_null(): + return True + elif max_version.is_null(): + return min_version <= self + elif min_version.is_null(): + return self <= max_version + else: + return min_version <= self <= max_version + + def get_string(self): + """Returns a string representation of this object. + + If this method is used to create an APIVersionRequest, + the resulting object will be an equivalent request. + """ + if self.is_null(): + raise ValueError + return ("%(major)s.%(minor)s" % + {'major': self._ver_major, 'minor': self._ver_minor}) diff --git a/cinder/api/openstack/rest_api_version_history.rst b/cinder/api/openstack/rest_api_version_history.rst new file mode 100644 index 000000000..2cc45d2cb --- /dev/null +++ b/cinder/api/openstack/rest_api_version_history.rst @@ -0,0 +1,30 @@ +REST API Version History +======================== + +This documents the changes made to the REST API with every +microversion change. The description for each version should be a +verbose one which has enough information to be suitable for use in +user documentation. + +3.0 +--- + The 3.0 Cinder API includes all v2 core APIs existing prior to + the introduction of microversions. The /v3 URL is used to call + 3.0 APIs. + This it the initial version of the Cinder API which supports + microversions. + + A user can specify a header in the API request:: + + OpenStack-Volume-microversion: + + where ```` is any valid api version for this API. + + If no version is specified then the API will behave as if version 3.0 + was requested. + + The only API change in version 3.0 is versions, i.e. + GET http://localhost:8786/, which now returns information about + 3.0 and later versions and their respective /v3 endpoints. + + All other 3.0 APIs are functionally identical to version 2.0. diff --git a/cinder/api/openstack/versioned_method.py b/cinder/api/openstack/versioned_method.py new file mode 100644 index 000000000..077e87149 --- /dev/null +++ b/cinder/api/openstack/versioned_method.py @@ -0,0 +1,48 @@ +# Copyright 2014 IBM Corp. +# Copyright 2015 Clinton Knight +# 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 utils + + +class VersionedMethod(utils.ComparableMixin): + + def __init__(self, name, start_version, end_version, experimental, func): + """Versioning information for a single method. + + Minimum and maximums are inclusive. + + :param name: Name of the method + :param start_version: Minimum acceptable version + :param end_version: Maximum acceptable_version + :param func: Method to call + """ + self.name = name + self.start_version = start_version + self.end_version = end_version + self.experimental = experimental + self.func = func + + def __str__(self): + args = { + 'name': self.name, + 'start': self.start_version, + 'end': self.end_version + } + return ("Version Method %(name)s: min: %(start)s, max: %(end)s" % args) + + def _cmpkey(self): + """Return the value used by ComparableMixin for rich comparisons.""" + return self.start_version diff --git a/cinder/api/openstack/wsgi.py b/cinder/api/openstack/wsgi.py index f49047173..fd8e93554 100644 --- a/cinder/api/openstack/wsgi.py +++ b/cinder/api/openstack/wsgi.py @@ -14,6 +14,7 @@ # License for the specific language governing permissions and limitations # under the License. +import functools import inspect import math import time @@ -25,9 +26,13 @@ from oslo_log import log as logging from oslo_log import versionutils from oslo_serialization import jsonutils from oslo_utils import excutils +from oslo_utils import strutils import six import webob +import webob.exc +from cinder.api.openstack import api_version_request as api_version +from cinder.api.openstack import versioned_method from cinder import exception from cinder import i18n from cinder.i18n import _, _LE, _LI @@ -58,12 +63,22 @@ _MEDIA_TYPE_MAP = { } +# name of attribute to keep version method information +VER_METHOD_ATTR = 'versioned_methods' + +# Name of header used by clients to request a specific version +# of the REST API +API_VERSION_REQUEST_HEADER = 'OpenStack-Volume-microversion' + + class Request(webob.Request): """Add some OpenStack API-specific logic to the base webob.Request.""" def __init__(self, *args, **kwargs): super(Request, self).__init__(*args, **kwargs) self._resource_cache = {} + if not hasattr(self, 'api_version_request'): + self.api_version_request = api_version.APIVersionRequest() def cache_resource(self, resource_to_cache, id_attribute='id', name=None): """Cache the given resource. @@ -269,6 +284,45 @@ class Request(webob.Request): all_languages = i18n.get_available_languages() return self.accept_language.best_match(all_languages) + def set_api_version_request(self, url): + """Set API version request based on the request header information. + + Microversions starts with /v3, so if a client sends a request for + version 1.0 or 2.0 with the /v3 endpoint, throw an exception. + Sending a header with any microversion to a /v1 or /v2 endpoint will + be ignored. + Note that a microversion must be set for the legacy endpoints. This + will appear as 1.0 and 2.0 for /v1 and /v2. + """ + if API_VERSION_REQUEST_HEADER in self.headers and 'v3' in url: + hdr_string = self.headers[API_VERSION_REQUEST_HEADER] + # 'latest' is a special keyword which is equivalent to requesting + # the maximum version of the API supported + if hdr_string == 'latest': + self.api_version_request = api_version.max_api_version() + else: + self.api_version_request = api_version.APIVersionRequest( + hdr_string) + + # Check that the version requested is within the global + # minimum/maximum of supported API versions + if not self.api_version_request.matches( + api_version.min_api_version(), + api_version.max_api_version()): + raise exception.InvalidGlobalAPIVersion( + req_ver=self.api_version_request.get_string(), + min_ver=api_version.min_api_version().get_string(), + max_ver=api_version.max_api_version().get_string()) + + else: + if 'v1' in url: + self.api_version_request = api_version.legacy_api_version1() + elif 'v2' in url: + self.api_version_request = api_version.legacy_api_version2() + else: + self.api_version_request = api_version.APIVersionRequest( + api_version._MIN_API_VERSION) + class ActionDispatcher(object): """Maps method name to local methods through action name.""" @@ -276,7 +330,7 @@ class ActionDispatcher(object): def dispatch(self, *args, **kwargs): """Find and call local method.""" action = kwargs.pop('action', 'default') - action_method = getattr(self, str(action), self.default) + action_method = getattr(self, six.text_type(action), self.default) return action_method(*args, **kwargs) def default(self, data): @@ -571,7 +625,7 @@ class ResponseObject(object): optional. """ - def __init__(self, obj, code=None, **serializers): + def __init__(self, obj, code=None, headers=None, **serializers): """Binds serializers with an object. Takes keyword arguments akin to the @serializer() decorator @@ -584,7 +638,7 @@ class ResponseObject(object): self.serializers = serializers self._default_code = 200 self._code = code - self._headers = {} + self._headers = headers or {} self.serializer = None self.media_type = None @@ -677,8 +731,8 @@ class ResponseObject(object): response = webob.Response() response.status_int = self.code for hdr, value in self._headers.items(): - response.headers[hdr] = value - response.headers['Content-Type'] = content_type + response.headers[hdr] = six.text_type(value) + response.headers['Content-Type'] = six.text_type(content_type) if self.obj is not None: body = serializer.serialize(self.obj) if isinstance(body, six.text_type): @@ -743,10 +797,13 @@ class ResourceExceptionHandler(object): return True if isinstance(ex_value, exception.NotAuthorized): - raise Fault(webob.exc.HTTPForbidden(explanation=ex_value.msg)) + msg = six.text_type(ex_value) + raise Fault(webob.exc.HTTPForbidden(explanation=msg)) + elif isinstance(ex_value, exception.VersionNotFoundForAPIMethod): + raise elif isinstance(ex_value, exception.Invalid): raise Fault(exception.ConvertedException( - code=ex_value.code, explanation=ex_value.msg)) + code=ex_value.code, explanation=six.text_type(ex_value))) elif isinstance(ex_value, TypeError): exc_info = (ex_type, ex_value, ex_traceback) LOG.error(_LE( @@ -754,10 +811,10 @@ class ResourceExceptionHandler(object): ex_value, exc_info=exc_info) raise Fault(webob.exc.HTTPBadRequest()) elif isinstance(ex_value, Fault): - LOG.info(_LI("Fault thrown: %s"), ex_value) + LOG.info(_LI("Fault thrown: %s"), six.text_type(ex_value)) raise ex_value elif isinstance(ex_value, webob.exc.HTTPException): - LOG.info(_LI("HTTP exception thrown: %s"), ex_value) + LOG.info(_LI("HTTP exception thrown: %s"), six.text_type(ex_value)) raise Fault(ex_value) # We didn't handle the exception @@ -778,6 +835,7 @@ class Resource(wsgi.Application): Exceptions derived from webob.exc.HTTPException will be automatically wrapped in Fault() to provide API friendly error responses. """ + support_api_request_version = True def __init__(self, controller, action_peek=None, **deserializers): """Initialize Resource. @@ -943,6 +1001,11 @@ class Resource(wsgi.Application): with ResourceExceptionHandler(): response = ext(req=request, resp_obj=resp_obj, **action_args) + except exception.VersionNotFoundForAPIMethod: + # If an attached extension (@wsgi.extends) for the + # method has no version match its not an error. We + # just don't run the extends code + continue except Fault as ex: response = ex @@ -960,6 +1023,17 @@ class Resource(wsgi.Application): {"method": request.method, "url": request.url}) + if self.support_api_request_version: + # Set the version of the API requested based on the header + try: + request.set_api_version_request(request.url) + except exception.InvalidAPIVersionString as e: + return Fault(webob.exc.HTTPBadRequest( + explanation=six.text_type(e))) + except exception.InvalidGlobalAPIVersion as e: + return Fault(webob.exc.HTTPNotAcceptable( + explanation=six.text_type(e))) + # Identify the action, its arguments, and the requested # content type action_args = self.get_action_args(request.environ) @@ -992,6 +1066,16 @@ class Resource(wsgi.Application): msg = _("Malformed request body") return Fault(webob.exc.HTTPBadRequest(explanation=msg)) + if body: + msg = ("Action: '%(action)s', calling method: %(meth)s, body: " + "%(body)s") % {'action': action, + 'body': six.text_type(body), + 'meth': six.text_type(meth)} + LOG.debug(strutils.mask_password(msg)) + else: + LOG.debug("Calling method '%(meth)s'", + {'meth': six.text_type(meth)}) + # Now, deserialize the request body... try: if content_type: @@ -1029,7 +1113,7 @@ class Resource(wsgi.Application): # No exceptions; convert action_result into a # ResponseObject resp_obj = None - if type(action_result) is dict or action_result is None: + if isinstance(action_result, dict) or action_result is None: resp_obj = ResponseObject(action_result) elif isinstance(action_result, ResponseObject): resp_obj = action_result @@ -1063,6 +1147,21 @@ class Resource(wsgi.Application): LOG.info(msg, msg_dict) + if hasattr(response, 'headers'): + for hdr, val in response.headers.items(): + # Headers must be utf-8 strings + try: + # python 2.x + response.headers[hdr] = val.encode('utf-8') + except Exception: + # python 3.x + response.headers[hdr] = six.text_type(val) + + if not request.api_version_request.is_null(): + response.headers[API_VERSION_REQUEST_HEADER] = ( + request.api_version_request.get_string()) + response.headers['Vary'] = API_VERSION_REQUEST_HEADER + return response def get_method(self, request, action, content_type, body): @@ -1101,7 +1200,13 @@ class Resource(wsgi.Application): def dispatch(self, method, request, action_args): """Dispatch a call to the action-specific method.""" - return method(req=request, **action_args) + try: + return method(req=request, **action_args) + except exception.VersionNotFoundForAPIMethod: + # We deliberately don't return any message information + # about the exception to the user so it looks as if + # the method is simply not implemented. + return Fault(webob.exc.HTTPNotFound()) def action(name): @@ -1161,9 +1266,22 @@ class ControllerMetaclass(type): # Find all actions actions = {} extensions = [] + versioned_methods = None # start with wsgi actions from base classes for base in bases: actions.update(getattr(base, 'wsgi_actions', {})) + + if base.__name__ == "Controller": + # NOTE(cyeoh): This resets the VER_METHOD_ATTR attribute + # between API controller class creations. This allows us + # to use a class decorator on the API methods that doesn't + # require naming explicitly what method is being versioned as + # it can be implicit based on the method decorated. It is a bit + # ugly. + if VER_METHOD_ATTR in base.__dict__: + versioned_methods = getattr(base, VER_METHOD_ATTR) + delattr(base, VER_METHOD_ATTR) + for key, value in cls_dict.items(): if not callable(value): continue @@ -1175,6 +1293,8 @@ class ControllerMetaclass(type): # Add the actions and extensions to the class dict cls_dict['wsgi_actions'] = actions cls_dict['wsgi_extensions'] = extensions + if versioned_methods: + cls_dict[VER_METHOD_ATTR] = versioned_methods return super(ControllerMetaclass, mcs).__new__(mcs, name, bases, cls_dict) @@ -1195,6 +1315,99 @@ class Controller(object): else: self._view_builder = None + def __getattribute__(self, key): + + def version_select(*args, **kwargs): + """Select and call the matching version of the specified method. + + Look for the method which matches the name supplied and version + constraints and calls it with the supplied arguments. + + :returns: Returns the result of the method called + :raises: VersionNotFoundForAPIMethod if there is no method which + matches the name and version constraints + """ + + # The first arg to all versioned methods is always the request + # object. The version for the request is attached to the + # request object + if len(args) == 0: + version_request = kwargs['req'].api_version_request + else: + version_request = args[0].api_version_request + + func_list = self.versioned_methods[key] + for func in func_list: + if version_request.matches_versioned_method(func): + # Update the version_select wrapper function so + # other decorator attributes like wsgi.response + # are still respected. + functools.update_wrapper(version_select, func.func) + return func.func(self, *args, **kwargs) + + # No version match + raise exception.VersionNotFoundForAPIMethod( + version=version_request) + + try: + version_meth_dict = object.__getattribute__(self, VER_METHOD_ATTR) + except AttributeError: + # No versioning on this class + return object.__getattribute__(self, key) + + if (version_meth_dict and key in + object.__getattribute__(self, VER_METHOD_ATTR)): + + return version_select + + return object.__getattribute__(self, key) + + # NOTE(cyeoh): This decorator MUST appear first (the outermost + # decorator) on an API method for it to work correctly + @classmethod + def api_version(cls, min_ver, max_ver=None, experimental=False): + """Decorator for versioning API methods. + + Add the decorator to any method which takes a request object + as the first parameter and belongs to a class which inherits from + wsgi.Controller. + + :param min_ver: string representing minimum version + :param max_ver: optional string representing maximum version + """ + + def decorator(f): + obj_min_ver = api_version.APIVersionRequest(min_ver) + if max_ver: + obj_max_ver = api_version.APIVersionRequest(max_ver) + else: + obj_max_ver = api_version.APIVersionRequest() + + # Add to list of versioned methods registered + func_name = f.__name__ + new_func = versioned_method.VersionedMethod( + func_name, obj_min_ver, obj_max_ver, experimental, f) + + func_dict = getattr(cls, VER_METHOD_ATTR, {}) + if not func_dict: + setattr(cls, VER_METHOD_ATTR, func_dict) + + func_list = func_dict.get(func_name, []) + if not func_list: + func_dict[func_name] = func_list + func_list.append(new_func) + # Ensure the list is sorted by minimum version (reversed) + # so later when we work through the list in order we find + # the method which has the latest version which supports + # the version requested. + # TODO(cyeoh): Add check to ensure that there are no overlapping + # ranges of valid versions as that is ambiguous + func_list.sort(reverse=True) + + return f + + return decorator + @staticmethod def is_valid_body(body, entity_name): if not (body and entity_name in body): @@ -1330,6 +1543,11 @@ class Fault(webob.exc.HTTPException): if retry: fault_data[fault_name]['retryAfter'] = retry + if not req.api_version_request.is_null(): + self.wrapped_exc.headers[API_VERSION_REQUEST_HEADER] = ( + req.api_version_request.get_string()) + self.wrapped_exc.headers['Vary'] = API_VERSION_REQUEST_HEADER + # 'code' is an attribute on the fault tag itself metadata = {'attributes': {fault_name: 'code'}} diff --git a/cinder/api/v1/router.py b/cinder/api/v1/router.py index 97e2807ee..67c8f8181 100644 --- a/cinder/api/v1/router.py +++ b/cinder/api/v1/router.py @@ -43,7 +43,7 @@ class APIRouter(cinder.api.openstack.APIRouter): self.resources['versions'] = versions.create_resource() mapper.connect("versions", "/", controller=self.resources['versions'], - action='show') + action='index') mapper.redirect("", "/") diff --git a/cinder/api/v2/router.py b/cinder/api/v2/router.py index 8ae0460f2..491423000 100644 --- a/cinder/api/v2/router.py +++ b/cinder/api/v2/router.py @@ -43,7 +43,7 @@ class APIRouter(cinder.api.openstack.APIRouter): self.resources['versions'] = versions.create_resource() mapper.connect("versions", "/", controller=self.resources['versions'], - action='show') + action='index') mapper.redirect("", "/") diff --git a/cinder/api/v2/volumes.py b/cinder/api/v2/volumes.py index 66f494523..d7aa1c0ba 100644 --- a/cinder/api/v2/volumes.py +++ b/cinder/api/v2/volumes.py @@ -35,20 +35,7 @@ from cinder import volume as cinder_volume from cinder.volume import utils as volume_utils from cinder.volume import volume_types - -query_volume_filters_opt = cfg.ListOpt('query_volume_filters', - default=['name', 'status', 'metadata', - 'availability_zone', - 'bootable'], - help="Volume filter options which " - "non-admin user could use to " - "query volumes. Default values " - "are: ['name', 'status', " - "'metadata', 'availability_zone'," - "'bootable']") - CONF = cfg.CONF -CONF.register_opt(query_volume_filters_opt) LOG = logging.getLogger(__name__) SCHEDULER_HINTS_NAMESPACE =\ diff --git a/cinder/api/v3/__init__.py b/cinder/api/v3/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/cinder/api/v3/router.py b/cinder/api/v3/router.py new file mode 100644 index 000000000..491423000 --- /dev/null +++ b/cinder/api/v3/router.py @@ -0,0 +1,99 @@ +# Copyright 2011 OpenStack Foundation +# Copyright 2011 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# 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. + +""" +WSGI middleware for OpenStack Volume API. +""" + +from oslo_log import log as logging + +from cinder.api import extensions +import cinder.api.openstack +from cinder.api.v2 import limits +from cinder.api.v2 import snapshot_metadata +from cinder.api.v2 import snapshots +from cinder.api.v2 import types +from cinder.api.v2 import volume_metadata +from cinder.api.v2 import volumes +from cinder.api import versions + + +LOG = logging.getLogger(__name__) + + +class APIRouter(cinder.api.openstack.APIRouter): + """Routes requests on the API to the appropriate controller and method.""" + ExtensionManager = extensions.ExtensionManager + + def _setup_routes(self, mapper, ext_mgr): + self.resources['versions'] = versions.create_resource() + mapper.connect("versions", "/", + controller=self.resources['versions'], + action='index') + + mapper.redirect("", "/") + + self.resources['volumes'] = volumes.create_resource(ext_mgr) + mapper.resource("volume", "volumes", + controller=self.resources['volumes'], + collection={'detail': 'GET'}, + member={'action': 'POST'}) + + self.resources['types'] = types.create_resource() + mapper.resource("type", "types", + controller=self.resources['types'], + member={'action': 'POST'}) + + self.resources['snapshots'] = snapshots.create_resource(ext_mgr) + mapper.resource("snapshot", "snapshots", + controller=self.resources['snapshots'], + collection={'detail': 'GET'}, + member={'action': 'POST'}) + + self.resources['limits'] = limits.create_resource() + mapper.resource("limit", "limits", + controller=self.resources['limits']) + + self.resources['snapshot_metadata'] = \ + snapshot_metadata.create_resource() + snapshot_metadata_controller = self.resources['snapshot_metadata'] + + mapper.resource("snapshot_metadata", "metadata", + controller=snapshot_metadata_controller, + parent_resource=dict(member_name='snapshot', + collection_name='snapshots')) + + mapper.connect("metadata", + "/{project_id}/snapshots/{snapshot_id}/metadata", + controller=snapshot_metadata_controller, + action='update_all', + conditions={"method": ['PUT']}) + + self.resources['volume_metadata'] = \ + volume_metadata.create_resource() + volume_metadata_controller = self.resources['volume_metadata'] + + mapper.resource("volume_metadata", "metadata", + controller=volume_metadata_controller, + parent_resource=dict(member_name='volume', + collection_name='volumes')) + + mapper.connect("metadata", + "/{project_id}/volumes/{volume_id}/metadata", + controller=volume_metadata_controller, + action='update_all', + conditions={"method": ['PUT']}) diff --git a/cinder/api/versions.py b/cinder/api/versions.py index 8bbf18803..9cc10bde5 100644 --- a/cinder/api/versions.py +++ b/cinder/api/versions.py @@ -1,4 +1,5 @@ # Copyright 2010 OpenStack Foundation +# Copyright 2015 Clinton Knight # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -14,11 +15,15 @@ # under the License. +import copy import datetime from lxml import etree from oslo_config import cfg +from cinder.api import extensions +from cinder.api import openstack +from cinder.api.openstack import api_version_request from cinder.api.openstack import wsgi from cinder.api.views import versions as views_versions from cinder.api import xmlutil @@ -26,67 +31,115 @@ from cinder.api import xmlutil CONF = cfg.CONF +_LINKS = [{ + "rel": "describedby", + "type": "text/html", + "href": "http://docs.openstack.org/", +}] + +_MEDIA_TYPES = [{ + "base": + "application/json", + "type": + "application/vnd.openstack.volume+json;version=1", +}, + {"base": + "application/xml", + "type": + "application/vnd.openstack.volume+xml;version=1", + }, +] _KNOWN_VERSIONS = { - "v2.0": { - "id": "v2.0", - "status": "CURRENT", - "updated": "2012-11-21T11:33:21Z", - "links": [ - { - "rel": "describedby", - "type": "text/html", - "href": "http://docs.openstack.org/", - }, - ], - "media-types": [ - { - "base": "application/xml", - "type": "application/vnd.openstack.volume+xml;version=1", - }, - { - "base": "application/json", - "type": "application/vnd.openstack.volume+json;version=1", - } - ], - }, "v1.0": { "id": "v1.0", "status": "SUPPORTED", + "version": "", + "min_version": "", "updated": "2014-06-28T12:20:21Z", - "links": [ - { - "rel": "describedby", - "type": "text/html", - "href": "http://docs.openstack.org/", - }, - ], - "media-types": [ - { - "base": "application/xml", - "type": "application/vnd.openstack.volume+xml;version=1", - }, - { - "base": "application/json", - "type": "application/vnd.openstack.volume+json;version=1", - } - ], - } + "links": _LINKS, + "media-types": _MEDIA_TYPES, + }, + "v2.0": { + "id": "v2.0", + "status": "SUPPORTED", + "version": "", + "min_version": "", + "updated": "2014-06-28T12:20:21Z", + "links": _LINKS, + "media-types": _MEDIA_TYPES, + }, + "v3.0": { + "id": "v3.0", + "status": "CURRENT", + "version": api_version_request._MAX_API_VERSION, + "min_version": api_version_request._MIN_API_VERSION, + "updated": "2016-02-08T12:20:21Z", + "links": _LINKS, + "media-types": _MEDIA_TYPES, + }, } -def get_supported_versions(): - versions = {} +class Versions(openstack.APIRouter): + """Route versions requests.""" - if CONF.enable_v1_api: - versions['v1.0'] = _KNOWN_VERSIONS['v1.0'] - if CONF.enable_v2_api: - versions['v2.0'] = _KNOWN_VERSIONS['v2.0'] + ExtensionManager = extensions.ExtensionManager - return versions + def _setup_routes(self, mapper, ext_mgr): + self.resources['versions'] = create_resource() + mapper.connect('versions', '/', + controller=self.resources['versions'], + action='all') + mapper.redirect('', '/') + + +class VersionsController(wsgi.Controller): + + def __init__(self): + super(VersionsController, self).__init__(None) + + @wsgi.Controller.api_version('1.0') + def index(self, req): # pylint: disable=E0102 + """Return versions supported prior to the microversions epoch.""" + builder = views_versions.get_view_builder(req) + known_versions = copy.deepcopy(_KNOWN_VERSIONS) + known_versions.pop('v2.0') + known_versions.pop('v3.0') + return builder.build_versions(known_versions) + + @wsgi.Controller.api_version('2.0') # noqa + def index(self, req): # pylint: disable=E0102 + """Return versions supported prior to the microversions epoch.""" + builder = views_versions.get_view_builder(req) + known_versions = copy.deepcopy(_KNOWN_VERSIONS) + known_versions.pop('v1.0') + known_versions.pop('v3.0') + return builder.build_versions(known_versions) + + @wsgi.Controller.api_version('3.0') # noqa + def index(self, req): # pylint: disable=E0102 + """Return versions supported after the start of microversions.""" + builder = views_versions.get_view_builder(req) + known_versions = copy.deepcopy(_KNOWN_VERSIONS) + known_versions.pop('v1.0') + known_versions.pop('v2.0') + return builder.build_versions(known_versions) + + # NOTE (cknight): Calling the versions API without + # /v1, /v2, or /v3 in the URL will lead to this unversioned + # method, which should always return info about all + # available versions. + @wsgi.response(300) + def all(self, req): + """Return all known versions.""" + builder = views_versions.get_view_builder(req) + known_versions = copy.deepcopy(_KNOWN_VERSIONS) + return builder.build_versions(known_versions) class MediaTypesTemplateElement(xmlutil.TemplateElement): + def will_render(self, datum): return 'media-types' in datum @@ -110,6 +163,7 @@ version_nsmap = {None: xmlutil.XMLNS_COMMON_V10, 'atom': xmlutil.XMLNS_ATOM} class VersionTemplate(xmlutil.TemplateBuilder): + def construct(self): root = xmlutil.TemplateElement('version', selector='version') make_version(root) @@ -117,6 +171,7 @@ class VersionTemplate(xmlutil.TemplateBuilder): class VersionsTemplate(xmlutil.TemplateBuilder): + def construct(self): root = xmlutil.TemplateElement('versions') elem = xmlutil.SubTemplateElement(root, 'version', selector='versions') @@ -125,6 +180,7 @@ class VersionsTemplate(xmlutil.TemplateBuilder): class ChoicesTemplate(xmlutil.TemplateBuilder): + def construct(self): root = xmlutil.TemplateElement('choices') elem = xmlutil.SubTemplateElement(root, 'version', selector='choices') @@ -209,6 +265,7 @@ class AtomSerializer(wsgi.XMLDictSerializer): class VersionsAtomSerializer(AtomSerializer): + def default(self, data): versions = data['versions'] feed_id = self._get_base_url(versions[0]['links'][0]['href']) @@ -217,6 +274,7 @@ class VersionsAtomSerializer(AtomSerializer): class VersionAtomSerializer(AtomSerializer): + def default(self, data): version = data['version'] feed_id = version['links'][0]['href'] @@ -224,46 +282,5 @@ class VersionAtomSerializer(AtomSerializer): return self._to_xml(feed) -class Versions(wsgi.Resource): - - def __init__(self): - super(Versions, self).__init__(None) - - @wsgi.serializers(xml=VersionsTemplate, - atom=VersionsAtomSerializer) - def index(self, req): - """Return all versions.""" - builder = views_versions.get_view_builder(req) - return builder.build_versions(get_supported_versions()) - - @wsgi.serializers(xml=ChoicesTemplate) - @wsgi.response(300) - def multi(self, req): - """Return multiple choices.""" - builder = views_versions.get_view_builder(req) - return builder.build_choices(get_supported_versions(), req) - - def get_action_args(self, request_environment): - """Parse dictionary created by routes library.""" - args = {} - if request_environment['PATH_INFO'] == '/': - args['action'] = 'index' - else: - args['action'] = 'multi' - - return args - - -class VolumeVersion(object): - @wsgi.serializers(xml=VersionTemplate, - atom=VersionAtomSerializer) - def show(self, req): - builder = views_versions.get_view_builder(req) - if 'v1' in builder.base_url: - return builder.build_version(_KNOWN_VERSIONS['v1.0']) - else: - return builder.build_version(_KNOWN_VERSIONS['v2.0']) - - def create_resource(): - return wsgi.Resource(VolumeVersion()) + return wsgi.Resource(VersionsController()) diff --git a/cinder/api/views/versions.py b/cinder/api/views/versions.py index 1129f3470..873f5d918 100644 --- a/cinder/api/views/versions.py +++ b/cinder/api/views/versions.py @@ -1,4 +1,5 @@ # Copyright 2010-2011 OpenStack Foundation +# Copyright 2015 Clinton Knight # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -14,9 +15,10 @@ # under the License. import copy -import os +import re from oslo_config import cfg +from six.moves import urllib versions_opts = [ @@ -45,57 +47,32 @@ class ViewBuilder(object): """ self.base_url = base_url - def build_choices(self, VERSIONS, req): - version_objs = [] - for version in VERSIONS: - version = VERSIONS[version] - version_objs.append({ - "id": version['id'], - "status": version['status'], - "links": [{"rel": "self", - "href": self.generate_href(version['id'], - req.path), }, ], - "media-types": version['media-types'], }) - - return dict(choices=version_objs) - def build_versions(self, versions): - version_objs = [] - for version in sorted(versions.keys()): - version = versions[version] - version_objs.append({ - "id": version['id'], - "status": version['status'], - "updated": version['updated'], - "links": self._build_links(version), }) + views = [self._build_version(versions[key]) + for key in sorted(list(versions.keys()))] + return dict(versions=views) - return dict(versions=version_objs) - - def build_version(self, version): - reval = copy.deepcopy(version) - reval['links'].insert(0, { - "rel": "self", - "href": self.base_url.rstrip('/') + '/', }) - return dict(version=reval) + def _build_version(self, version): + view = copy.deepcopy(version) + view['links'] = self._build_links(version) + return view def _build_links(self, version_data): """Generate a container of links that refer to the provided version.""" - href = self.generate_href(version_data['id']) - - links = [{'rel': 'self', - 'href': href, }, ] - + links = copy.deepcopy(version_data.get('links', {})) + version_num = version_data["id"].split('.')[0] + links.append({'rel': 'self', + 'href': self._generate_href(version=version_num)}) return links - def generate_href(self, version, path=None): - """Create an url that refers to a specific version_number.""" - if version.find('v1.') == 0: - version_number = 'v1' - else: - version_number = 'v2' - + def _generate_href(self, version='v3', path=None): + """Create a URL that refers to a specific version_number.""" + base_url = self._get_base_url_without_version() + href = urllib.parse.urljoin(base_url, version).rstrip('/') + '/' if path: - path = path.strip('/') - return os.path.join(self.base_url, version_number, path) - else: - return os.path.join(self.base_url, version_number) + '/' + href += path.lstrip('/') + return href + + def _get_base_url_without_version(self): + """Get the base URL with out the /v1 suffix.""" + return re.sub('v[1-9]+/?$', '', self.base_url) diff --git a/cinder/common/config.py b/cinder/common/config.py index 4965fc290..65390fad2 100644 --- a/cinder/common/config.py +++ b/cinder/common/config.py @@ -103,7 +103,10 @@ global_opts = [ help=_("DEPRECATED: Deploy v1 of the Cinder API.")), cfg.BoolOpt('enable_v2_api', default=True, - help=_("Deploy v2 of the Cinder API.")), + help=_("DEPRECATED: Deploy v2 of the Cinder API.")), + cfg.BoolOpt('enable_v3_api', + default=True, + help=_("Deploy v3 of the Cinder API.")), cfg.BoolOpt('api_rate_limit', default=True, help='Enables or disables rate limit of the API.'), diff --git a/cinder/exception.py b/cinder/exception.py index 88b3c3da9..f11187ce1 100644 --- a/cinder/exception.py +++ b/cinder/exception.py @@ -243,6 +243,20 @@ class InvalidUUID(Invalid): message = _("Expected a uuid but received %(uuid)s.") +class InvalidAPIVersionString(Invalid): + message = _("API Version String %(version)s is of invalid format. Must " + "be of format MajorNum.MinorNum.") + + +class VersionNotFoundForAPIMethod(Invalid): + message = _("API version %(version)s is not supported on this method.") + + +class InvalidGlobalAPIVersion(Invalid): + message = _("Version %(req_ver)s is not supported by the API. Minimum " + "is %(min_ver)s and maximum is %(max_ver)s.") + + class APIException(CinderException): message = _("Error while requesting %(service)s API.") diff --git a/cinder/opts.py b/cinder/opts.py index 0e210d695..5d96618b0 100644 --- a/cinder/opts.py +++ b/cinder/opts.py @@ -17,7 +17,6 @@ import itertools from cinder.api import common as cinder_api_common from cinder.api.middleware import auth as cinder_api_middleware_auth from cinder.api.middleware import sizelimit as cinder_api_middleware_sizelimit -from cinder.api.v2 import volumes as cinder_api_v2_volumes from cinder.api.views import versions as cinder_api_views_versions from cinder.backup import chunkeddriver as cinder_backup_chunkeddriver from cinder.backup import driver as cinder_backup_driver @@ -333,7 +332,6 @@ def list_opts(): cinder_volume_drivers_hpe_hpe3parcommon.hpe3par_opts, cinder_volume_drivers_datera.d_opts, cinder_volume_drivers_blockdevice.volume_opts, - [cinder_api_v2_volumes.query_volume_filters_opt], cinder_volume_drivers_quobyte.volume_opts, cinder_volume_drivers_vzstorage.vzstorage_opts, cinder_volume_drivers_nfs.nfs_opts, diff --git a/cinder/tests/unit/api/fakes.py b/cinder/tests/unit/api/fakes.py index 6ed111443..260447410 100644 --- a/cinder/tests/unit/api/fakes.py +++ b/cinder/tests/unit/api/fakes.py @@ -24,6 +24,7 @@ import webob.request from cinder.api.middleware import auth from cinder.api.middleware import fault +from cinder.api.openstack import api_version_request as api_version from cinder.api.openstack import wsgi as os_wsgi from cinder.api import urlmap from cinder.api.v2 import limits @@ -78,7 +79,7 @@ def wsgi_app(inner_app_v2=None, fake_auth=True, fake_auth_context=None, mapper = urlmap.URLMap() mapper['/v2'] = api_v2 - mapper['/'] = fault.FaultWrapper(versions.Versions()) + mapper['/'] = fault.FaultWrapper(versions.VersionsController()) return mapper @@ -106,17 +107,21 @@ class HTTPRequest(webob.Request): @classmethod def blank(cls, *args, **kwargs): if args is not None: - if args[0].find('v1') == 0: + if 'v1' in args[0]: kwargs['base_url'] = 'http://localhost/v1' - else: + if 'v2' in args[0]: kwargs['base_url'] = 'http://localhost/v2' + if 'v3' in args[0]: + kwargs['base_url'] = 'http://localhost/v3' use_admin_context = kwargs.pop('use_admin_context', False) + version = kwargs.pop('version', api_version._MIN_API_VERSION) out = os_wsgi.Request.blank(*args, **kwargs) out.environ['cinder.context'] = FakeRequestContext( 'fake_user', 'fakeproject', is_admin=use_admin_context) + out.api_version_request = api_version.APIVersionRequest(version) return out diff --git a/cinder/tests/unit/api/openstack/test_api_version_request.py b/cinder/tests/unit/api/openstack/test_api_version_request.py new file mode 100644 index 000000000..1cf3e2ec1 --- /dev/null +++ b/cinder/tests/unit/api/openstack/test_api_version_request.py @@ -0,0 +1,149 @@ +# Copyright 2014 IBM Corp. +# Copyright 2015 Clinton Knight +# 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 ddt +import six + +from cinder.api.openstack import api_version_request +from cinder import exception +from cinder import test + + +@ddt.ddt +class APIVersionRequestTests(test.TestCase): + + def test_init(self): + + result = api_version_request.APIVersionRequest() + + self.assertIsNone(result._ver_major) + self.assertIsNone(result._ver_minor) + + def test_min_version(self): + + self.assertEqual( + api_version_request.APIVersionRequest( + api_version_request._MIN_API_VERSION), + api_version_request.min_api_version()) + + def test_max_api_version(self): + + self.assertEqual( + api_version_request.APIVersionRequest( + api_version_request._MAX_API_VERSION), + api_version_request.max_api_version()) + + @ddt.data( + ('1.1', 1, 1), + ('2.10', 2, 10), + ('5.234', 5, 234), + ('12.5', 12, 5), + ('2.0', 2, 0), + ('2.200', 2, 200) + ) + @ddt.unpack + def test_valid_version_strings(self, version_string, major, minor): + + request = api_version_request.APIVersionRequest(version_string) + + self.assertEqual(major, request._ver_major) + self.assertEqual(minor, request._ver_minor) + + def test_null_version(self): + + v = api_version_request.APIVersionRequest() + + self.assertTrue(v.is_null()) + + @ddt.data('2', '200', '2.1.4', '200.23.66.3', '5 .3', '5. 3', + '5.03', '02.1', '2.001', '', ' 2.1', '2.1 ') + def test_invalid_version_strings(self, version_string): + + self.assertRaises(exception.InvalidAPIVersionString, + api_version_request.APIVersionRequest, + version_string) + + def test_cmpkey(self): + request = api_version_request.APIVersionRequest('1.2') + self.assertEqual((1, 2), request._cmpkey()) + + def test_version_comparisons(self): + v1 = api_version_request.APIVersionRequest('2.0') + v2 = api_version_request.APIVersionRequest('2.5') + v3 = api_version_request.APIVersionRequest('5.23') + v4 = api_version_request.APIVersionRequest('2.0') + v_null = api_version_request.APIVersionRequest() + + self.assertTrue(v1 < v2) + self.assertTrue(v1 <= v2) + self.assertTrue(v3 > v2) + self.assertTrue(v3 >= v2) + self.assertTrue(v1 != v2) + self.assertTrue(v1 == v4) + self.assertTrue(v1 != v_null) + self.assertTrue(v_null == v_null) + self.assertFalse(v1 == '2.0') + + def test_version_matches(self): + v1 = api_version_request.APIVersionRequest('2.0') + v2 = api_version_request.APIVersionRequest('2.5') + v3 = api_version_request.APIVersionRequest('2.45') + v4 = api_version_request.APIVersionRequest('3.3') + v5 = api_version_request.APIVersionRequest('3.23') + v6 = api_version_request.APIVersionRequest('2.0') + v7 = api_version_request.APIVersionRequest('3.3') + v8 = api_version_request.APIVersionRequest('4.0') + v_null = api_version_request.APIVersionRequest() + + self.assertTrue(v2.matches(v1, v3)) + self.assertTrue(v2.matches(v1, v_null)) + self.assertTrue(v1.matches(v6, v2)) + self.assertTrue(v4.matches(v2, v7)) + self.assertTrue(v4.matches(v_null, v7)) + self.assertTrue(v4.matches(v_null, v8)) + self.assertFalse(v1.matches(v2, v3)) + self.assertFalse(v5.matches(v2, v4)) + self.assertFalse(v2.matches(v3, v1)) + self.assertTrue(v1.matches(v_null, v_null)) + + self.assertRaises(ValueError, v_null.matches, v1, v3) + + def test_matches_versioned_method(self): + + request = api_version_request.APIVersionRequest('2.0') + + self.assertRaises(exception.InvalidParameterValue, + request.matches_versioned_method, + 'fake_method') + + def test_get_string(self): + v1_string = '3.23' + v1 = api_version_request.APIVersionRequest(v1_string) + self.assertEqual(v1_string, v1.get_string()) + + self.assertRaises(ValueError, + api_version_request.APIVersionRequest().get_string) + + @ddt.data(('1', '0'), ('1', '1')) + @ddt.unpack + def test_str(self, major, minor): + request_input = '%s.%s' % (major, minor) + request = api_version_request.APIVersionRequest(request_input) + request_string = six.text_type(request) + + self.assertEqual('API Version Request ' + 'Major: %s, Minor: %s' % (major, minor), + request_string) diff --git a/cinder/tests/unit/api/openstack/test_versioned_method.py b/cinder/tests/unit/api/openstack/test_versioned_method.py new file mode 100644 index 000000000..c5dbb510a --- /dev/null +++ b/cinder/tests/unit/api/openstack/test_versioned_method.py @@ -0,0 +1,36 @@ +# Copyright 2015 Clinton Knight +# 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.api.openstack import versioned_method +from cinder import test + + +class VersionedMethodTestCase(test.TestCase): + + def test_str(self): + args = ('fake_name', 'fake_min', 'fake_max') + method = versioned_method.VersionedMethod(*(args + (False, None))) + method_string = six.text_type(method) + + self.assertEqual('Version Method %s: min: %s, max: %s' % args, + method_string) + + def test_cmpkey(self): + method = versioned_method.VersionedMethod( + 'fake_name', 'fake_start_version', 'fake_end_version', False, + 'fake_func') + self.assertEqual('fake_start_version', method._cmpkey()) diff --git a/cinder/tests/unit/api/openstack/test_wsgi.py b/cinder/tests/unit/api/openstack/test_wsgi.py index e156007ee..586c4b3ae 100644 --- a/cinder/tests/unit/api/openstack/test_wsgi.py +++ b/cinder/tests/unit/api/openstack/test_wsgi.py @@ -797,6 +797,28 @@ class ResourceTest(test.TestCase): self.assertEqual([2], called) self.assertEqual('foo', response) + def test_post_process_extensions_version_not_found(self): + class Controller(object): + def index(self, req, pants=None): + return pants + + controller = Controller() + resource = wsgi.Resource(controller) + + called = [] + + def extension1(req, resp_obj): + called.append(1) + return 'bar' + + def extension2(req, resp_obj): + raise exception.VersionNotFoundForAPIMethod(version='fake_version') + + response = resource.post_process_extensions([extension2, extension1], + None, None, {}) + self.assertEqual([1], called) + self.assertEqual('bar', response) + def test_post_process_extensions_generator(self): class Controller(object): def index(self, req, pants=None): diff --git a/cinder/tests/unit/api/test_router.py b/cinder/tests/unit/api/test_router.py deleted file mode 100644 index 865f8c2b8..000000000 --- a/cinder/tests/unit/api/test_router.py +++ /dev/null @@ -1,258 +0,0 @@ -# Copyright 2011 Denali Systems, Inc. -# 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.api.openstack import wsgi -from cinder.api.v1 import router -from cinder.api.v1 import snapshots -from cinder.api.v1 import volumes -from cinder.api import versions -from cinder import test -from cinder.tests.unit.api import fakes - - -class FakeController(object): - def __init__(self, ext_mgr=None): - self.ext_mgr = ext_mgr - - def index(self, req): - obj_type = req.path.split("/")[3] - return {obj_type: []} - - def detail(self, req): - obj_type = req.path.split("/")[3] - return {obj_type: []} - - -def create_resource(ext_mgr): - return wsgi.Resource(FakeController(ext_mgr)) - - -class VolumeRouterTestCase(test.TestCase): - def setUp(self): - super(VolumeRouterTestCase, self).setUp() - # NOTE(vish): versions is just returning text so, no need to stub. - self.stubs.Set(snapshots, 'create_resource', create_resource) - self.stubs.Set(volumes, 'create_resource', create_resource) - self.app = router.APIRouter() - - def test_versions(self): - req = fakes.HTTPRequest.blank('') - req.method = 'GET' - req.content_type = 'application/json' - response = req.get_response(self.app) - self.assertEqual(302, response.status_int) - req = fakes.HTTPRequest.blank('/') - req.method = 'GET' - req.content_type = 'application/json' - response = req.get_response(self.app) - self.assertEqual(200, response.status_int) - - def test_versions_action_args_index(self): - request_environment = {'PATH_INFO': '/'} - resource = versions.Versions() - result = resource.get_action_args(request_environment) - self.assertEqual('index', result['action']) - - def test_versions_action_args_multi(self): - request_environment = {'PATH_INFO': '/fake/path'} - resource = versions.Versions() - result = resource.get_action_args(request_environment) - self.assertEqual('multi', result['action']) - - def test_versions_get_most_recent_update(self): - res = versions.AtomSerializer() - fake_date_updated = [ - {"updated": '2012-01-04T11:33:21Z'}, - {"updated": '2012-11-21T11:33:21Z'} - ] - result = res._get_most_recent_update(fake_date_updated) - self.assertEqual('2012-11-21T11:33:21Z', result) - - def test_versions_create_version_entry(self): - res = versions.AtomSerializer() - vers = { - "id": "v2.0", - "status": "CURRENT", - "updated": "2015-05-07T11:33:21Z", - "links": [ - { - "rel": "describedby", - "type": "application/pdf", - "href": "http://developer.openstack.org/api-ref-guides/" - "bk-api-ref-blockstorage-v2.pdf", - }, - ], - } - fake_result = { - 'id': 'http://developer.openstack.org/api-ref-guides/' - 'bk-api-ref-blockstorage-v2.pdf', - 'title': 'Version v2.0', - 'updated': '2015-05-07T11:33:21Z', - 'link': { - 'href': 'http://developer.openstack.org/api-ref-guides/' - 'bk-api-ref-blockstorage-v2.pdf', - 'type': 'application/pdf', - 'rel': 'describedby' - }, - 'content': 'Version v2.0 CURRENT (2015-05-07T11:33:21Z)' - } - result_function = res._create_version_entry(vers) - result = {} - for subElement in result_function: - if subElement.text: - result[subElement.tag] = subElement.text - else: - result[subElement.tag] = subElement.attrib - self.assertEqual(fake_result, result) - - def test_versions_create_feed(self): - res = versions.AtomSerializer() - vers = [ - { - "id": "v2.0", - "status": "CURRENT", - "updated": "2015-05-07T11:33:21Z", - "links": [ - { - "rel": "describedby", - "type": "application/pdf", - "href": "http://developer.openstack.org/" - "api-ref-guides/" - "bk-api-ref-blockstorage-v2.pdf", - }, - ], - }, - { - "id": "v1.0", - "status": "CURRENT", - "updated": "2012-01-04T11:33:21Z", - "links": [ - { - "rel": "describedby", - "type": "application/vnd.sun.wadl+xml", - "href": "http://docs.rackspacecloud.com/" - "servers/api/v1.1/application.wadl", - }, - ], - } - ] - result = res._create_feed(vers, "fake_feed_title", - "http://developer.openstack.org/" - "api-ref-guides/" - "bk-api-ref-blockstorage-v1.pdf") - fake_data = { - 'id': 'http://developer.openstack.org/api-ref-guides/' - 'bk-api-ref-blockstorage-v1.pdf', - 'title': 'fake_feed_title', - 'updated': '2015-05-07T11:33:21Z', - } - data = {} - for subElement in result: - if subElement.text: - data[subElement.tag] = subElement.text - self.assertEqual(fake_data, data) - - def test_versions_multi(self): - req = fakes.HTTPRequest.blank('/') - req.method = 'GET' - req.content_type = 'application/json' - resource = versions.Versions() - result = resource.dispatch(resource.multi, req, {}) - ids = [v['id'] for v in result['choices']] - self.assertEqual(set(['v1.0', 'v2.0']), set(ids)) - - def test_versions_multi_disable_v1(self): - self.flags(enable_v1_api=False) - req = fakes.HTTPRequest.blank('/') - req.method = 'GET' - req.content_type = 'application/json' - resource = versions.Versions() - result = resource.dispatch(resource.multi, req, {}) - ids = [v['id'] for v in result['choices']] - self.assertEqual(set(['v2.0']), set(ids)) - - def test_versions_multi_disable_v2(self): - self.flags(enable_v2_api=False) - req = fakes.HTTPRequest.blank('/') - req.method = 'GET' - req.content_type = 'application/json' - resource = versions.Versions() - result = resource.dispatch(resource.multi, req, {}) - ids = [v['id'] for v in result['choices']] - self.assertEqual(set(['v1.0']), set(ids)) - - def test_versions_index(self): - req = fakes.HTTPRequest.blank('/') - req.method = 'GET' - req.content_type = 'application/json' - resource = versions.Versions() - result = resource.dispatch(resource.index, req, {}) - ids = [v['id'] for v in result['versions']] - self.assertEqual(set(['v1.0', 'v2.0']), set(ids)) - - def test_versions_index_disable_v1(self): - self.flags(enable_v1_api=False) - req = fakes.HTTPRequest.blank('/') - req.method = 'GET' - req.content_type = 'application/json' - resource = versions.Versions() - result = resource.dispatch(resource.index, req, {}) - ids = [v['id'] for v in result['versions']] - self.assertEqual(set(['v2.0']), set(ids)) - - def test_versions_index_disable_v2(self): - self.flags(enable_v2_api=False) - req = fakes.HTTPRequest.blank('/') - req.method = 'GET' - req.content_type = 'application/json' - resource = versions.Versions() - result = resource.dispatch(resource.index, req, {}) - ids = [v['id'] for v in result['versions']] - self.assertEqual(set(['v1.0']), set(ids)) - - def test_volumes(self): - req = fakes.HTTPRequest.blank('/fakeproject/volumes') - req.method = 'GET' - req.content_type = 'application/json' - response = req.get_response(self.app) - self.assertEqual(200, response.status_int) - - def test_volumes_detail(self): - req = fakes.HTTPRequest.blank('/fakeproject/volumes/detail') - req.method = 'GET' - req.content_type = 'application/json' - response = req.get_response(self.app) - self.assertEqual(200, response.status_int) - - def test_types(self): - req = fakes.HTTPRequest.blank('/fakeproject/types') - req.method = 'GET' - req.content_type = 'application/json' - response = req.get_response(self.app) - self.assertEqual(200, response.status_int) - - def test_snapshots(self): - req = fakes.HTTPRequest.blank('/fakeproject/snapshots') - req.method = 'GET' - req.content_type = 'application/json' - response = req.get_response(self.app) - self.assertEqual(200, response.status_int) - - def test_snapshots_detail(self): - req = fakes.HTTPRequest.blank('/fakeproject/snapshots/detail') - req.method = 'GET' - req.content_type = 'application/json' - response = req.get_response(self.app) - self.assertEqual(200, response.status_int) diff --git a/cinder/tests/unit/api/test_versions.py b/cinder/tests/unit/api/test_versions.py index eb00321c2..df008fb07 100644 --- a/cinder/tests/unit/api/test_versions.py +++ b/cinder/tests/unit/api/test_versions.py @@ -1,4 +1,4 @@ -# Copyright (c) 2015 - 2016 Huawei Technologies Co., Ltd. +# Copyright 2015 Clinton Knight # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -13,131 +13,186 @@ # License for the specific language governing permissions and limitations # under the License. -import webob +import ddt +import mock +from oslo_serialization import jsonutils +from cinder.api.openstack import api_version_request +from cinder.api.openstack import wsgi +from cinder.api.v1 import router from cinder.api import versions from cinder import test +from cinder.tests.unit.api import fakes -class VersionsTest(test.TestCase): +version_header_name = 'OpenStack-Volume-microversion' - """Test the version information returned from the API service.""" - def test_get_version_list_public_endpoint(self): - req = webob.Request.blank('/', base_url='http://127.0.0.1:8776/') - req.accept = 'application/json' - self.override_config('public_endpoint', 'https://example.com:8776') - res = versions.Versions().index(req) - results = res['versions'] - expected = [ - { - 'id': 'v1.0', - 'status': 'SUPPORTED', - 'updated': '2014-06-28T12:20:21Z', - 'links': [{'rel': 'self', - 'href': 'https://example.com:8776/v1/'}], - }, - { - 'id': 'v2.0', - 'status': 'CURRENT', - 'updated': '2012-11-21T11:33:21Z', - 'links': [{'rel': 'self', - 'href': 'https://example.com:8776/v2/'}], - }, - ] - self.assertEqual(expected, results) +@ddt.ddt +class VersionsControllerTestCase(test.TestCase): - def test_get_version_list(self): - req = webob.Request.blank('/', base_url='http://127.0.0.1:8776/') - req.accept = 'application/json' - res = versions.Versions().index(req) - results = res['versions'] - expected = [ - { - 'id': 'v1.0', - 'status': 'SUPPORTED', - 'updated': '2014-06-28T12:20:21Z', - 'links': [{'rel': 'self', - 'href': 'http://127.0.0.1:8776/v1/'}], - }, - { - 'id': 'v2.0', - 'status': 'CURRENT', - 'updated': '2012-11-21T11:33:21Z', - 'links': [{'rel': 'self', - 'href': 'http://127.0.0.1:8776/v2/'}], - }, - ] - self.assertEqual(expected, results) + def setUp(self): + super(VersionsControllerTestCase, self).setUp() + self.wsgi_apps = (versions.Versions(), router.APIRouter()) - def test_get_version_detail_v1(self): - req = webob.Request.blank('/', base_url='http://127.0.0.1:8776/v1') - req.accept = 'application/json' - res = versions.VolumeVersion().show(req) - expected = { - "version": { - "status": "SUPPORTED", - "updated": "2014-06-28T12:20:21Z", - "media-types": [ - { - "base": "application/xml", - "type": - "application/vnd.openstack.volume+xml;version=1" - }, - { - "base": "application/json", - "type": - "application/vnd.openstack.volume+json;version=1" - } - ], - "id": "v1.0", - "links": [ - { - "href": "http://127.0.0.1:8776/v1/", - "rel": "self" - }, - { - "href": "http://docs.openstack.org/", - "type": "text/html", - "rel": "describedby" - } - ] - } - } - self.assertEqual(expected, res) + @ddt.data('1.0', '2.0', '3.0') + def test_versions_root(self, version): + req = fakes.HTTPRequest.blank('/', base_url='http://localhost') + req.method = 'GET' + req.content_type = 'application/json' - def test_get_version_detail_v2(self): - req = webob.Request.blank('/', base_url='http://127.0.0.1:8776/v2') - req.accept = 'application/json' - res = versions.VolumeVersion().show(req) - expected = { - "version": { - "status": "CURRENT", - "updated": "2012-11-21T11:33:21Z", - "media-types": [ - { - "base": "application/xml", - "type": - "application/vnd.openstack.volume+xml;version=1" - }, - { - "base": "application/json", - "type": - "application/vnd.openstack.volume+json;version=1" - } - ], - "id": "v2.0", - "links": [ - { - "href": "http://127.0.0.1:8776/v2/", - "rel": "self" - }, - { - "href": "http://docs.openstack.org/", - "type": "text/html", - "rel": "describedby" - } - ] - } - } - self.assertEqual(expected, res) + response = req.get_response(versions.Versions()) + self.assertEqual(300, response.status_int) + body = jsonutils.loads(response.body) + version_list = body['versions'] + + ids = [v['id'] for v in version_list] + self.assertEqual({'v1.0', 'v2.0', 'v3.0'}, set(ids)) + + v1 = [v for v in version_list if v['id'] == 'v1.0'][0] + self.assertEqual('', v1.get('min_version')) + self.assertEqual('', v1.get('version')) + + v2 = [v for v in version_list if v['id'] == 'v2.0'][0] + self.assertEqual('', v2.get('min_version')) + self.assertEqual('', v2.get('version')) + + v3 = [v for v in version_list if v['id'] == 'v3.0'][0] + self.assertEqual(api_version_request._MAX_API_VERSION, + v3.get('version')) + self.assertEqual(api_version_request._MIN_API_VERSION, + v3.get('min_version')) + + def test_versions_v1_no_header(self): + req = fakes.HTTPRequest.blank('/', base_url='http://localhost/v1') + req.method = 'GET' + req.content_type = 'application/json' + + response = req.get_response(router.APIRouter()) + self.assertEqual(200, response.status_int) + + def test_versions_v2_no_header(self): + req = fakes.HTTPRequest.blank('/', base_url='http://localhost/v2') + req.method = 'GET' + req.content_type = 'application/json' + + response = req.get_response(router.APIRouter()) + self.assertEqual(200, response.status_int) + + @ddt.data('1.0') + def test_versions_v1(self, version): + req = fakes.HTTPRequest.blank('/', base_url='http://localhost/v1') + req.method = 'GET' + req.content_type = 'application/json' + if version is not None: + req.headers = {version_header_name: version} + + response = req.get_response(router.APIRouter()) + self.assertEqual(200, response.status_int) + body = jsonutils.loads(response.body) + version_list = body['versions'] + + ids = [v['id'] for v in version_list] + self.assertEqual({'v1.0'}, set(ids)) + self.assertEqual('1.0', response.headers[version_header_name]) + self.assertEqual(version, response.headers[version_header_name]) + self.assertEqual(version_header_name, response.headers['Vary']) + + self.assertEqual('', version_list[0].get('min_version')) + self.assertEqual('', version_list[0].get('version')) + + @ddt.data('2.0') + def test_versions_v2(self, version): + req = fakes.HTTPRequest.blank('/', base_url='http://localhost/v2') + req.method = 'GET' + req.content_type = 'application/json' + req.headers = {version_header_name: version} + + response = req.get_response(router.APIRouter()) + self.assertEqual(200, response.status_int) + body = jsonutils.loads(response.body) + version_list = body['versions'] + + ids = [v['id'] for v in version_list] + self.assertEqual({'v2.0'}, set(ids)) + self.assertEqual('2.0', response.headers[version_header_name]) + self.assertEqual(version, response.headers[version_header_name]) + self.assertEqual(version_header_name, response.headers['Vary']) + + self.assertEqual('', version_list[0].get('min_version')) + self.assertEqual('', version_list[0].get('version')) + + @ddt.data('3.0', 'latest') + def test_versions_v3_0_and_latest(self, version): + req = fakes.HTTPRequest.blank('/', base_url='http://localhost/v3') + req.method = 'GET' + req.content_type = 'application/json' + req.headers = {version_header_name: version} + + response = req.get_response(router.APIRouter()) + self.assertEqual(200, response.status_int) + body = jsonutils.loads(response.body) + version_list = body['versions'] + + ids = [v['id'] for v in version_list] + self.assertEqual({'v3.0'}, set(ids)) + self.assertEqual('3.0', response.headers[version_header_name]) + self.assertEqual(version_header_name, response.headers['Vary']) + + self.assertEqual(api_version_request._MAX_API_VERSION, + version_list[0].get('version')) + self.assertEqual(api_version_request._MIN_API_VERSION, + version_list[0].get('min_version')) + + def test_versions_version_latest(self): + req = fakes.HTTPRequest.blank('/', base_url='http://localhost/v3') + req.method = 'GET' + req.content_type = 'application/json' + req.headers = {version_header_name: 'latest'} + + response = req.get_response(router.APIRouter()) + + self.assertEqual(200, response.status_int) + + def test_versions_version_invalid(self): + req = fakes.HTTPRequest.blank('/', base_url='http://localhost/v3') + req.method = 'GET' + req.content_type = 'application/json' + req.headers = {version_header_name: '2.0.1'} + + for app in self.wsgi_apps: + response = req.get_response(app) + + self.assertEqual(400, response.status_int) + + def test_versions_version_not_found(self): + api_version_request_4_0 = api_version_request.APIVersionRequest('4.0') + self.mock_object(api_version_request, + 'max_api_version', + mock.Mock(return_value=api_version_request_4_0)) + + class Controller(wsgi.Controller): + + @wsgi.Controller.api_version('3.0', '3.0') + def index(self, req): + return 'off' + + req = fakes.HTTPRequest.blank('/tests', base_url='http://localhost/v3') + req.headers = {version_header_name: '3.5'} + app = fakes.TestRouter(Controller()) + + response = req.get_response(app) + + self.assertEqual(404, response.status_int) + + def test_versions_version_not_acceptable(self): + req = fakes.HTTPRequest.blank('/', base_url='http://localhost/v3') + req.method = 'GET' + req.content_type = 'application/json' + req.headers = {version_header_name: '4.0'} + + response = req.get_response(router.APIRouter()) + + self.assertEqual(406, response.status_int) + self.assertEqual('4.0', response.headers[version_header_name]) + self.assertEqual(version_header_name, response.headers['Vary']) diff --git a/cinder/tests/unit/api/v2/test_snapshots.py b/cinder/tests/unit/api/v2/test_snapshots.py index e29d03752..8cffcf775 100644 --- a/cinder/tests/unit/api/v2/test_snapshots.py +++ b/cinder/tests/unit/api/v2/test_snapshots.py @@ -406,8 +406,8 @@ class SnapshotApiTest(test.TestCase): """Check a page of snapshots list.""" # Since we are accessing v2 api directly we don't need to specify # v2 in the request path, if we did, we'd get /v2/v2 links back - request_path = '/%s/snapshots' % project - expected_path = '/v2' + request_path + request_path = '/v2/%s/snapshots' % project + expected_path = request_path # Construct the query if there are kwargs if kwargs: diff --git a/cinder/tests/unit/api/views/test_versions.py b/cinder/tests/unit/api/views/test_versions.py new file mode 100644 index 000000000..8f430a84c --- /dev/null +++ b/cinder/tests/unit/api/views/test_versions.py @@ -0,0 +1,159 @@ +# Copyright 2015 Clinton Knight +# 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 copy + +import ddt +import mock + +from cinder.api.views import versions +from cinder import test + + +class FakeRequest(object): + def __init__(self, application_url): + self.application_url = application_url + + +URL_BASE = 'http://localhost/' +FAKE_HREF = URL_BASE + 'v1/' + +FAKE_VERSIONS = { + "v1.0": { + "id": "v1.0", + "status": "CURRENT", + "version": "1.1", + "min_version": "1.0", + "updated": "2015-07-30T11:33:21Z", + "links": [ + { + "rel": "describedby", + "type": "text/html", + "href": 'http://docs.openstack.org/', + }, + ], + "media-types": [ + { + "base": "application/json", + "type": "application/vnd.openstack.share+json;version=1", + }, + { + "base": "application/xml", + "type": "application/vnd.openstack.share+xml;version=1", + } + ], + }, +} + +FAKE_LINKS = [ + { + "rel": "describedby", + "type": "text/html", + "href": 'http://docs.openstack.org/', + }, + { + 'rel': 'self', + 'href': FAKE_HREF + }, +] + + +@ddt.ddt +class ViewBuilderTestCase(test.TestCase): + + def _get_builder(self): + request = FakeRequest('fake') + return versions.get_view_builder(request) + + def test_build_versions(self): + + self.mock_object(versions.ViewBuilder, + '_build_links', + mock.Mock(return_value=FAKE_LINKS)) + + result = self._get_builder().build_versions(FAKE_VERSIONS) + + expected = {'versions': list(FAKE_VERSIONS.values())} + expected['versions'][0]['links'] = FAKE_LINKS + + self.assertEqual(expected, result) + + def test_build_version(self): + + self.mock_object(versions.ViewBuilder, + '_build_links', + mock.Mock(return_value=FAKE_LINKS)) + + result = self._get_builder()._build_version(FAKE_VERSIONS['v1.0']) + + expected = copy.deepcopy(FAKE_VERSIONS['v1.0']) + expected['links'] = FAKE_LINKS + + self.assertEqual(expected, result) + + def test_build_links(self): + + self.mock_object(versions.ViewBuilder, + '_generate_href', + mock.Mock(return_value=FAKE_HREF)) + + result = self._get_builder()._build_links(FAKE_VERSIONS['v1.0']) + + self.assertEqual(FAKE_LINKS, result) + + def test_generate_href_defaults(self): + + self.mock_object(versions.ViewBuilder, + '_get_base_url_without_version', + mock.Mock(return_value=URL_BASE)) + + result = self._get_builder()._generate_href() + + self.assertEqual('http://localhost/v1/', result) + + @ddt.data( + ('v2', None, URL_BASE + 'v2/'), + ('/v2/', None, URL_BASE + 'v2/'), + ('/v2/', 'fake_path', URL_BASE + 'v2/fake_path'), + ('/v2/', '/fake_path/', URL_BASE + 'v2/fake_path/'), + ) + @ddt.unpack + def test_generate_href_no_path(self, version, path, expected): + + self.mock_object(versions.ViewBuilder, + '_get_base_url_without_version', + mock.Mock(return_value=URL_BASE)) + + result = self._get_builder()._generate_href(version=version, + path=path) + + self.assertEqual(expected, result) + + @ddt.data( + ('http://1.1.1.1/', 'http://1.1.1.1/'), + ('http://localhost/', 'http://localhost/'), + ('http://1.1.1.1/v1/', 'http://1.1.1.1/'), + ('http://1.1.1.1/v1', 'http://1.1.1.1/'), + ('http://1.1.1.1/v11', 'http://1.1.1.1/'), + ) + @ddt.unpack + def test_get_base_url_without_version(self, base_url, base_url_no_version): + + request = FakeRequest(base_url) + builder = versions.get_view_builder(request) + + result = builder._get_base_url_without_version() + + self.assertEqual(base_url_no_version, result) diff --git a/cinder/tests/unit/test_utils.py b/cinder/tests/unit/test_utils.py index 021ba3d9d..a6ae17cb8 100644 --- a/cinder/tests/unit/test_utils.py +++ b/cinder/tests/unit/test_utils.py @@ -1361,3 +1361,53 @@ class LogTracingTestCase(test.TestCase): host_stat['reserved_percentage']) self.assertEqual(37.02, free) + + +class Comparable(utils.ComparableMixin): + def __init__(self, value): + self.value = value + + def _cmpkey(self): + return self.value + + +class TestComparableMixin(test.TestCase): + + def setUp(self): + super(TestComparableMixin, self).setUp() + self.one = Comparable(1) + self.two = Comparable(2) + + def test_lt(self): + self.assertTrue(self.one < self.two) + self.assertFalse(self.two < self.one) + self.assertFalse(self.one < self.one) + + def test_le(self): + self.assertTrue(self.one <= self.two) + self.assertFalse(self.two <= self.one) + self.assertTrue(self.one <= self.one) + + def test_eq(self): + self.assertFalse(self.one == self.two) + self.assertFalse(self.two == self.one) + self.assertTrue(self.one == self.one) + + def test_ge(self): + self.assertFalse(self.one >= self.two) + self.assertTrue(self.two >= self.one) + self.assertTrue(self.one >= self.one) + + def test_gt(self): + self.assertFalse(self.one > self.two) + self.assertTrue(self.two > self.one) + self.assertFalse(self.one > self.one) + + def test_ne(self): + self.assertTrue(self.one != self.two) + self.assertTrue(self.two != self.one) + self.assertFalse(self.one != self.one) + + def test_compare(self): + self.assertEqual(NotImplemented, + self.one._compare(1, self.one._cmpkey)) diff --git a/cinder/utils.py b/cinder/utils.py index 27a991f2c..c54bc9eaf 100644 --- a/cinder/utils.py +++ b/cinder/utils.py @@ -772,6 +772,34 @@ def is_blk_device(dev): return False +class ComparableMixin(object): + def _compare(self, other, method): + try: + return method(self._cmpkey(), other._cmpkey()) + except (AttributeError, TypeError): + # _cmpkey not implemented, or return different type, + # so I can't compare with "other". + return NotImplemented + + def __lt__(self, other): + return self._compare(other, lambda s, o: s < o) + + def __le__(self, other): + return self._compare(other, lambda s, o: s <= o) + + def __eq__(self, other): + return self._compare(other, lambda s, o: s == o) + + def __ge__(self, other): + return self._compare(other, lambda s, o: s >= o) + + def __gt__(self, other): + return self._compare(other, lambda s, o: s > o) + + def __ne__(self, other): + return self._compare(other, lambda s, o: s != o) + + def retry(exceptions, interval=1, retries=3, backoff_rate=2, wait_random=False): diff --git a/doc/source/devref/api_microversion_dev.rst b/doc/source/devref/api_microversion_dev.rst new file mode 100644 index 000000000..31ba63f25 --- /dev/null +++ b/doc/source/devref/api_microversion_dev.rst @@ -0,0 +1,308 @@ +API Microversions +================= + +Background +---------- + +Cinder uses a framework we called 'API Microversions' for allowing changes +to the API while preserving backward compatibility. The basic idea is +that a user has to explicitly ask for their request to be treated with +a particular version of the API. So breaking changes can be added to +the API without breaking users who don't specifically ask for it. This +is done with an HTTP header ``OpenStack-Volume-microversion`` which +is a monotonically increasing semantic version number starting from +``3.0``. + +If a user makes a request without specifying a version, they will get +the ``DEFAULT_API_VERSION`` as defined in +``cinder/api/openstack/api_version_request.py``. This value is currently ``3.0`` +and is expected to remain so for quite a long time. + +The Nova project was the first to implement microversions. For full +details please read Nova's `Kilo spec for microversions +`_ + +When do I need a new Microversion? +---------------------------------- + +A microversion is needed when the contract to the user is +changed. The user contract covers many kinds of information such as: + +- the Request + + - the list of resource urls which exist on the server + + Example: adding a new shares/{ID}/foo which didn't exist in a + previous version of the code + + - the list of query parameters that are valid on urls + + Example: adding a new parameter ``is_yellow`` servers/{ID}?is_yellow=True + + - the list of query parameter values for non free form fields + + Example: parameter filter_by takes a small set of constants/enums "A", + "B", "C". Adding support for new enum "D". + + - new headers accepted on a request + +- the Response + + - the list of attributes and data structures returned + + Example: adding a new attribute 'locked': True/False to the output + of shares/{ID} + + - the allowed values of non free form fields + + Example: adding a new allowed ``status`` to shares/{ID} + + - the list of status codes allowed for a particular request + + Example: an API previously could return 200, 400, 403, 404 and the + change would make the API now also be allowed to return 409. + + - changing a status code on a particular response + + Example: changing the return code of an API from 501 to 400. + + - new headers returned on a response + +The following flow chart attempts to walk through the process of "do +we need a microversion". + + +.. graphviz:: + + digraph states { + + label="Do I need a microversion?" + + silent_fail[shape="diamond", style="", label="Did we silently + fail to do what is asked?"]; + ret_500[shape="diamond", style="", label="Did we return a 500 + before?"]; + new_error[shape="diamond", style="", label="Are we changing what + status code is returned?"]; + new_attr[shape="diamond", style="", label="Did we add or remove an + attribute to a payload?"]; + new_param[shape="diamond", style="", label="Did we add or remove + an accepted query string parameter or value?"]; + new_resource[shape="diamond", style="", label="Did we add or remove a + resource url?"]; + + + no[shape="box", style=rounded, label="No microversion needed"]; + yes[shape="box", style=rounded, label="Yes, you need a microversion"]; + no2[shape="box", style=rounded, label="No microversion needed, it's + a bug"]; + + silent_fail -> ret_500[label="no"]; + silent_fail -> no2[label="yes"]; + + ret_500 -> no2[label="yes [1]"]; + ret_500 -> new_error[label="no"]; + + new_error -> new_attr[label="no"]; + new_error -> yes[label="yes"]; + + new_attr -> new_param[label="no"]; + new_attr -> yes[label="yes"]; + + new_param -> new_resource[label="no"]; + new_param -> yes[label="yes"]; + + new_resource -> no[label="no"]; + new_resource -> yes[label="yes"]; + + {rank=same; yes new_attr} + {rank=same; no2 ret_500} + {rank=min; silent_fail} + } + + +**Footnotes** + +[1] - When fixing 500 errors that previously caused stack traces, try +to map the new error into the existing set of errors that API call +could previously return (400 if nothing else is appropriate). Changing +the set of allowed status codes from a request is changing the +contract, and should be part of a microversion. + +The reason why we are so strict on contract is that we'd like +application writers to be able to know, for sure, what the contract is +at every microversion in Cinder. If they do not, they will need to write +conditional code in their application to handle ambiguities. + +When in doubt, consider application authors. If it would work with no +client side changes on both Cinder versions, you probably don't need a +microversion. If, on the other hand, there is any ambiguity, a +microversion is probably needed. + + +In Code +------- + +In ``cinder/api/openstack/wsgi.py`` we define an ``@api_version`` decorator +which is intended to be used on top-level Controller methods. It is +not appropriate for lower-level methods. Some examples: + +Adding a new API method +~~~~~~~~~~~~~~~~~~~~~~~ + +In the controller class:: + + @wsgi.Controller.api_version("3.4") + def my_api_method(self, req, id): + .... + +This method would only be available if the caller had specified an +``OpenStack-Volume-microversion`` of >= ``3.4``. If they had specified a +lower version (or not specified it and received the default of ``3.1``) +the server would respond with ``HTTP/404``. + +Removing an API method +~~~~~~~~~~~~~~~~~~~~~~ + +In the controller class:: + + @wsgi.Controller.api_version("3.1", "3.4") + def my_api_method(self, req, id): + .... + +This method would only be available if the caller had specified an +``OpenStack-Volume-microversion`` of <= ``3.4``. If ``3.5`` or later +is specified the server will respond with ``HTTP/404``. + +Changing a method's behaviour +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In the controller class:: + + @wsgi.Controller.api_version("3.1", "3.3") + def my_api_method(self, req, id): + .... method_1 ... + + @wsgi.Controller.api_version("3.4") # noqa + def my_api_method(self, req, id): + .... method_2 ... + +If a caller specified ``3.1``, ``3.2`` or ``3.3`` (or received the +default of ``3.1``) they would see the result from ``method_1``, +``3.4`` or later ``method_2``. + +It is vital that the two methods have the same name, so the second of +them will need ``# noqa`` to avoid failing flake8's ``F811`` rule. The +two methods may be different in any kind of semantics (schema +validation, return values, response codes, etc) + +A method with only small changes between versions +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A method may have only small changes between microversions, in which +case you can decorate a private method:: + + @api_version("3.1", "3.4") + def _version_specific_func(self, req, arg1): + pass + + @api_version(min_version="3.5") # noqa + def _version_specific_func(self, req, arg1): + pass + + def show(self, req, id): + .... common stuff .... + self._version_specific_func(req, "foo") + .... common stuff .... + +When not using decorators +~~~~~~~~~~~~~~~~~~~~~~~~~ + +When you don't want to use the ``@api_version`` decorator on a method +or you want to change behaviour within a method (say it leads to +simpler or simply a lot less code) you can directly test for the +requested version with a method as long as you have access to the api +request object (commonly called ``req``). Every API method has an +api_version_request object attached to the req object and that can be +used to modify behaviour based on its value:: + + def index(self, req): + + + req_version = req.api_version_request + if req_version.matches("3.1", "3.5"): + ....stuff.... + elif req_version.matches("3.6", "3.10"): + ....other stuff.... + elif req_version > api_version_request.APIVersionRequest("3.10"): + ....more stuff..... + + + +The first argument to the matches method is the minimum acceptable version +and the second is maximum acceptable version. A specified version can be null:: + + null_version = APIVersionRequest() + +If the minimum version specified is null then there is no restriction on +the minimum version, and likewise if the maximum version is null there +is no restriction the maximum version. Alternatively a one sided comparison +can be used as in the example above. + +Other necessary changes +----------------------- + +If you are adding a patch which adds a new microversion, it is +necessary to add changes to other places which describe your change: + +* Update ``REST_API_VERSION_HISTORY`` in + ``cinder/api/openstack/api_version_request.py`` + +* Update ``_MAX_API_VERSION`` in + ``cinder/api/openstack/api_version_request.py`` + +* Add a verbose description to + ``cinder/api/openstack/rest_api_version_history.rst``. There should + be enough information that it could be used by the docs team for + release notes. + +* Update the expected versions in affected tests. + +Allocating a microversion +------------------------- + +If you are adding a patch which adds a new microversion, it is +necessary to allocate the next microversion number. Except under +extremely unusual circumstances and this would have been mentioned in +the blueprint for the change, the minor number of ``_MAX_API_VERSION`` +will be incremented. This will also be the new microversion number for +the API change. + +It is possible that multiple microversion patches would be proposed in +parallel and the microversions would conflict between patches. This +will cause a merge conflict. We don't reserve a microversion for each +patch in advance as we don't know the final merge order. Developers +may need over time to rebase their patch calculating a new version +number as above based on the updated value of ``_MAX_API_VERSION``. + +Testing Microversioned API Methods +---------------------------------- + +Unit tests for microversions should be put in cinder/tests/unit/api/v3/ . +Since all existing functionality is tested in cinder/tests/unit/api/v2, +these unit tests are not replicated in .../v3, and only new functionality +needs to be place in the .../v3/directory. + +Testing a microversioned API method is very similar to a normal controller +method test, you just need to add the ``OpenStack-Volume-microversion`` +header, for example:: + + req = fakes.HTTPRequest.blank('/testable/url/endpoint') + req.headers = {'OpenStack-Volume-microversion': '3.2'} + req.api_version_request = api_version.APIVersionRequest('3.6') + + controller = controller.TestableController() + + res = controller.index(req) + ... assertions about the response ... + diff --git a/doc/source/devref/api_microversion_history.rst b/doc/source/devref/api_microversion_history.rst new file mode 100644 index 000000000..12e4d8876 --- /dev/null +++ b/doc/source/devref/api_microversion_history.rst @@ -0,0 +1 @@ +.. include:: ../../../cinder/api/openstack/rest_api_version_history.rst diff --git a/doc/source/devref/index.rst b/doc/source/devref/index.rst index e638781d0..a0fe37d61 100644 --- a/doc/source/devref/index.rst +++ b/doc/source/devref/index.rst @@ -27,6 +27,8 @@ Programming HowTos and Tutorials :maxdepth: 3 development.environment + api_microversion_dev + api_microversion_history unit_tests addmethod.openstackapi drivers diff --git a/etc/cinder/api-paste.ini b/etc/cinder/api-paste.ini index 191754014..5914d81af 100644 --- a/etc/cinder/api-paste.ini +++ b/etc/cinder/api-paste.ini @@ -7,6 +7,7 @@ use = call:cinder.api:root_app_factory /: apiversions /v1: openstack_volume_api_v1 /v2: openstack_volume_api_v2 +/v3: openstack_volume_api_v3 [composite:openstack_volume_api_v1] use = call:cinder.api.middleware.auth:pipeline_factory @@ -20,14 +21,20 @@ noauth = cors request_id faultwrap sizelimit osprofiler noauth apiv2 keystone = cors request_id faultwrap sizelimit osprofiler authtoken keystonecontext apiv2 keystone_nolimit = cors request_id faultwrap sizelimit osprofiler authtoken keystonecontext apiv2 +[composite:openstack_volume_api_v3] +use = call:cinder.api.middleware.auth:pipeline_factory +noauth = cors request_id faultwrap sizelimit osprofiler noauth apiv3 +keystone = cors request_id faultwrap sizelimit osprofiler authtoken keystonecontext apiv3 +keystone_nolimit = cors request_id faultwrap sizelimit osprofiler authtoken keystonecontext apiv3 + [filter:request_id] paste.filter_factory = oslo_middleware.request_id:RequestId.factory [filter:cors] paste.filter_factory = oslo_middleware.cors:filter_factory oslo_config_project = cinder -latent_allow_headers = X-Auth-Token, X-Identity-Status, X-Roles, X-Service-Catalog, X-User-Id, X-Tenant-Id, X-OpenStack-Request-ID, X-Trace-Info, X-Trace-HMAC -latent_expose_headers = X-Auth-Token, X-Subject-Token, X-Service-Token, X-OpenStack-Request-ID +latent_allow_headers = X-Auth-Token, X-Identity-Status, X-Roles, X-Service-Catalog, X-User-Id, X-Tenant-Id, X-OpenStack-Request-ID, X-Trace-Info, X-Trace-HMAC, OpenStack-Volume-microversion +latent_expose_headers = X-Auth-Token, X-Subject-Token, X-Service-Token, X-OpenStack-Request-ID, OpenStack-Volume-microversion latent_allow_methods = GET, PUT, POST, DELETE, PATCH [filter:faultwrap] @@ -48,6 +55,9 @@ paste.app_factory = cinder.api.v1.router:APIRouter.factory [app:apiv2] paste.app_factory = cinder.api.v2.router:APIRouter.factory +[app:apiv3] +paste.app_factory = cinder.api.v3.router:APIRouter.factory + [pipeline:apiversions] pipeline = cors faultwrap osvolumeversionapp diff --git a/releasenotes/notes/cinder-api-microversions-d2082a095c322ce6.yaml b/releasenotes/notes/cinder-api-microversions-d2082a095c322ce6.yaml new file mode 100644 index 000000000..1d5c667b2 --- /dev/null +++ b/releasenotes/notes/cinder-api-microversions-d2082a095c322ce6.yaml @@ -0,0 +1,3 @@ +--- +features: + - Add support for API microversions, as well as /v3 API endpoint