From 6b11d276d13cc866aea9b3cfa321b3b19ee13d55 Mon Sep 17 00:00:00 2001 From: scottda Date: Wed, 16 Sep 2015 12:57:35 +0000 Subject: [PATCH] cinder-api-microversions code Many changes to the Cinder REST API require changes to the consumers of the API. For example, If we need to add a required parameter to a method that is called by Nova, we'd need both the Nova calling code and the cinderclient that Nova uses to change. But newer Cinder versions with the change must work with older Nova versions, and there is no mechanism for this at the moment. Adding microversions will solve this problem. With microversions, the highest supported version will be negotiated by a field in the HTTP header that is sent to the Cinder API. In the case where the field 'versions' is not sent (i.e. clients and scripts that pre-date this change), then the lowest supported version would be used. In order to ensure that the API consumer is explicitly asking for a microversioned API, a new endpoint v3 is added, which is identical to API version v2. This means that our new Cinder API v3 would be the default, and consumers of the API that wished to use a newer version could do so by using that endpoint and a microversion in the HTTP header. New tests for microversioned API features on endpoint /v3 should be added to cinder/tests/unit/api/v3/ directory. Existing functionality will be tested via the .../v2/ unit tests. DocImpact APIImpact Implements: https://blueprints.launchpad.net/cinder/+spec/cinder-api-microversions Change-Id: I48cdbbc900c2805e59ee9aebc3b1c64aed3212ae --- cinder/api/__init__.py | 10 +- cinder/api/common.py | 10 + cinder/api/openstack/api_version_request.py | 164 ++++++++++ .../openstack/rest_api_version_history.rst | 30 ++ cinder/api/openstack/versioned_method.py | 48 +++ cinder/api/openstack/wsgi.py | 240 +++++++++++++- cinder/api/v1/router.py | 2 +- cinder/api/v2/router.py | 2 +- cinder/api/v2/volumes.py | 13 - cinder/api/v3/__init__.py | 0 cinder/api/v3/router.py | 99 ++++++ cinder/api/versions.py | 195 ++++++----- cinder/api/views/versions.py | 71 ++-- cinder/common/config.py | 5 +- cinder/exception.py | 14 + cinder/opts.py | 2 - cinder/tests/unit/api/fakes.py | 11 +- .../api/openstack/test_api_version_request.py | 149 +++++++++ .../api/openstack/test_versioned_method.py | 36 ++ cinder/tests/unit/api/openstack/test_wsgi.py | 22 ++ cinder/tests/unit/api/test_router.py | 258 --------------- cinder/tests/unit/api/test_versions.py | 293 ++++++++++------- cinder/tests/unit/api/v2/test_snapshots.py | 4 +- cinder/tests/unit/api/views/test_versions.py | 159 +++++++++ cinder/tests/unit/test_utils.py | 50 +++ cinder/utils.py | 28 ++ doc/source/devref/api_microversion_dev.rst | 308 ++++++++++++++++++ .../devref/api_microversion_history.rst | 1 + doc/source/devref/index.rst | 2 + etc/cinder/api-paste.ini | 14 +- ...er-api-microversions-d2082a095c322ce6.yaml | 3 + 31 files changed, 1687 insertions(+), 556 deletions(-) create mode 100644 cinder/api/openstack/api_version_request.py create mode 100644 cinder/api/openstack/rest_api_version_history.rst create mode 100644 cinder/api/openstack/versioned_method.py create mode 100644 cinder/api/v3/__init__.py create mode 100644 cinder/api/v3/router.py create mode 100644 cinder/tests/unit/api/openstack/test_api_version_request.py create mode 100644 cinder/tests/unit/api/openstack/test_versioned_method.py delete mode 100644 cinder/tests/unit/api/test_router.py create mode 100644 cinder/tests/unit/api/views/test_versions.py create mode 100644 doc/source/devref/api_microversion_dev.rst create mode 100644 doc/source/devref/api_microversion_history.rst create mode 100644 releasenotes/notes/cinder-api-microversions-d2082a095c322ce6.yaml diff --git a/cinder/api/__init__.py b/cinder/api/__init__.py index 53712d1fe41..083e1c5198c 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 c7fc51568fd..17e7dddb4e6 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 00000000000..175cb228451 --- /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 00000000000..2cc45d2cbee --- /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 00000000000..077e8714930 --- /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 f490471737b..fd8e9355450 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 97e2807ee31..67c8f818104 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 8ae0460f2e2..491423000c8 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 66f49452369..d7aa1c0ba7b 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 00000000000..e69de29bb2d diff --git a/cinder/api/v3/router.py b/cinder/api/v3/router.py new file mode 100644 index 00000000000..491423000c8 --- /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 8bbf1880342..9cc10bde501 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 1129f3470b7..873f5d91858 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 4965fc2904f..65390fad24c 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 88b3c3da94f..f11187ce1fe 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 0e210d695e0..5d96618b09f 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 6ed1114432a..260447410f2 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 00000000000..1cf3e2ec111 --- /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 00000000000..c5dbb510a32 --- /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 e156007ee39..586c4b3ae21 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 865f8c2b876..00000000000 --- 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 eb00321c233..df008fb07ab 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 e29d0375237..8cffcf7753f 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 00000000000..8f430a84c4c --- /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 021ba3d9db0..a6ae17cb87b 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 27a991f2c7c..c54bc9eaf4c 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 00000000000..31ba63f25da --- /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 00000000000..12e4d887619 --- /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 e638781d0f0..a0fe37d61dc 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 191754014d4..5914d81af02 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 00000000000..1d5c667b255 --- /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