Remove the need for project_id from API endpoints

Inclusion of a project_id in API URLs is now optional, and no longer
required. Removing the project_id requirement facilitates supporting
Secure RBAC's notion of system scope, in which an API method is not
associated with a specific project.

The API v3 routing is enhanced to provide duplicate routes for API
methods that traditionally required a project_id in the URL:
- The existing route for which a project_id is in the URL
- A new route for when the URL does not include a project_id

To test both routes and ensure there are no regresssions, the "API
samples" functional tests include a project_id in the URLs, and the
rest of the functional tests do not include the project_id. This is
handled by changing the 'noauth' WSGI middleware to no longer add the
project_id, and adding a new 'noauth_include_project_id' middleware
filter that implements the legacy behavior.

A new microversion V3.67 is introduced, but it only serves to inform
clients whether the project_id is optional or required. When an API
node supports mv 3.67, the project_id is optional in all API requests,
even when the request specifies a earlier microversion. See the spec
Ia44f199243be8f862520d7923007e7182b32f67d for more details on this
behavior.

Note: Much of the groundwork for this is based on manila's patch
I5127e150e8a71e621890f30dba6720b3932cf583.

DocImpact
APIImpact

Implements: blueprint project-id-optional-in-urls
Change-Id: I3729cbe1902ab4dc335451d13ed921ec236fb8fd
This commit is contained in:
Alan Bishop 2022-02-08 08:32:23 -08:00
parent e93c2a3c25
commit 31b34e91e0
22 changed files with 377 additions and 171 deletions

View File

@ -4,6 +4,25 @@
Block Storage API V3 (CURRENT)
==============================
.. note::
The URL for most API methods includes a {project_id} placeholder that
represents the caller's project ID. As of V3.67, the project_id is optional
in the URL, and the following are equivalent:
* GET /v3/{project_id}/volumes
* GET /v3/volumes
In both instances, the actual project_id used by the API method is the one
in the caller's keystone context. For that reason, including a project_id
in the URL is redundant.
The V3.67 microversion is only used as an indicator that the API accepts a
URL without a project_id, and this applies to all requests regardless of
the microversion in the request. For example, an API node serving V3.67 or
greater will accept a URL without a project_id even if the request asks for
V3.0. Likewise, it will accept a URL containing a project_id even if the
request asks for V3.67.
.. rest_expand_all::
.. First thing we want to see is the version discovery document.

View File

@ -21,8 +21,8 @@
],
"min_version": "3.0",
"status": "CURRENT",
"updated": "2021-09-16T00:00:00Z",
"version": "3.66"
"updated": "2021-12-16T00:00:00Z",
"version": "3.67"
}
]
}

View File

@ -21,8 +21,8 @@
],
"min_version": "3.0",
"status": "CURRENT",
"updated": "2021-09-16T00:00:00Z",
"version": "3.66"
"updated": "2021-12-16T00:00:00Z",
"version": "3.67"
}
]
}

View File

@ -229,6 +229,14 @@ class ViewBuilder(object):
_collection_name = None
def _get_project_id_in_url(self, request):
project_id = request.environ["cinder.context"].project_id
if project_id and ("/v3/%s" % project_id in request.url):
# project_ids are not mandatory within v3 URLs, but links need
# to include them if the request does.
return project_id
return ''
def _get_links(self, request, identifier):
return [{"rel": "self",
"href": self._get_href_link(request, identifier), },
@ -242,7 +250,7 @@ class ViewBuilder(object):
prefix = self._update_link_prefix(get_request_url(request),
CONF.public_endpoint)
url = os.path.join(prefix,
request.environ["cinder.context"].project_id,
self._get_project_id_in_url(request),
collection_name)
return "%s?%s" % (url, urllib.parse.urlencode(params))
@ -251,7 +259,7 @@ class ViewBuilder(object):
prefix = self._update_link_prefix(get_request_url(request),
CONF.public_endpoint)
return os.path.join(prefix,
request.environ["cinder.context"].project_id,
self._get_project_id_in_url(request),
self._collection_name,
str(identifier))
@ -261,7 +269,7 @@ class ViewBuilder(object):
base_url = self._update_link_prefix(base_url,
CONF.public_endpoint)
return os.path.join(base_url,
request.environ["cinder.context"].project_id,
self._get_project_id_in_url(request),
self._collection_name,
str(identifier))

View File

@ -125,15 +125,17 @@ class CinderKeystoneContext(base_wsgi.Middleware):
return self.application
class NoAuthMiddleware(base_wsgi.Middleware):
class NoAuthMiddlewareBase(base_wsgi.Middleware):
"""Return a fake token if one isn't specified."""
@webob.dec.wsgify(RequestClass=wsgi.Request)
def __call__(self, req):
def base_call(self, req, project_id_in_path=False):
if 'X-Auth-Token' not in req.headers:
user_id = req.headers.get('X-Auth-User', 'admin')
project_id = req.headers.get('X-Auth-Project-Id', 'admin')
os_url = os.path.join(req.url, project_id)
if project_id_in_path:
os_url = os.path.join(req.url.rstrip('/'), project_id)
else:
os_url = req.url.rstrip('/')
res = webob.Response()
# NOTE(vish): This is expecting and returning Auth(1.1), whereas
# keystone uses 2.0 auth. We should probably allow
@ -157,3 +159,24 @@ class NoAuthMiddleware(base_wsgi.Middleware):
req.environ['cinder.context'] = ctx
return self.application
class NoAuthMiddleware(NoAuthMiddlewareBase):
"""Return a fake token if one isn't specified.
Sets project_id in URLs.
"""
@webob.dec.wsgify(RequestClass=wsgi.Request)
def __call__(self, req):
return self.base_call(req)
class NoAuthMiddlewareIncludeProjectID(NoAuthMiddlewareBase):
"""Return a fake token if one isn't specified.
Does not set project_id in URLs.
"""
@webob.dec.wsgify(RequestClass=wsgi.Request)
def __call__(self, req):
return self.base_call(req, project_id_in_path=True)

View File

@ -18,6 +18,7 @@
WSGI middleware for OpenStack API controllers.
"""
from oslo_config import cfg
from oslo_log import log as logging
from oslo_service import wsgi as base_wsgi
import routes
@ -26,6 +27,16 @@ from cinder.api.openstack import wsgi
from cinder.i18n import _
openstack_api_opts = [
cfg.StrOpt('project_id_regex',
default=r"[0-9a-f\-]+",
help=r'The validation regex for project_ids used in urls. '
r'This defaults to [0-9a-f\\-]+ if not set, '
r'which matches normal uuids created by keystone.'),
]
CONF = cfg.CONF
CONF.register_opts(openstack_api_opts)
LOG = logging.getLogger(__name__)
@ -48,14 +59,42 @@ class APIMapper(routes.Mapper):
class ProjectMapper(APIMapper):
def resource(self, member_name, collection_name, **kwargs):
"""Base resource path handler
This method is compatible with resource paths that include a
project_id and those that don't. Including project_id in the URLs
was a legacy API requirement; and making API requests against
such endpoints won't work for users that don't belong to a
particular project.
"""
# NOTE: project_id parameter is only valid if its hex or hex + dashes
# (note, integers are a subset of this). This is required to handle
# our overlapping routes issues.
project_id_regex = CONF.project_id_regex
project_id_token = '{project_id:%s}' % project_id_regex
if 'parent_resource' not in kwargs:
kwargs['path_prefix'] = '{project_id}/'
kwargs['path_prefix'] = '%s/' % project_id_token
else:
parent_resource = kwargs['parent_resource']
p_collection = parent_resource['collection_name']
p_member = parent_resource['member_name']
kwargs['path_prefix'] = '{project_id}/%s/:%s_id' % (p_collection,
p_member)
kwargs['path_prefix'] = '%s/%s/:%s_id' % (project_id_token,
p_collection,
p_member)
routes.Mapper.resource(self,
member_name,
collection_name,
**kwargs)
# Add additional routes without project_id.
if 'parent_resource' not in kwargs:
del kwargs['path_prefix']
else:
parent_resource = kwargs['parent_resource']
p_collection = parent_resource['collection_name']
p_member = parent_resource['member_name']
kwargs['path_prefix'] = '%s/:%s_id' % (p_collection,
p_member)
routes.Mapper.resource(self,
member_name,
collection_name,

View File

@ -152,14 +152,15 @@ REST_API_VERSION_HISTORY = """
- Accept 'consumes_quota' filter in volume and snapshot list
operation.
* 3.66 - Allow snapshotting in-use volumes without force flag.
* 3.67 - API URLs no longer need to include a project_id parameter.
"""
# The minimum and maximum versions of the API supported
# The default api version request is defined to be the
# minimum version of the API supported.
_MIN_API_VERSION = "3.0"
_MAX_API_VERSION = "3.66"
UPDATED = "2021-09-16T00:00:00Z"
_MAX_API_VERSION = "3.67"
UPDATED = "2021-11-02T00:00:00Z"
# NOTE(cyeoh): min and max versions declared as functions so we can

View File

@ -505,3 +505,11 @@ Volume snapshots of in-use volumes can be created without the 'force' flag.
Although the 'force' flag is now considered invalid when passed in a volume
snapshot request, for backward compatibility, the 'force' flag with a value
evaluating to True is silently ignored.
3.67
----
API URLs no longer need a "project_id" argument in them. For example, the API
route: ``https://$(controller)s/volume/v3/$(project_id)s/volumes`` is
equivalent to ``https://$(controller)s/volume/v3/volumes``. When interacting
with the cinder service as system or domain scoped users, a project_id should
not be specified in the API path.

View File

@ -94,27 +94,31 @@ class APIRouter(cinder.api.openstack.APIRouter):
controller=self.resources['groups'],
collection={'detail': 'GET'},
member={'action': 'POST'})
mapper.connect("groups",
"/{project_id}/groups/{id}/action",
controller=self.resources["groups"],
action="action",
conditions={"method": ["POST"]})
mapper.connect("groups/action",
"/{project_id}/groups/action",
controller=self.resources["groups"],
action="action",
conditions={"method": ["POST"]})
for path_prefix in ['/{project_id}', '']:
# project_id is optional
mapper.connect("groups",
"%s/groups/{id}/action" % path_prefix,
controller=self.resources["groups"],
action="action",
conditions={"method": ["POST"]})
mapper.connect("groups/action",
"%s/groups/action" % path_prefix,
controller=self.resources["groups"],
action="action",
conditions={"method": ["POST"]})
self.resources['group_snapshots'] = group_snapshots.create_resource()
mapper.resource("group_snapshot", "group_snapshots",
controller=self.resources['group_snapshots'],
collection={'detail': 'GET'},
member={'action': 'POST'})
mapper.connect("group_snapshots",
"/{project_id}/group_snapshots/{id}/action",
controller=self.resources["group_snapshots"],
action="action",
conditions={"method": ["POST"]})
for path_prefix in ['/{project_id}', '']:
# project_id is optional
mapper.connect("group_snapshots",
"%s/group_snapshots/{id}/action" % path_prefix,
controller=self.resources["group_snapshots"],
action="action",
conditions={"method": ["POST"]})
self.resources['snapshots'] = snapshots.create_resource(ext_mgr)
mapper.resource("snapshot", "snapshots",
controller=self.resources['snapshots'],
@ -134,11 +138,13 @@ class APIRouter(cinder.api.openstack.APIRouter):
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']})
for path_prefix in ['/{project_id}', '']:
# project_id is optional
mapper.connect("metadata",
"%s/snapshots/{snapshot_id}/metadata" % path_prefix,
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']
@ -148,11 +154,13 @@ class APIRouter(cinder.api.openstack.APIRouter):
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']})
for path_prefix in ['/{project_id}', '']:
# project_id is optional
mapper.connect("metadata",
"%s/volumes/{volume_id}/metadata" % path_prefix,
controller=volume_metadata_controller,
action='update_all',
conditions={"method": ['PUT']})
self.resources['consistencygroups'] = (
consistencygroups.create_resource())

View File

@ -147,9 +147,12 @@ auth_opts = [
cfg.StrOpt('auth_strategy',
default='keystone',
choices=[('noauth', 'Do not perform authentication'),
('noauth_include_project_id',
'Do not perform authentication, and include a'
' project_id in API URLs'),
('keystone', 'Authenticate using keystone')],
help='The strategy to use for auth. Supports noauth or '
'keystone.'),
help='The strategy to use for auth. Supports noauth,'
' noauth_include_project_id or keystone.'),
]
backup_opts = [

View File

@ -29,6 +29,7 @@ from cinder import objects # noqa
objects.register_all()
from cinder.api import common as cinder_api_common
from cinder.api.middleware import auth as cinder_api_middleware_auth
import cinder.api.openstack
from cinder.api.views import versions as cinder_api_views_versions
from cinder.backup import api as cinder_backup_api
from cinder.backup import chunkeddriver as cinder_backup_chunkeddriver
@ -216,6 +217,7 @@ def list_opts():
itertools.chain(
cinder_api_common.api_common_opts,
[cinder_api_middleware_auth.use_forwarded_for_opt],
cinder.api.openstack.openstack_api_opts,
cinder_api_views_versions.versions_opts,
cinder_backup_api.backup_opts,
cinder_backup_chunkeddriver.backup_opts,

View File

@ -78,6 +78,22 @@ class ApiSampleTestBase(functional_helpers._FunctionalTestBase):
# this is used to generate sample docs
self.generate_samples = os.getenv('GENERATE_SAMPLES') is not None
def _get_flags(self):
f = super()._get_flags()
# Use noauth_include_project_id so the API samples tests include a
# project_id in the API URLs. This is done for two reasons:
#
# 1. The API samples generated by the tests need to include a
# project_id because the API documentation includes the project_id.
#
# 2. It ensures there are no regressions, and the API functions
# correctly when a project_id is in the URL. The other functional
# tests do not include the project_id, so we cover both variants.
f['auth_strategy'] = {'v': 'noauth_include_project_id'}
return f
@property
def subs(self):
return self._subs

View File

@ -25,6 +25,8 @@ import webob
import webob.exc
from cinder.api import common
from cinder.tests.unit.api import fakes
from cinder.tests.unit import fake_constants
from cinder.tests.unit import test
@ -358,6 +360,45 @@ class TestCollectionLinks(test.TestCase):
self._validate_next_link(item_count, osapi_max_limit, limit,
should_link_exist)
@ddt.data(
{
# The project_id in the context matches the one in the v3 URL.
'project_id': fake_constants.PROJECT_ID,
'url': '/v3/%s/something' % fake_constants.PROJECT_ID,
'expected': fake_constants.PROJECT_ID,
},
{
# The project_id in the context does NOT match the one in the URL.
'project_id': fake_constants.PROJECT2_ID,
'url': '/v3/%s/something' % fake_constants.PROJECT_ID,
'expected': '',
},
{
# The context does not include a project_id (it's system scoped).
'project_id': None,
'url': '/v3/%s/something' % fake_constants.PROJECT_ID,
'expected': '',
},
{
# The v3 URL does not contain a project ID.
'project_id': fake_constants.PROJECT_ID,
'url': '/v3/something',
'expected': '',
},
{
# The URL doesn't specify v3.
'project_id': fake_constants.PROJECT_ID,
'url': '/vX/%s/something' % fake_constants.PROJECT_ID,
'expected': '',
},
)
@ddt.unpack
def test_project_id_in_url(self, project_id, url, expected):
req = fakes.HTTPRequest.blank(url)
req.environ['cinder.context'].project_id = project_id
actual = common.ViewBuilder()._get_project_id_in_url(req)
self.assertEqual(expected, actual)
@ddt.ddt
class GeneralFiltersTest(test.TestCase):

View File

@ -102,7 +102,7 @@ class SnapshotApiTest(test.TestCase):
}
body = dict(snapshot=snapshot)
req = fakes.HTTPRequest.blank('/v2/snapshots')
req = fakes.HTTPRequest.blank('/v3/snapshots')
resp_dict = self.controller.create(req, body=body)
self.assertIn('snapshot', resp_dict)
@ -123,7 +123,7 @@ class SnapshotApiTest(test.TestCase):
}
body = dict(snapshot=snapshot)
req = fakes.HTTPRequest.blank('/v2/snapshots')
req = fakes.HTTPRequest.blank('/v3/snapshots')
resp_dict = self.controller.create(req, body=body)
self.assertIn('snapshot', resp_dict)
@ -144,7 +144,7 @@ class SnapshotApiTest(test.TestCase):
"description": snapshot_description
}
body = dict(snapshot=snapshot)
req = fakes.HTTPRequest.blank('/v2/snapshots')
req = fakes.HTTPRequest.blank('/v3/snapshots')
resp_dict = self.controller.create(req, body=body)
self.assertIn('snapshot', resp_dict)
@ -169,7 +169,7 @@ class SnapshotApiTest(test.TestCase):
"description": snapshot_description
}
body = dict(snapshot=snapshot)
req = fakes.HTTPRequest.blank('/v2/snapshots')
req = fakes.HTTPRequest.blank('/v3/snapshots')
self.assertRaises(exception.InvalidVolume,
self.controller.create,
req,
@ -192,7 +192,7 @@ class SnapshotApiTest(test.TestCase):
"description": snapshot_description
}
body = dict(snapshot=snapshot)
req = fakes.HTTPRequest.blank('/v2/snapshots')
req = fakes.HTTPRequest.blank('/v3/snapshots')
self.assertRaises(exception.ValidationError,
self.controller.create,
req,
@ -210,7 +210,7 @@ class SnapshotApiTest(test.TestCase):
"description": snapshot_description
}
}
req = fakes.HTTPRequest.blank('/v2/snapshots')
req = fakes.HTTPRequest.blank('/v3/snapshots')
self.assertRaises(exception.ValidationError,
self.controller.create, req, body=body)
@ -223,7 +223,7 @@ class SnapshotApiTest(test.TestCase):
def test_snapshot_create_with_leading_trailing_spaces(self, body):
volume = utils.create_volume(self.ctx, volume_type_id=None)
body['snapshot']['volume_id'] = volume.id
req = fakes.HTTPRequest.blank('/v2/snapshots')
req = fakes.HTTPRequest.blank('/v3/snapshots')
resp_dict = self.controller.create(req, body=body)
self.assertEqual(body['snapshot']['display_name'].strip(),
@ -259,7 +259,7 @@ class SnapshotApiTest(test.TestCase):
"name": "Updated Test Name",
}
body = {"snapshot": updates}
req = fakes.HTTPRequest.blank('/v2/snapshots/%s' % UUID)
req = fakes.HTTPRequest.blank('/v3/snapshots/%s' % UUID)
req.environ['cinder.context'] = self.ctx
res_dict = self.controller.update(req, UUID, body=body)
expected = {
@ -307,7 +307,7 @@ class SnapshotApiTest(test.TestCase):
"description": None,
}
body = {"snapshot": updates}
req = fakes.HTTPRequest.blank('/v2/snapshots/%s' % UUID)
req = fakes.HTTPRequest.blank('/v3/snapshots/%s' % UUID)
req.environ['cinder.context'] = self.ctx
res_dict = self.controller.update(req, UUID, body=body)
@ -318,13 +318,13 @@ class SnapshotApiTest(test.TestCase):
def test_snapshot_update_missing_body(self):
body = {}
req = fakes.HTTPRequest.blank('/v2/snapshots/%s' % UUID)
req = fakes.HTTPRequest.blank('/v3/snapshots/%s' % UUID)
self.assertRaises(exception.ValidationError,
self.controller.update, req, UUID, body=body)
def test_snapshot_update_invalid_body(self):
body = {'name': 'missing top level snapshot key'}
req = fakes.HTTPRequest.blank('/v2/snapshots/%s' % UUID)
req = fakes.HTTPRequest.blank('/v3/snapshots/%s' % UUID)
self.assertRaises(exception.ValidationError,
self.controller.update, req, UUID, body=body)
@ -334,7 +334,7 @@ class SnapshotApiTest(test.TestCase):
"name": "Updated Test Name",
}
body = {"snapshot": updates}
req = fakes.HTTPRequest.blank('/v2/snapshots/not-the-uuid')
req = fakes.HTTPRequest.blank('/v3/snapshots/not-the-uuid')
self.assertRaises(exception.SnapshotNotFound, self.controller.update,
req, 'not-the-uuid', body=body)
@ -366,7 +366,7 @@ class SnapshotApiTest(test.TestCase):
"description": " test "
}
body = {"snapshot": updates}
req = fakes.HTTPRequest.blank('/v2/snapshots/%s' % UUID)
req = fakes.HTTPRequest.blank('/v3/snapshots/%s' % UUID)
req.environ['cinder.context'] = self.ctx
res_dict = self.controller.update(req, UUID, body=body)
expected = {
@ -408,7 +408,7 @@ class SnapshotApiTest(test.TestCase):
volume_get_by_id.return_value = fake_volume_obj
snapshot_id = UUID
req = fakes.HTTPRequest.blank('/v2/snapshots/%s' % snapshot_id)
req = fakes.HTTPRequest.blank('/v3/snapshots/%s' % snapshot_id)
req.environ['cinder.context'] = self.ctx
resp = self.controller.delete(req, snapshot_id)
self.assertEqual(HTTPStatus.ACCEPTED, resp.status_int)
@ -417,7 +417,7 @@ class SnapshotApiTest(test.TestCase):
self.mock_object(volume.api.API, "delete_snapshot",
fake_snapshot_delete)
snapshot_id = INVALID_UUID
req = fakes.HTTPRequest.blank('/v2/snapshots/%s' % snapshot_id)
req = fakes.HTTPRequest.blank('/v3/snapshots/%s' % snapshot_id)
self.assertRaises(exception.SnapshotNotFound, self.controller.delete,
req, snapshot_id)
@ -439,7 +439,7 @@ class SnapshotApiTest(test.TestCase):
fake_volume_obj = fake_volume.fake_volume_obj(self.ctx)
snapshot_get_by_id.return_value = snapshot_obj
volume_get_by_id.return_value = fake_volume_obj
req = fakes.HTTPRequest.blank('/v2/snapshots/%s' % UUID)
req = fakes.HTTPRequest.blank('/v3/snapshots/%s' % UUID)
req.environ['cinder.context'] = self.ctx
resp_dict = self.controller.show(req, UUID)
@ -449,7 +449,7 @@ class SnapshotApiTest(test.TestCase):
def test_snapshot_show_invalid_id(self):
snapshot_id = INVALID_UUID
req = fakes.HTTPRequest.blank('/v2/snapshots/%s' % snapshot_id)
req = fakes.HTTPRequest.blank('/v3/snapshots/%s' % snapshot_id)
self.assertRaises(exception.SnapshotNotFound,
self.controller.show, req, snapshot_id)
@ -476,7 +476,7 @@ class SnapshotApiTest(test.TestCase):
snapshots = objects.SnapshotList(objects=[snapshot_obj])
get_all_snapshots.return_value = snapshots
req = fakes.HTTPRequest.blank('/v2/snapshots/detail')
req = fakes.HTTPRequest.blank('/v3/snapshots/detail')
resp_dict = self.controller.detail(req)
self.assertIn('snapshots', resp_dict)
@ -494,7 +494,7 @@ class SnapshotApiTest(test.TestCase):
@mock.patch('cinder.db.snapshot_metadata_get', return_value=dict())
def test_admin_list_snapshots_limited_to_project(self,
snapshot_metadata_get):
req = fakes.HTTPRequest.blank('/v2/%s/snapshots' % fake.PROJECT_ID,
req = fakes.HTTPRequest.blank('/v3/%s/snapshots' % fake.PROJECT_ID,
use_admin_context=True)
res = self.controller.index(req)
@ -505,7 +505,7 @@ class SnapshotApiTest(test.TestCase):
def test_list_snapshots_with_limit_and_offset(self,
snapshot_metadata_get):
def list_snapshots_with_limit_and_offset(snaps, is_admin):
req = fakes.HTTPRequest.blank('/v2/%s/snapshots?limit=1'
req = fakes.HTTPRequest.blank('/v3/%s/snapshots?limit=1'
'&offset=1' % fake.PROJECT_ID,
use_admin_context=is_admin)
res = self.controller.index(req)
@ -517,7 +517,7 @@ class SnapshotApiTest(test.TestCase):
# Test that we get an empty list with an offset greater than the
# number of items
req = fakes.HTTPRequest.blank('/v2/snapshots?limit=1&offset=3')
req = fakes.HTTPRequest.blank('/v3/snapshots?limit=1&offset=3')
self.assertEqual({'snapshots': []}, self.controller.index(req))
volume, snaps = self._create_db_snapshots(3)
@ -535,32 +535,32 @@ class SnapshotApiTest(test.TestCase):
mock_snapshot_get_all.return_value = []
# Negative limit
req = fakes.HTTPRequest.blank('/v2/snapshots?limit=-1&offset=1')
req = fakes.HTTPRequest.blank('/v3/snapshots?limit=-1&offset=1')
self.assertRaises(webob.exc.HTTPBadRequest,
self.controller.index,
req)
# Non numeric limit
req = fakes.HTTPRequest.blank('/v2/snapshots?limit=a&offset=1')
req = fakes.HTTPRequest.blank('/v3/snapshots?limit=a&offset=1')
self.assertRaises(webob.exc.HTTPBadRequest,
self.controller.index,
req)
# Negative offset
req = fakes.HTTPRequest.blank('/v2/snapshots?limit=1&offset=-1')
req = fakes.HTTPRequest.blank('/v3/snapshots?limit=1&offset=-1')
self.assertRaises(webob.exc.HTTPBadRequest,
self.controller.index,
req)
# Non numeric offset
req = fakes.HTTPRequest.blank('/v2/snapshots?limit=1&offset=a')
req = fakes.HTTPRequest.blank('/v3/snapshots?limit=1&offset=a')
self.assertRaises(webob.exc.HTTPBadRequest,
self.controller.index,
req)
# Test that we get an exception HTTPBadRequest(400) with an offset
# greater than the maximum offset value.
url = '/v2/snapshots?limit=1&offset=323245324356534235'
url = '/v3/snapshots?limit=1&offset=323245324356534235'
req = fakes.HTTPRequest.blank(url)
self.assertRaises(webob.exc.HTTPBadRequest,
self.controller.index, req)
@ -569,8 +569,8 @@ class SnapshotApiTest(test.TestCase):
**kwargs):
"""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 = '/v2/%s/snapshots' % project
# v2 in the request path, if we did, we'd get /v3/v2 links back
request_path = '/v3/%s/snapshots' % project
expected_path = request_path
# Construct the query if there are kwargs
@ -679,7 +679,7 @@ class SnapshotApiTest(test.TestCase):
v2_fakes.fake_snapshot_get_all)
@mock.patch('cinder.db.snapshot_metadata_get', return_value=dict())
def test_admin_list_snapshots_all_tenants(self, snapshot_metadata_get):
req = fakes.HTTPRequest.blank('/v2/%s/snapshots?all_tenants=1' %
req = fakes.HTTPRequest.blank('/v3/%s/snapshots?all_tenants=1' %
fake.PROJECT_ID,
use_admin_context=True)
res = self.controller.index(req)
@ -700,7 +700,7 @@ class SnapshotApiTest(test.TestCase):
snapshot_get_all.side_effect = get_all
req = fakes.HTTPRequest.blank('/v2/%s/snapshots?all_tenants=1'
req = fakes.HTTPRequest.blank('/v3/%s/snapshots?all_tenants=1'
'&project_id=tenant1' % fake.PROJECT_ID,
use_admin_context=True)
res = self.controller.index(req)
@ -712,7 +712,7 @@ class SnapshotApiTest(test.TestCase):
@mock.patch('cinder.db.snapshot_metadata_get', return_value=dict())
def test_all_tenants_non_admin_gets_all_tenants(self,
snapshot_metadata_get):
req = fakes.HTTPRequest.blank('/v2/%s/snapshots?all_tenants=1' %
req = fakes.HTTPRequest.blank('/v3/%s/snapshots?all_tenants=1' %
fake.PROJECT_ID)
res = self.controller.index(req)
self.assertIn('snapshots', res)
@ -724,13 +724,13 @@ class SnapshotApiTest(test.TestCase):
v2_fakes.fake_snapshot_get_all)
@mock.patch('cinder.db.snapshot_metadata_get', return_value=dict())
def test_non_admin_get_by_project(self, snapshot_metadata_get):
req = fakes.HTTPRequest.blank('/v2/%s/snapshots' % fake.PROJECT_ID)
req = fakes.HTTPRequest.blank('/v3/%s/snapshots' % fake.PROJECT_ID)
res = self.controller.index(req)
self.assertIn('snapshots', res)
self.assertEqual(1, len(res['snapshots']))
def _create_snapshot_bad_body(self, body):
req = fakes.HTTPRequest.blank('/v2/%s/snapshots' % fake.PROJECT_ID)
req = fakes.HTTPRequest.blank('/v3/%s/snapshots' % fake.PROJECT_ID)
req.method = 'POST'
self.assertRaises(exception.ValidationError,

View File

@ -93,7 +93,7 @@ class VolumeApiTest(test.TestCase):
vol = self._vol_in_request_body()
body = {"volume": vol}
req = fakes.HTTPRequest.blank('/v2/volumes')
req = fakes.HTTPRequest.blank('/v3/volumes')
res_dict = self.controller.create(req, body=body)
ex = self._expected_vol_from_controller()
self.assertEqual(ex, res_dict)
@ -112,7 +112,7 @@ class VolumeApiTest(test.TestCase):
vol = self._vol_in_request_body(volume_type="FakeTypeName")
body = {"volume": vol}
req = fakes.HTTPRequest.blank('/v2/volumes')
req = fakes.HTTPRequest.blank('/v3/volumes')
# Raise 404 when type name isn't valid
self.assertRaises(exception.VolumeTypeNotFoundByName,
self.controller.create, req, body=body)
@ -143,7 +143,7 @@ class VolumeApiTest(test.TestCase):
db.sqlalchemy.api._GET_METHODS = {}
self.mock_object(db.sqlalchemy.api, '_volume_type_get_full',
v2_fakes.fake_volume_type_get)
req = fakes.HTTPRequest.blank('/v2/volumes/detail')
req = fakes.HTTPRequest.blank('/v3/volumes/detail')
res_dict = self.controller.detail(req)
self.assertTrue(mock_validate.called)
@ -207,11 +207,11 @@ class VolumeApiTest(test.TestCase):
'description': description,
'id': v2_fakes.DEFAULT_VOL_ID,
'links':
[{'href': 'http://localhost/v2/%s/volumes/%s' % (
fake.PROJECT_ID, fake.VOLUME_ID),
[{'href': 'http://localhost/v3/volumes/%s' % (
fake.VOLUME_ID),
'rel': 'self'},
{'href': 'http://localhost/%s/volumes/%s' % (
fake.PROJECT_ID, fake.VOLUME_ID),
{'href': 'http://localhost/volumes/%s' % (
fake.VOLUME_ID),
'rel': 'bookmark'}],
'metadata': metadata,
'name': name,
@ -256,7 +256,7 @@ class VolumeApiTest(test.TestCase):
snapshot_id = fake.SNAPSHOT_ID
vol = self._vol_in_request_body(snapshot_id=snapshot_id)
body = {"volume": vol}
req = fakes.HTTPRequest.blank('/v2/volumes')
req = fakes.HTTPRequest.blank('/v3/volumes')
res_dict = self.controller.create(req, body=body)
ex = self._expected_vol_from_controller(snapshot_id=snapshot_id)
@ -282,7 +282,7 @@ class VolumeApiTest(test.TestCase):
snapshot_id = fake.WILL_NOT_BE_FOUND_ID
vol = self._vol_in_request_body(snapshot_id=snapshot_id)
body = {"volume": vol}
req = fakes.HTTPRequest.blank('/v2/volumes')
req = fakes.HTTPRequest.blank('/v3/volumes')
# Raise 404 when snapshot cannot be found.
self.assertRaises(exception.SnapshotNotFound, self.controller.create,
req, body=body)
@ -297,7 +297,7 @@ class VolumeApiTest(test.TestCase):
snapshot_id = value
vol = self._vol_in_request_body(snapshot_id=snapshot_id)
body = {"volume": vol}
req = fakes.HTTPRequest.blank('/v2/volumes')
req = fakes.HTTPRequest.blank('/v3/volumes')
# Raise 400 when snapshot has not uuid type.
self.assertRaises(exception.ValidationError, self.controller.create,
req, body=body)
@ -315,7 +315,7 @@ class VolumeApiTest(test.TestCase):
source_volid = '2f49aa3a-6aae-488d-8b99-a43271605af6'
vol = self._vol_in_request_body(source_volid=source_volid)
body = {"volume": vol}
req = fakes.HTTPRequest.blank('/v2/volumes')
req = fakes.HTTPRequest.blank('/v3/volumes')
res_dict = self.controller.create(req, body=body)
ex = self._expected_vol_from_controller(source_volid=source_volid)
@ -344,7 +344,7 @@ class VolumeApiTest(test.TestCase):
source_volid = fake.VOLUME_ID
vol = self._vol_in_request_body(source_volid=source_volid)
body = {"volume": vol}
req = fakes.HTTPRequest.blank('/v2/volumes')
req = fakes.HTTPRequest.blank('/v3/volumes')
# Raise 404 when source volume cannot be found.
self.assertRaises(exception.VolumeNotFound, self.controller.create,
req, body=body)
@ -361,7 +361,7 @@ class VolumeApiTest(test.TestCase):
vol = self._vol_in_request_body()
vol.update(updated_uuids)
body = {"volume": vol}
req = fakes.HTTPRequest.blank('/v2/volumes')
req = fakes.HTTPRequest.blank('/v3/volumes')
# Raise 400 for resource requested with invalid uuids.
self.assertRaises(exception.ValidationError, self.controller.create,
req, body=body)
@ -376,7 +376,7 @@ class VolumeApiTest(test.TestCase):
vol = self._vol_in_request_body(
consistencygroup_id=consistencygroup_id)
body = {"volume": vol}
req = fakes.HTTPRequest.blank('/v2/volumes')
req = fakes.HTTPRequest.blank('/v3/volumes')
# Raise 404 when consistency group is not found.
self.assertRaises(exception.GroupNotFound,
self.controller.create, req, body=body)
@ -388,7 +388,7 @@ class VolumeApiTest(test.TestCase):
def test_volume_creation_fails_with_bad_size(self):
vol = self._vol_in_request_body(size="")
body = {"volume": vol}
req = fakes.HTTPRequest.blank('/v2/volumes')
req = fakes.HTTPRequest.blank('/v3/volumes')
self.assertRaises(exception.ValidationError,
self.controller.create,
req,
@ -397,7 +397,7 @@ class VolumeApiTest(test.TestCase):
def test_volume_creation_fails_with_bad_availability_zone(self):
vol = self._vol_in_request_body(availability_zone="zonen:hostn")
body = {"volume": vol}
req = fakes.HTTPRequest.blank('/v2/volumes')
req = fakes.HTTPRequest.blank('/v3/volumes')
self.assertRaises(exception.InvalidAvailabilityZone,
self.controller.create,
req, body=body)
@ -415,7 +415,7 @@ class VolumeApiTest(test.TestCase):
image_ref="c905cedb-7281-47e4-8a62-f26bc5fc4c77")
ex = self._expected_vol_from_controller(availability_zone="nova")
body = {"volume": vol}
req = fakes.HTTPRequest.blank('/v2/volumes')
req = fakes.HTTPRequest.blank('/v3/volumes')
res_dict = self.controller.create(req, body=body)
self.assertEqual(ex, res_dict)
self.assertTrue(mock_validate.called)
@ -425,7 +425,7 @@ class VolumeApiTest(test.TestCase):
vol = self._vol_in_request_body(availability_zone="cinder",
image_ref=1234)
body = {"volume": vol}
req = fakes.HTTPRequest.blank('/v2/volumes')
req = fakes.HTTPRequest.blank('/v3/volumes')
self.assertRaises(exception.ValidationError,
self.controller.create,
req,
@ -439,7 +439,7 @@ class VolumeApiTest(test.TestCase):
vol = self._vol_in_request_body(availability_zone="cinder",
image_ref="12345")
body = {"volume": vol}
req = fakes.HTTPRequest.blank('/v2/volumes')
req = fakes.HTTPRequest.blank('/v3/volumes')
self.assertRaises(webob.exc.HTTPBadRequest,
self.controller.create,
req,
@ -453,7 +453,7 @@ class VolumeApiTest(test.TestCase):
vol = self._vol_in_request_body(availability_zone="cinder",
image_ref="")
body = {"volume": vol}
req = fakes.HTTPRequest.blank('/v2/volumes')
req = fakes.HTTPRequest.blank('/v3/volumes')
self.assertRaises(webob.exc.HTTPBadRequest,
self.controller.create,
req,
@ -472,7 +472,7 @@ class VolumeApiTest(test.TestCase):
image_id="c905cedb-7281-47e4-8a62-f26bc5fc4c77")
ex = self._expected_vol_from_controller(availability_zone="nova")
body = {"volume": vol}
req = fakes.HTTPRequest.blank('/v2/volumes')
req = fakes.HTTPRequest.blank('/v3/volumes')
res_dict = self.controller.create(req, body=body)
self.assertEqual(ex, res_dict)
self.assertTrue(mock_validate.called)
@ -482,7 +482,7 @@ class VolumeApiTest(test.TestCase):
vol = self._vol_in_request_body(availability_zone="cinder",
image_id=1234)
body = {"volume": vol}
req = fakes.HTTPRequest.blank('/v2/volumes')
req = fakes.HTTPRequest.blank('/v3/volumes')
self.assertRaises(exception.ValidationError,
self.controller.create,
req,
@ -496,7 +496,7 @@ class VolumeApiTest(test.TestCase):
vol = self._vol_in_request_body(availability_zone="cinder",
image_id="12345")
body = {"volume": vol}
req = fakes.HTTPRequest.blank('/v2/volumes')
req = fakes.HTTPRequest.blank('/v3/volumes')
self.assertRaises(webob.exc.HTTPBadRequest,
self.controller.create,
req,
@ -510,7 +510,7 @@ class VolumeApiTest(test.TestCase):
vol = self._vol_in_request_body(availability_zone="cinder",
image_id="")
body = {"volume": vol}
req = fakes.HTTPRequest.blank('/v2/volumes')
req = fakes.HTTPRequest.blank('/v3/volumes')
self.assertRaises(webob.exc.HTTPBadRequest,
self.controller.create,
req,
@ -532,7 +532,7 @@ class VolumeApiTest(test.TestCase):
image_ref=test_id)
ex = self._expected_vol_from_controller(availability_zone="nova")
body = {"volume": vol}
req = fakes.HTTPRequest.blank('/v2/volumes')
req = fakes.HTTPRequest.blank('/v3/volumes')
res_dict = self.controller.create(req, body=body)
self.assertEqual(ex, res_dict)
@ -547,7 +547,7 @@ class VolumeApiTest(test.TestCase):
vol = self._vol_in_request_body(availability_zone="nova",
image_ref=test_id)
body = {"volume": vol}
req = fakes.HTTPRequest.blank('/v2/volumes')
req = fakes.HTTPRequest.blank('/v3/volumes')
self.assertRaises(webob.exc.HTTPConflict,
self.controller.create,
req,
@ -564,7 +564,7 @@ class VolumeApiTest(test.TestCase):
vol = self._vol_in_request_body(availability_zone="nova",
image_ref=test_id)
body = {"volume": vol}
req = fakes.HTTPRequest.blank('/v2/volumes')
req = fakes.HTTPRequest.blank('/v3/volumes')
self.assertRaises(webob.exc.HTTPBadRequest,
self.controller.create,
req,
@ -573,7 +573,7 @@ class VolumeApiTest(test.TestCase):
def test_volume_create_with_invalid_multiattach(self):
vol = self._vol_in_request_body(multiattach="InvalidBool")
body = {"volume": vol}
req = fakes.HTTPRequest.blank('/v2/volumes')
req = fakes.HTTPRequest.blank('/v3/volumes')
self.assertRaises(exception.ValidationError,
self.controller.create,
@ -596,7 +596,7 @@ class VolumeApiTest(test.TestCase):
ex = self._expected_vol_from_controller(multiattach=True)
req = fakes.HTTPRequest.blank('/v2/volumes')
req = fakes.HTTPRequest.blank('/v3/volumes')
res_dict = self.controller.create(req, body=body)
self.assertEqual(ex, res_dict)
@ -609,7 +609,7 @@ class VolumeApiTest(test.TestCase):
vol = self._vol_in_request_body()
vol['metadata'] = value
body = {"volume": vol}
req = fakes.HTTPRequest.blank('/v2/volumes')
req = fakes.HTTPRequest.blank('/v3/volumes')
self.assertRaises(exception.ValidationError,
self.controller.create,
@ -630,7 +630,7 @@ class VolumeApiTest(test.TestCase):
"description": body['description']
}
body = {"volume": updates}
req = fakes.HTTPRequest.blank('/v2/volumes/%s' % fake.VOLUME_ID)
req = fakes.HTTPRequest.blank('/v3/volumes/%s' % fake.VOLUME_ID)
self.assertEqual(0, len(self.notifier.notifications))
name = updates["name"].strip()
description = updates["description"].strip()
@ -655,7 +655,7 @@ class VolumeApiTest(test.TestCase):
"display_description": "Updated Test Description",
}
body = {"volume": updates}
req = fakes.HTTPRequest.blank('/v2/volumes/%s' % fake.VOLUME_ID)
req = fakes.HTTPRequest.blank('/v3/volumes/%s' % fake.VOLUME_ID)
self.assertEqual(0, len(self.notifier.notifications))
res_dict = self.controller.update(req, fake.VOLUME_ID, body=body)
expected = self._expected_vol_from_controller(
@ -682,7 +682,7 @@ class VolumeApiTest(test.TestCase):
"display_description": "Not Shown Description",
}
body = {"volume": updates}
req = fakes.HTTPRequest.blank('/v2/volumes/%s' % fake.VOLUME_ID)
req = fakes.HTTPRequest.blank('/v3/volumes/%s' % fake.VOLUME_ID)
self.assertEqual(0, len(self.notifier.notifications))
res_dict = self.controller.update(req, fake.VOLUME_ID, body=body)
expected = self._expected_vol_from_controller(
@ -705,7 +705,7 @@ class VolumeApiTest(test.TestCase):
"metadata": {"qos_max_iops": '2000'}
}
body = {"volume": updates}
req = fakes.HTTPRequest.blank('/v2/volumes/%s' % fake.VOLUME_ID)
req = fakes.HTTPRequest.blank('/v3/volumes/%s' % fake.VOLUME_ID)
self.assertEqual(0, len(self.notifier.notifications))
res_dict = self.controller.update(req, fake.VOLUME_ID, body=body)
expected = self._expected_vol_from_controller(
@ -743,7 +743,7 @@ class VolumeApiTest(test.TestCase):
"name": "Updated Test Name",
}
body = {"volume": updates}
req = fakes.HTTPRequest.blank('/v2/volumes/%s' % fake.VOLUME_ID)
req = fakes.HTTPRequest.blank('/v3/volumes/%s' % fake.VOLUME_ID)
self.assertEqual(0, len(self.notifier.notifications))
admin_ctx = context.RequestContext(fake.USER_ID, fake.PROJECT_ID, True)
req.environ['cinder.context'] = admin_ctx
@ -780,7 +780,7 @@ class VolumeApiTest(test.TestCase):
"metadata": value
}
body = {"volume": updates}
req = fakes.HTTPRequest.blank('/v2/volumes/%s' % fake.VOLUME_ID)
req = fakes.HTTPRequest.blank('/v3/volumes/%s' % fake.VOLUME_ID)
self.assertRaises(exception.ValidationError,
self.controller.update,
@ -788,7 +788,7 @@ class VolumeApiTest(test.TestCase):
def test_update_empty_body(self):
body = {}
req = fakes.HTTPRequest.blank('/v2/volumes/%s' % fake.VOLUME_ID)
req = fakes.HTTPRequest.blank('/v3/volumes/%s' % fake.VOLUME_ID)
self.assertRaises(exception.ValidationError,
self.controller.update,
req, fake.VOLUME_ID, body=body)
@ -797,7 +797,7 @@ class VolumeApiTest(test.TestCase):
body = {
'name': 'missing top level volume key'
}
req = fakes.HTTPRequest.blank('/v2/volumes/%s' % fake.VOLUME_ID)
req = fakes.HTTPRequest.blank('/v3/volumes/%s' % fake.VOLUME_ID)
self.assertRaises(exception.ValidationError,
self.controller.update,
req, fake.VOLUME_ID, body=body)
@ -807,7 +807,7 @@ class VolumeApiTest(test.TestCase):
{'display_name': 'a' * 256},
{'display_description': 'a' * 256})
def test_update_exceeds_length_name_description(self, vol):
req = fakes.HTTPRequest.blank('/v2/volumes/%s' % fake.VOLUME_ID)
req = fakes.HTTPRequest.blank('/v3/volumes/%s' % fake.VOLUME_ID)
body = {'volume': vol}
self.assertRaises(exception.InvalidInput,
self.controller.update,
@ -820,7 +820,7 @@ class VolumeApiTest(test.TestCase):
"name": "Updated Test Name",
}
body = {"volume": updates}
req = fakes.HTTPRequest.blank('/v2/volumes/%s' % fake.VOLUME_ID)
req = fakes.HTTPRequest.blank('/v3/volumes/%s' % fake.VOLUME_ID)
self.assertRaises(exception.VolumeNotFound,
self.controller.update,
req, fake.VOLUME_ID, body=body)
@ -831,7 +831,7 @@ class VolumeApiTest(test.TestCase):
self.mock_object(db.sqlalchemy.api, '_volume_type_get_full',
v2_fakes.fake_volume_type_get)
req = fakes.HTTPRequest.blank('/v2/volumes')
req = fakes.HTTPRequest.blank('/v3/%s/volumes' % fake.PROJECT_ID)
res_dict = self.controller.index(req)
expected = {
'volumes': [
@ -840,7 +840,7 @@ class VolumeApiTest(test.TestCase):
'id': fake.VOLUME_ID,
'links': [
{
'href': 'http://localhost/v2/%s/volumes/%s' % (
'href': 'http://localhost/v3/%s/volumes/%s' % (
fake.PROJECT_ID, fake.VOLUME_ID),
'rel': 'self'
},
@ -863,7 +863,7 @@ class VolumeApiTest(test.TestCase):
self.mock_object(db.sqlalchemy.api, '_volume_type_get_full',
v2_fakes.fake_volume_type_get)
req = fakes.HTTPRequest.blank('/v2/volumes/detail')
req = fakes.HTTPRequest.blank('/v3/volumes/detail')
res_dict = self.controller.detail(req)
exp_vol = self._expected_vol_from_controller(
availability_zone=v2_fakes.DEFAULT_AZ,
@ -892,7 +892,7 @@ class VolumeApiTest(test.TestCase):
attachment['id'])
volume_tmp = db.volume_get(context.get_admin_context(), fake.VOLUME_ID)
req = fakes.HTTPRequest.blank('/v2/volumes/detail')
req = fakes.HTTPRequest.blank('/v3/volumes/detail')
admin_ctx = context.RequestContext(fake.USER_ID, fake.PROJECT_ID, True)
req.environ['cinder.context'] = admin_ctx
res_dict = self.controller.detail(req)
@ -928,7 +928,7 @@ class VolumeApiTest(test.TestCase):
db.volume_attachment_get(context.get_admin_context(),
attachment['id'])
req = fakes.HTTPRequest.blank('/v2/volumes/detail')
req = fakes.HTTPRequest.blank('/v3/volumes/detail')
res_dict = self.controller.detail(req)
# host_name will always be None for non-admins
self.assertIsNone(
@ -958,7 +958,7 @@ class VolumeApiTest(test.TestCase):
fake_volume_get_all_by_project)
self.mock_object(volume_api.API, 'get', v2_fakes.fake_volume_get)
req = fakes.HTTPRequest.blank('/v2/volumes?marker=1')
req = fakes.HTTPRequest.blank('/v3/volumes?marker=1')
res_dict = self.controller.index(req)
volumes = res_dict['volumes']
self.assertEqual(2, len(volumes))
@ -970,9 +970,9 @@ class VolumeApiTest(test.TestCase):
v2_fakes.fake_volume_get_all_by_project)
self.mock_object(volume_api.API, 'get', v2_fakes.fake_volume_get)
req = fakes.HTTPRequest.blank('/v2/volumes'
req = fakes.HTTPRequest.blank('/v3/%s/volumes'
'?limit=1&name=foo'
'&sort=id1:asc')
'&sort=id1:asc' % fake.PROJECT_ID)
res_dict = self.controller.index(req)
volumes = res_dict['volumes']
self.assertEqual(1, len(volumes))
@ -985,7 +985,7 @@ class VolumeApiTest(test.TestCase):
links = res_dict['volumes_links']
self.assertEqual('next', links[0]['rel'])
href_parts = urllib.parse.urlparse(links[0]['href'])
self.assertEqual('/v2/%s/volumes' % fake.PROJECT_ID, href_parts.path)
self.assertEqual('/v3/%s/volumes' % fake.PROJECT_ID, href_parts.path)
params = urllib.parse.parse_qs(href_parts.query)
self.assertEqual(str(volumes[0]['id']), params['marker'][0])
self.assertEqual('1', params['limit'][0])
@ -993,13 +993,13 @@ class VolumeApiTest(test.TestCase):
self.assertEqual('id1:asc', params['sort'][0])
def test_volume_index_limit_negative(self):
req = fakes.HTTPRequest.blank('/v2/volumes?limit=-1')
req = fakes.HTTPRequest.blank('/v3/volumes?limit=-1')
self.assertRaises(webob.exc.HTTPBadRequest,
self.controller.index,
req)
def test_volume_index_limit_non_int(self):
req = fakes.HTTPRequest.blank('/v2/volumes?limit=a')
req = fakes.HTTPRequest.blank('/v3/volumes?limit=a')
self.assertRaises(webob.exc.HTTPBadRequest,
self.controller.index,
req)
@ -1009,7 +1009,7 @@ class VolumeApiTest(test.TestCase):
v2_fakes.fake_volume_get_all_by_project)
self.mock_object(volume_api.API, 'get', v2_fakes.fake_volume_get)
req = fakes.HTTPRequest.blank('/v2/volumes?marker=1&limit=1')
req = fakes.HTTPRequest.blank('/v3/volumes?marker=1&limit=1')
res_dict = self.controller.index(req)
volumes = res_dict['volumes']
self.assertEqual(1, len(volumes))
@ -1025,25 +1025,25 @@ class VolumeApiTest(test.TestCase):
def test_volume_index_limit_offset(self):
created_volumes = self._create_db_volumes(2)
req = fakes.HTTPRequest.blank('/v2/volumes?limit=2&offset=1')
req = fakes.HTTPRequest.blank('/v3/volumes?limit=2&offset=1')
res_dict = self.controller.index(req)
volumes = res_dict['volumes']
self.assertEqual(1, len(volumes))
self.assertEqual(created_volumes[1].id, volumes[0]['id'])
req = fakes.HTTPRequest.blank('/v2/volumes?limit=-1&offset=1')
req = fakes.HTTPRequest.blank('/v3/volumes?limit=-1&offset=1')
self.assertRaises(webob.exc.HTTPBadRequest,
self.controller.index,
req)
req = fakes.HTTPRequest.blank('/v2/volumes?limit=a&offset=1')
req = fakes.HTTPRequest.blank('/v3/volumes?limit=a&offset=1')
self.assertRaises(webob.exc.HTTPBadRequest,
self.controller.index,
req)
# Test that we get an exception HTTPBadRequest(400) with an offset
# greater than the maximum offset value.
url = '/v2/volumes?limit=2&offset=43543564546567575'
url = '/v3/volumes?limit=2&offset=43543564546567575'
req = fakes.HTTPRequest.blank(url)
self.assertRaises(webob.exc.HTTPBadRequest,
self.controller.index,
@ -1066,7 +1066,7 @@ class VolumeApiTest(test.TestCase):
self.mock_object(db.sqlalchemy.api, '_volume_type_get_full',
v2_fakes.fake_volume_type_get)
req = fakes.HTTPRequest.blank('/v2/volumes/detail?marker=1')
req = fakes.HTTPRequest.blank('/v3/volumes/detail?marker=1')
res_dict = self.controller.detail(req)
volumes = res_dict['volumes']
self.assertEqual(2, len(volumes))
@ -1078,7 +1078,8 @@ class VolumeApiTest(test.TestCase):
v2_fakes.fake_volume_get_all_by_project)
self.mock_object(db.sqlalchemy.api, '_volume_type_get_full',
v2_fakes.fake_volume_type_get)
req = fakes.HTTPRequest.blank('/v2/volumes/detail?limit=1')
req = fakes.HTTPRequest.blank('/v3/%s/volumes/detail?limit=1'
% fake.PROJECT_ID)
res_dict = self.controller.detail(req)
volumes = res_dict['volumes']
self.assertEqual(1, len(volumes))
@ -1087,20 +1088,20 @@ class VolumeApiTest(test.TestCase):
links = res_dict['volumes_links']
self.assertEqual('next', links[0]['rel'])
href_parts = urllib.parse.urlparse(links[0]['href'])
self.assertEqual('/v2/%s/volumes/detail' % fake.PROJECT_ID,
self.assertEqual('/v3/%s/volumes/detail' % fake.PROJECT_ID,
href_parts.path)
params = urllib.parse.parse_qs(href_parts.query)
self.assertIn('marker', params)
self.assertEqual('1', params['limit'][0])
def test_volume_detail_limit_negative(self):
req = fakes.HTTPRequest.blank('/v2/volumes/detail?limit=-1')
req = fakes.HTTPRequest.blank('/v3/volumes/detail?limit=-1')
self.assertRaises(webob.exc.HTTPBadRequest,
self.controller.detail,
req)
def test_volume_detail_limit_non_int(self):
req = fakes.HTTPRequest.blank('/v2/volumes/detail?limit=a')
req = fakes.HTTPRequest.blank('/v3/volumes/detail?limit=a')
self.assertRaises(webob.exc.HTTPBadRequest,
self.controller.detail,
req)
@ -1111,7 +1112,7 @@ class VolumeApiTest(test.TestCase):
self.mock_object(db.sqlalchemy.api, '_volume_type_get_full',
v2_fakes.fake_volume_type_get)
req = fakes.HTTPRequest.blank('/v2/volumes/detail?marker=1&limit=1')
req = fakes.HTTPRequest.blank('/v3/volumes/detail?marker=1&limit=1')
res_dict = self.controller.detail(req)
volumes = res_dict['volumes']
self.assertEqual(1, len(volumes))
@ -1119,30 +1120,30 @@ class VolumeApiTest(test.TestCase):
def test_volume_detail_limit_offset(self):
created_volumes = self._create_db_volumes(2)
req = fakes.HTTPRequest.blank('/v2/volumes/detail?limit=2&offset=1')
req = fakes.HTTPRequest.blank('/v3/volumes/detail?limit=2&offset=1')
res_dict = self.controller.detail(req)
volumes = res_dict['volumes']
self.assertEqual(1, len(volumes))
self.assertEqual(created_volumes[1].id, volumes[0]['id'])
req = fakes.HTTPRequest.blank('/v2/volumes/detail?limit=2&offset=1',
req = fakes.HTTPRequest.blank('/v3/volumes/detail?limit=2&offset=1',
use_admin_context=True)
res_dict = self.controller.detail(req)
volumes = res_dict['volumes']
self.assertEqual(1, len(volumes))
self.assertEqual(created_volumes[1].id, volumes[0]['id'])
req = fakes.HTTPRequest.blank('/v2/volumes/detail?limit=-1&offset=1')
req = fakes.HTTPRequest.blank('/v3/volumes/detail?limit=-1&offset=1')
self.assertRaises(webob.exc.HTTPBadRequest,
self.controller.detail,
req)
req = fakes.HTTPRequest.blank('/v2/volumes/detail?limit=a&offset=1')
req = fakes.HTTPRequest.blank('/v3/volumes/detail?limit=a&offset=1')
self.assertRaises(webob.exc.HTTPBadRequest,
self.controller.detail,
req)
url = '/v2/volumes/detail?limit=2&offset=4536546546546467'
url = '/v3/volumes/detail?limit=2&offset=4536546546546467'
req = fakes.HTTPRequest.blank(url)
self.assertRaises(webob.exc.HTTPBadRequest,
self.controller.detail,
@ -1152,7 +1153,7 @@ class VolumeApiTest(test.TestCase):
def fake_volume_get_all(context, marker, limit, **kwargs):
return []
self.mock_object(db, 'volume_get_all', fake_volume_get_all)
req = fakes.HTTPRequest.blank('/v2/volumes?limit=0')
req = fakes.HTTPRequest.blank('/v3/volumes?limit=0')
res_dict = self.controller.index(req)
expected = {'volumes': []}
self.assertEqual(expected, res_dict)
@ -1163,7 +1164,7 @@ class VolumeApiTest(test.TestCase):
('volumes/detail', self.controller.detail))
key, fn = keys_fns[detailed]
req_string = '/v2/%s?all_tenants=1' % key
req_string = '/v3/%s?all_tenants=1' % key
if limit:
req_string += '&limit=%s' % limit
req = fakes.HTTPRequest.blank(req_string, use_admin_context=True)
@ -1255,7 +1256,7 @@ class VolumeApiTest(test.TestCase):
# all_tenants does not matter for non-admin
for params in ['', '?all_tenants=1']:
req = fakes.HTTPRequest.blank('/v2/volumes%s' % params)
req = fakes.HTTPRequest.blank('/v3/volumes%s' % params)
resp = self.controller.index(req)
self.assertEqual(1, len(resp['volumes']))
self.assertEqual('vol1', resp['volumes'][0]['name'])
@ -1280,7 +1281,7 @@ class VolumeApiTest(test.TestCase):
fake_volume_get_all_by_project2)
self.mock_object(db, 'volume_get_all', fake_volume_get_all2)
req = fakes.HTTPRequest.blank('/v2/volumes', use_admin_context=True)
req = fakes.HTTPRequest.blank('/v3/volumes', use_admin_context=True)
resp = self.controller.index(req)
self.assertEqual(1, len(resp['volumes']))
self.assertEqual('vol2', resp['volumes'][0]['name'])
@ -1306,7 +1307,7 @@ class VolumeApiTest(test.TestCase):
fake_volume_get_all_by_project3)
self.mock_object(db, 'volume_get_all', fake_volume_get_all3)
req = fakes.HTTPRequest.blank('/v2/volumes?all_tenants=1',
req = fakes.HTTPRequest.blank('/v3/volumes?all_tenants=1',
use_admin_context=True)
resp = self.controller.index(req)
self.assertEqual(1, len(resp['volumes']))
@ -1317,7 +1318,7 @@ class VolumeApiTest(test.TestCase):
self.mock_object(db.sqlalchemy.api, '_volume_type_get_full',
v2_fakes.fake_volume_type_get)
req = fakes.HTTPRequest.blank('/v2/volumes/%s' % fake.VOLUME_ID)
req = fakes.HTTPRequest.blank('/v3/volumes/%s' % fake.VOLUME_ID)
res_dict = self.controller.show(req, fake.VOLUME_ID)
expected = self._expected_vol_from_controller(
availability_zone=v2_fakes.DEFAULT_AZ,
@ -1344,7 +1345,7 @@ class VolumeApiTest(test.TestCase):
self.mock_object(db.sqlalchemy.api, '_volume_type_get_full',
v2_fakes.fake_volume_type_get)
req = fakes.HTTPRequest.blank('/v2/volumes/%s' % fake.VOLUME_ID)
req = fakes.HTTPRequest.blank('/v3/volumes/%s' % fake.VOLUME_ID)
res_dict = self.controller.show(req, fake.VOLUME_ID)
expected = self._expected_vol_from_controller(
availability_zone=v2_fakes.DEFAULT_AZ,
@ -1356,7 +1357,7 @@ class VolumeApiTest(test.TestCase):
self.mock_object(volume_api.API, "get",
v2_fakes.fake_volume_get_notfound)
req = fakes.HTTPRequest.blank('/v2/volumes/%s' % fake.VOLUME_ID)
req = fakes.HTTPRequest.blank('/v3/volumes/%s' % fake.VOLUME_ID)
self.assertRaises(exception.VolumeNotFound, self.controller.show,
req, 1)
# Finally test that nothing was cached
@ -1380,7 +1381,7 @@ class VolumeApiTest(test.TestCase):
attach_tmp = db.volume_attachment_get(context.get_admin_context(),
attachment['id'])
volume_tmp = db.volume_get(context.get_admin_context(), fake.VOLUME_ID)
req = fakes.HTTPRequest.blank('/v2/volumes/%s' % fake.VOLUME_ID)
req = fakes.HTTPRequest.blank('/v3/volumes/%s' % fake.VOLUME_ID)
admin_ctx = context.RequestContext(fake.USER_ID, fake.PROJECT_ID, True)
req.environ['cinder.context'] = admin_ctx
res_dict = self.controller.show(req, fake.VOLUME_ID)
@ -1412,7 +1413,7 @@ class VolumeApiTest(test.TestCase):
self.mock_object(db.sqlalchemy.api, '_volume_type_get_full',
v2_fakes.fake_volume_type_get)
req = fakes.HTTPRequest.blank('/v2/volumes/%s' % fake.VOLUME_ID)
req = fakes.HTTPRequest.blank('/v3/volumes/%s' % fake.VOLUME_ID)
res_dict = self.controller.show(req, fake.VOLUME_ID)
self.assertTrue(res_dict['volume']['encrypted'])
@ -1421,7 +1422,7 @@ class VolumeApiTest(test.TestCase):
self.mock_object(db.sqlalchemy.api, '_volume_type_get_full',
v2_fakes.fake_volume_type_get)
req = fakes.HTTPRequest.blank('/v2/volumes/%s' % fake.VOLUME_ID)
req = fakes.HTTPRequest.blank('/v3/volumes/%s' % fake.VOLUME_ID)
res_dict = self.controller.show(req, fake.VOLUME_ID)
self.assertEqual(False, res_dict['volume']['encrypted'])
@ -1435,14 +1436,14 @@ class VolumeApiTest(test.TestCase):
self.mock_object(db.sqlalchemy.api, '_volume_type_get_full',
v2_fakes.fake_volume_type_get)
req = fakes.HTTPRequest.blank('/v2/volumes/%s' % fake.VOLUME_ID)
req = fakes.HTTPRequest.blank('/v3/volumes/%s' % fake.VOLUME_ID)
res_dict = self.controller.show(req, fake.VOLUME_ID)
self.assertEqual('deleting', res_dict['volume']['status'])
@mock.patch.object(volume_api.API, 'delete', v2_fakes.fake_volume_delete)
@mock.patch.object(volume_api.API, 'get', v2_fakes.fake_volume_get)
def test_volume_delete(self):
req = fakes.HTTPRequest.blank('/v2/volumes/%s' % fake.VOLUME_ID)
req = fakes.HTTPRequest.blank('/v3/volumes/%s' % fake.VOLUME_ID)
resp = self.controller.delete(req, fake.VOLUME_ID)
self.assertEqual(HTTPStatus.ACCEPTED, resp.status_int)
@ -1453,7 +1454,7 @@ class VolumeApiTest(test.TestCase):
self.mock_object(volume_api.API, "delete", fake_volume_attached)
self.mock_object(volume_api.API, 'get', v2_fakes.fake_volume_get)
req = fakes.HTTPRequest.blank('/v2/volumes/%s' % fake.VOLUME_ID)
req = fakes.HTTPRequest.blank('/v3/volumes/%s' % fake.VOLUME_ID)
exp = self.assertRaises(exception.VolumeAttached,
self.controller.delete,
req, 1)
@ -1464,7 +1465,7 @@ class VolumeApiTest(test.TestCase):
self.mock_object(volume_api.API, "get",
v2_fakes.fake_volume_get_notfound)
req = fakes.HTTPRequest.blank('/v2/volumes/%s' % fake.VOLUME_ID)
req = fakes.HTTPRequest.blank('/v3/volumes/%s' % fake.VOLUME_ID)
self.assertRaises(exception.VolumeNotFound, self.controller.delete,
req, 1)
@ -1472,7 +1473,7 @@ class VolumeApiTest(test.TestCase):
self.mock_object(db, 'volume_get_all_by_project',
v2_fakes.fake_volume_get_all_by_project)
req = fakes.HTTPRequest.blank('/v2/%s/volumes' % fake.PROJECT_ID,
req = fakes.HTTPRequest.blank('/v3/%s/volumes' % fake.PROJECT_ID,
use_admin_context=True)
res = self.controller.index(req)
@ -1484,7 +1485,7 @@ class VolumeApiTest(test.TestCase):
v2_fakes.fake_volume_get_all_by_project)
def test_admin_list_volumes_all_tenants(self):
req = fakes.HTTPRequest.blank(
'/v2/%s/volumes?all_tenants=1' % fake.PROJECT_ID,
'/v3/%s/volumes?all_tenants=1' % fake.PROJECT_ID,
use_admin_context=True)
res = self.controller.index(req)
self.assertIn('volumes', res)
@ -1496,7 +1497,7 @@ class VolumeApiTest(test.TestCase):
@mock.patch.object(volume_api.API, 'get', v2_fakes.fake_volume_get)
def test_all_tenants_non_admin_gets_all_tenants(self):
req = fakes.HTTPRequest.blank(
'/v2/%s/volumes?all_tenants=1' % fake.PROJECT_ID)
'/v3/%s/volumes?all_tenants=1' % fake.PROJECT_ID)
res = self.controller.index(req)
self.assertIn('volumes', res)
self.assertEqual(1, len(res['volumes']))
@ -1505,13 +1506,13 @@ class VolumeApiTest(test.TestCase):
v2_fakes.fake_volume_get_all_by_project)
@mock.patch.object(volume_api.API, 'get', v2_fakes.fake_volume_get)
def test_non_admin_get_by_project(self):
req = fakes.HTTPRequest.blank('/v2/%s/volumes' % fake.PROJECT_ID)
req = fakes.HTTPRequest.blank('/v3/%s/volumes' % fake.PROJECT_ID)
res = self.controller.index(req)
self.assertIn('volumes', res)
self.assertEqual(1, len(res['volumes']))
def _create_volume_bad_request(self, body):
req = fakes.HTTPRequest.blank('/v2/%s/volumes' % fake.PROJECT_ID)
req = fakes.HTTPRequest.blank('/v3/%s/volumes' % fake.PROJECT_ID)
req.method = 'POST'
self.assertRaises(exception.ValidationError,

View File

@ -68,7 +68,8 @@ class MessageApiTest(test.TestCase):
self.mock_object(message_api.API, 'get', v3_fakes.fake_message_get)
req = fakes.HTTPRequest.blank(
'/v3/messages/%s' % fakes.FAKE_UUID, version=mv.MESSAGES)
'/v3/fakeproject/messages/%s' % fakes.FAKE_UUID,
version=mv.MESSAGES)
req.environ['cinder.context'] = self.ctxt
res_dict = self.controller.show(req, fakes.FAKE_UUID)
@ -142,7 +143,8 @@ class MessageApiTest(test.TestCase):
self.mock_object(message_api.API, 'get_all',
return_value=[v3_fakes.fake_message(fakes.FAKE_UUID)])
req = fakes.HTTPRequest.blank(
'/v3/messages/%s' % fakes.FAKE_UUID, version=mv.MESSAGES)
'/v3/fakeproject/messages/%s' % fakes.FAKE_UUID,
version=mv.MESSAGES)
req.environ['cinder.context'] = self.ctxt
res_dict = self.controller.index(req)

View File

@ -412,7 +412,7 @@ class VolumeApiTest(test.TestCase):
snapshot_id = fake.SNAPSHOT_ID
ex = self._expected_vol_from_controller(snapshot_id=snapshot_id)
body = {"volume": vol}
req = fakes.HTTPRequest.blank('/v3/volumes')
req = fakes.HTTPRequest.blank('/v3/%s/volumes' % fake.PROJECT_ID)
req.headers = mv.get_mv_header(mv.SUPPORT_NOVA_IMAGE)
req.api_version_request = mv.get_api_version(mv.SUPPORT_NOVA_IMAGE)
res_dict = self.controller.create(req, body=body)
@ -736,7 +736,7 @@ class VolumeApiTest(test.TestCase):
vol = self._vol_in_request_body(snapshot_id=snapshot_id,
group_id=fake.GROUP_ID)
body = {"volume": vol}
req = fakes.HTTPRequest.blank('/v3/volumes')
req = fakes.HTTPRequest.blank('/v3/%s/volumes' % fake.PROJECT_ID)
req.api_version_request = mv.get_api_version(max_ver)
res_dict = self.controller.create(req, body=body)
ex = self._expected_vol_from_controller(
@ -771,7 +771,7 @@ class VolumeApiTest(test.TestCase):
volume_type_get.side_effect = v2_fakes.fake_volume_type_get
backup_id = fake.BACKUP_ID
req = fakes.HTTPRequest.blank('/v3/volumes')
req = fakes.HTTPRequest.blank('/v3/%s/volumes' % fake.PROJECT_ID)
req.api_version_request = mv.get_api_version(max_ver)
if max_ver == mv.VOLUME_CREATE_FROM_BACKUP:
vol = self._vol_in_request_body(backup_id=backup_id)

View File

@ -57,3 +57,5 @@ def set_defaults(conf):
# This is where we don't authenticate
conf.set_default('auth_strategy', 'noauth')
conf.set_default('auth_url', 'fake', 'keystone_authtoken')
# we use "fake" and "openstack" as project ID in a number of tests
conf.set_default('project_id_regex', r"[0-9a-fopnstk\-]+")

View File

@ -469,7 +469,7 @@ class MessageApiTest(test.TestCase):
self.create_message_for_tests()
# first request of this test
url = '/v3/fake/messages?limit=2'
url = '/v3/%s/messages?limit=2' % fake_constants.PROJECT_ID
req = fakes.HTTPRequest.blank(url)
req.method = 'GET'
req.content_type = 'application/json'
@ -489,8 +489,8 @@ class MessageApiTest(test.TestCase):
# Second request in this test
# Test for second page using marker (res['messages][0]['id'])
# values fetched in first request with limit 2 in this test
url = '/v3/fake/messages?limit=1&marker=%s' % (
res['messages'][0]['id'])
url = '/v3/%s/messages?limit=1&marker=%s' % (
fake_constants.PROJECT_ID, res['messages'][0]['id'])
req = fakes.HTTPRequest.blank(url)
req.method = 'GET'
req.content_type = 'application/json'

View File

@ -10,6 +10,7 @@ use = call:cinder.api:root_app_factory
[composite:openstack_volume_api_v3]
use = call:cinder.api.middleware.auth:pipeline_factory
noauth = cors http_proxy_to_wsgi request_id faultwrap sizelimit osprofiler noauth apiv3
noauth_include_project_id = cors http_proxy_to_wsgi request_id faultwrap sizelimit osprofiler noauth_include_project_id apiv3
keystone = cors http_proxy_to_wsgi request_id faultwrap sizelimit osprofiler authtoken keystonecontext apiv3
keystone_nolimit = cors http_proxy_to_wsgi request_id faultwrap sizelimit osprofiler authtoken keystonecontext apiv3
@ -32,6 +33,9 @@ paste.filter_factory = osprofiler.web:WsgiMiddleware.factory
[filter:noauth]
paste.filter_factory = cinder.api.middleware.auth:NoAuthMiddleware.factory
[filter:noauth_include_project_id]
paste.filter_factory = cinder.api.middleware.auth:NoAuthMiddlewareIncludeProjectID.factory
[filter:sizelimit]
paste.filter_factory = oslo_middleware.sizelimit:RequestBodySizeLimiter.factory

View File

@ -11,6 +11,7 @@ use = call:cinder.api:root_app_factory
[composite:openstack_volume_api_v3]
use = call:cinder.api.middleware.auth:pipeline_factory
noauth = cors http_proxy_to_wsgi request_id faultwrap sizelimit osprofiler noauth apiv3
noauth_include_project_id = cors http_proxy_to_wsgi request_id faultwrap sizelimit osprofiler noauth_include_project_id apiv3
keystone = cors http_proxy_to_wsgi request_id faultwrap sizelimit osprofiler authtoken keystonecontext apiv3
keystone_nolimit = cors http_proxy_to_wsgi request_id faultwrap sizelimit osprofiler authtoken keystonecontext apiv3
@ -33,6 +34,9 @@ paste.filter_factory = osprofiler.web:WsgiMiddleware.factory
[filter:noauth]
paste.filter_factory = cinder.api.middleware.auth:NoAuthMiddleware.factory
[filter:noauth_include_project_id]
paste.filter_factory = cinder.api.middleware.auth:NoAuthMiddlewareIncludeProjectID.factory
[filter:sizelimit]
paste.filter_factory = oslo_middleware.sizelimit:RequestBodySizeLimiter.factory

View File

@ -0,0 +1,25 @@
---
features:
- |
Inclusion of a project_id in API URLs is now optional. The `Block Storage
API V3 <https://docs.openstack.org/api-ref/block-storage/v3>`_ reference
guide continues to show URLs with a project_id because the legacy behavior
continues to be supported.
A new API microversion V3.67 is introduced to inform clients when
inclusion of a project_id in API URLs is optional. The V3.67 microversion
is only used as an indicator that the API accepts a URL without a
project_id, and this applies to all requests regardless of the
microversion in the request. For example, an API node serving V3.67 or
greater will accept a URL without a project_id even if the request asks
for V3.0. Likewise, it will accept a URL containing a project_id even if
the request asks for V3.67.
upgrade:
- |
Upgrades are not affected by the new functionality whereby a project_id
is no longer required in API URLs. The legacy behavior in which a
project_id is included in the URL continues to be supported.
Detection of whether a URL includes a project_id is based on the value of
a new ``project_id_regex`` option. The default value matches UUIDs
created by keystone.