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
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)
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
--- /dev/null
+# 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})
--- /dev/null
+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: <version>
+
+ where ``<version>`` 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.
--- /dev/null
+# 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
# License for the specific language governing permissions and limitations
# under the License.
+import functools
import inspect
import math
import time
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
}
+# 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.
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."""
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):
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
self.serializers = serializers
self._default_code = 200
self._code = code
- self._headers = {}
+ self._headers = headers or {}
self.serializer = None
self.media_type = None
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):
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(
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
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.
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
{"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)
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:
# 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
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):
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):
# 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
# 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)
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):
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'}}
self.resources['versions'] = versions.create_resource()
mapper.connect("versions", "/",
controller=self.resources['versions'],
- action='show')
+ action='index')
mapper.redirect("", "/")
self.resources['versions'] = versions.create_resource()
mapper.connect("versions", "/",
controller=self.resources['versions'],
- action='show')
+ action='index')
mapper.redirect("", "/")
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 =\
--- /dev/null
+# 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']})
# Copyright 2010 OpenStack Foundation
+# Copyright 2015 Clinton Knight
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# 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
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."""
+
+ ExtensionManager = extensions.ExtensionManager
+
+ def _setup_routes(self, mapper, ext_mgr):
+ self.resources['versions'] = create_resource()
+ mapper.connect('versions', '/',
+ controller=self.resources['versions'],
+ action='all')
+ mapper.redirect('', '/')
- if CONF.enable_v1_api:
- versions['v1.0'] = _KNOWN_VERSIONS['v1.0']
- if CONF.enable_v2_api:
- versions['v2.0'] = _KNOWN_VERSIONS['v2.0']
- return versions
+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
class VersionTemplate(xmlutil.TemplateBuilder):
+
def construct(self):
root = xmlutil.TemplateElement('version', selector='version')
make_version(root)
class VersionsTemplate(xmlutil.TemplateBuilder):
+
def construct(self):
root = xmlutil.TemplateElement('versions')
elem = xmlutil.SubTemplateElement(root, 'version', selector='versions')
class ChoicesTemplate(xmlutil.TemplateBuilder):
+
def construct(self):
root = xmlutil.TemplateElement('choices')
elem = xmlutil.SubTemplateElement(root, 'version', selector='choices')
class VersionsAtomSerializer(AtomSerializer):
+
def default(self, data):
versions = data['versions']
feed_id = self._get_base_url(versions[0]['links'][0]['href'])
class VersionAtomSerializer(AtomSerializer):
+
def default(self, data):
version = data['version']
feed_id = version['links'][0]['href']
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())
# Copyright 2010-2011 OpenStack Foundation
+# Copyright 2015 Clinton Knight
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# under the License.
import copy
-import os
+import re
from oslo_config import cfg
+from six.moves import urllib
versions_opts = [
"""
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)
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.'),
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.")
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
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,
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
mapper = urlmap.URLMap()
mapper['/v2'] = api_v2
- mapper['/'] = fault.FaultWrapper(versions.Versions())
+ mapper['/'] = fault.FaultWrapper(versions.VersionsController())
return mapper
@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
--- /dev/null
+# 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)
--- /dev/null
+# 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())
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):
+++ /dev/null
-# 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)
-# 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
# 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):
-
- """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)
-
- 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 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)
-
- 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)
+version_header_name = 'OpenStack-Volume-microversion'
+
+
+@ddt.ddt
+class VersionsControllerTestCase(test.TestCase):
+
+ def setUp(self):
+ super(VersionsControllerTestCase, self).setUp()
+ self.wsgi_apps = (versions.Versions(), router.APIRouter())
+
+ @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'
+
+ 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'])
"""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:
--- /dev/null
+# 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)
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))
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):
--- /dev/null
+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
+<http://git.openstack.org/cgit/openstack/nova-specs/tree/specs/kilo/implemented/api-microversions.rst>`_
+
+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):
+ <common code>
+
+ 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.....
+
+ <common code>
+
+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 ...
+
--- /dev/null
+.. include:: ../../../cinder/api/openstack/rest_api_version_history.rst
:maxdepth: 3
development.environment
+ api_microversion_dev
+ api_microversion_history
unit_tests
addmethod.openstackapi
drivers
/: 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
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]
[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
--- /dev/null
+---
+features:
+ - Add support for API microversions, as well as /v3 API endpoint