Add support for version ranges

Just wanting "latest" isn't the full picture. A client could support,
say, v1 and v2 of an API but not v3 and would like to find an
appropriate matching endpoint.

Add two new arguments, min_version and max_version, rather than
repurpose the version argument.

This changes the behavior of versioned_data_for and versioned_url_for in
the case where version=None. Before that would return None, now it
returns the information about the endpoint that was in the catalog.

The booleans in this are a bit hard to read, as are the fun times with
latest and things being or not being defined. It's time to make the
versions into objects, but we'll do that as a followup.

Change-Id: I8ba948a712002775098b0a86c70f05e0c68250f5
This commit is contained in:
Monty Taylor 2017-05-29 20:24:52 -05:00
parent 432f17778e
commit d658f84a0f
No known key found for this signature in database
GPG Key ID: 7BAE94BC7141A594
4 changed files with 361 additions and 57 deletions

View File

@ -113,11 +113,63 @@ def normalize_version_number(version):
raise TypeError('Invalid version specified: %s' % version)
def _normalize_version_args(version, min_version, max_version):
if version and (min_version or max_version):
raise TypeError(
"version is mutually exclusive with min_version and"
" max_version")
if min_version == 'latest' and max_version not in (
None, 'latest'):
raise TypeError(
"min_version is 'latest' and max_version is {max_version}"
" but is only allowed to be 'latest' or None".format(
max_version=max_version))
if version and version != 'latest':
version = normalize_version_number(version)
if min_version:
if min_version == 'latest':
min_version = None
max_version = 'latest'
else:
min_version = normalize_version_number(min_version)
if max_version and max_version != 'latest':
max_version = normalize_version_number(max_version)
return version, min_version, max_version
def version_to_string(version):
"""Turn a version tuple into a string."""
return ".".join([str(x) for x in version])
def version_between(min_version, max_version, candidate):
# A version can't be between a range that doesn't exist
if not min_version and not max_version:
return False
# If the candidate is less than the min_version, it's
# not a match.
if min_version:
min_version = normalize_version_number(min_version)
if candidate < min_version:
return False
# Lack of max_version implies latest.
if max_version == 'latest' or not max_version:
return True
max_version = normalize_version_number(max_version)
if version_match(max_version, candidate):
return True
if max_version < candidate:
return False
return True
def version_match(required, candidate):
"""Test that an available version satisfies the required version.
@ -324,8 +376,13 @@ class Discover(object):
data = self.data_for(version, **kwargs)
return data['url'] if data else None
def versioned_data_for(self, version=None, url=None, **kwargs):
"""Return endpoint data that matches the version or url.
def versioned_data_for(self, version=None, url=None,
min_version=None, max_version=None,
**kwargs):
"""Return endpoint data for the service at a url.
version, min_version and max_version can all be given either as a
string or a tuple.
:param version: The version is the minimum version in the
same major release as there should be no compatibility issues with
@ -333,42 +390,83 @@ class Discover(object):
given, the highest available version will be matched.
:param string url: If url is given, the data will be returned for the
endpoint data that has a self link matching the url.
:param min_version: The minimum version that is acceptable. Mutually
exclusive with version. If min_version is given with no max_version
it is as if max version is 'latest'. If min_version is 'latest',
max_version may only be 'latest' or None.
:param max_version: The maximum version that is acceptable. Mutually
exclusive with version. If min_version is given with no max_version
it is as if max version is 'latest'. If min_version is 'latest',
max_version may only be 'latest' or None.
:returns: the endpoint data for a URL that matches the required version
(the format is described in version_data) or None if no
match.
:rtype: dict
"""
version, min_version, max_version = _normalize_version_args(
version, min_version, max_version)
no_version = not version and not max_version and not min_version
version_data = self.version_data(reverse=True, **kwargs)
if version == 'latest':
# If we don't have to check a min_version, we can short
# circuit anything else
if 'latest' in (version, max_version) and not min_version:
# because we reverse we can just take the first entry
return version_data[0]
if version:
version = normalize_version_number(version)
if url:
url = url.rstrip('/') + '/'
for data in self.version_data(reverse=True, **kwargs):
if no_version and not url:
# because we reverse we can just take the first entry
return version_data[0]
# Version data is in order from highest to lowest, so we return
# the first matching entry
for data in version_data:
if url and data['url'] and data['url'].rstrip('/') + '/' == url:
return data
if version and version_match(version, data['version']):
return data
if version_between(min_version, max_version, data['version']):
return data
# If there is no version requested and we could not find a matching
# url in the discovery doc, that means we've got an unversioned
# endpoint in the catalog and the user is requesting version data
# so that they know what version they got. We can return the first
# entry from version_data, because the user hasn't requested anything
# different.
if no_version and url:
return version_data[0]
# We couldn't find a match.
return None
def versioned_url_for(self, version, **kwargs):
def versioned_url_for(self, version=None,
min_version=None, max_version=None, **kwargs):
"""Get the endpoint url for a version.
:param tuple version: The version is always a minimum version in the
version, min_version and max_version can all be given either as a
string or a tuple.
:param version: The version is always a minimum version in the
same major release as there should be no compatibility issues with
using a version newer than the one asked for.
:param min_version: The minimum version that is acceptable. Mutually
exclusive with version. If min_version is given with no max_version
it is as if max version is 'latest'.
:param max_version: The maximum version that is acceptable. Mutually
exclusive with version. If min_version is given with no max_version
it is as if max version is 'latest'.
:returns: The url for the specified version or None if no match.
:rtype: str
"""
data = self.versioned_data_for(version, **kwargs)
data = self.versioned_data_for(version, min_version=min_version,
max_version=max_version, **kwargs)
return data['url'] if data else None
@ -436,19 +534,23 @@ class EndpointData(object):
return self.service_url or self.catalog_url
@positional(3)
def get_versioned_data(self, session, version,
def get_versioned_data(self, session, version=None,
authenticated=False, allow=None, cache=None,
allow_version_hack=True, project_id=None,
discover_versions=False):
discover_versions=False,
min_version=None, max_version=None):
"""Run version discovery for the service described.
Performs Version Discovery and returns a new EndpointData object with
information found.
version, min_version and max_version can all be given either as a
string or a tuple.
:param session: A session object that can be used for communication.
:type session: keystoneauth1.session.Session
:param tuple version: The minimum major version required for this
endpoint.
:param version: The minimum major version required for this endpoint.
Mutually exclusive with min_version and max_version.
:param string project_id: ID of the currently scoped project. Used for
removing project_id components of URLs from
the catalog. (optional)
@ -466,39 +568,48 @@ class EndpointData(object):
even if a version string wasn't
requested. This is useful for getting
microversion information.
:param min_version: The minimum version that is acceptable. Mutually
exclusive with version. If min_version is given
with no max_version it is as if max version is
'latest'.
:param max_version: The maximum version that is acceptable. Mutually
exclusive with version. If min_version is given
with no max_version it is as if max version is
'latest'.
:raises keystoneauth1.exceptions.http.HttpError: An error from an
invalid HTTP response.
"""
version, min_version, max_version = _normalize_version_args(
version, min_version, max_version)
if not allow:
allow = {}
# This method should always return a new EndpointData
new_data = copy.copy(self)
if not version and not discover_versions:
# NOTE(jamielennox): This may not be the best thing to default to
# but is here for backwards compatibility. It may be worth
# defaulting to the most recent version.
return new_data
new_data._set_version_info(
session=session, version=version, authenticated=authenticated,
allow=allow, cache=cache, allow_version_hack=allow_version_hack,
project_id=project_id, discover_versions=discover_versions)
project_id=project_id, discover_versions=discover_versions,
min_version=min_version, max_version=max_version)
return new_data
def _set_version_info(self, session, version,
authenticated=False, allow=None, cache=None,
allow_version_hack=True, project_id=None,
discover_versions=False):
discover_versions=False,
min_version=None, max_version=None):
match_url = None
if not version and not discover_versions:
no_version = not version and not max_version and not min_version
if no_version and not discover_versions:
# NOTE(jamielennox): This may not be the best thing to default to
# but is here for backwards compatibility. It may be worth
# defaulting to the most recent version.
return
elif not version and discover_versions:
elif no_version and discover_versions:
# We want to run discovery, but we don't want to find different
# endpoints than what's in the catalog
allow_version_hack = False
@ -510,8 +621,11 @@ class EndpointData(object):
disc = None
vers_url = None
tried = set()
for vers_url in self._get_url_choices(version, project_id,
allow_version_hack):
for vers_url in self._get_discovery_url_choices(
version=version, project_id=project_id,
allow_version_hack=allow_version_hack,
min_version=min_version,
max_version=max_version):
if vers_url in tried:
continue
@ -557,24 +671,40 @@ class EndpointData(object):
"Version requested but version discovery document was not"
" found and allow_version_hack was False")
# NOTE(jamielennox): urljoin allows the url to be relative or even
# protocol-less. The additional trailing '/' make urljoin respect
# the current path as canonical even if the url doesn't include it.
# for example a "v2" path from http://host/admin should resolve as
# http://host/admin/v2 where it would otherwise be host/v2.
# This has no effect on absolute urls returned from url_for.
discovered_data = disc.versioned_data_for(
version, url=match_url, **allow)
version, min_version=min_version, max_version=max_version,
url=match_url, **allow)
if not discovered_data:
raise exceptions.DiscoveryFailure(
"Version {version} requested, but was not found".format(
version=version_to_string(version)))
if version:
raise exceptions.DiscoveryFailure(
"Version {version} requested, but was not found".format(
version=version_to_string(version)))
elif min_version and not max_version:
raise exceptions.DiscoveryFailure(
"Minimum version {min_version} was not found".format(
min_version=version_to_string(min_version)))
elif max_version and not min_version:
raise exceptions.DiscoveryFailure(
"Maximum version {max_version} was not found".format(
max_version=version_to_string(max_version)))
elif min_version and max_version:
raise exceptions.DiscoveryFailure(
"No version found between {min_version}"
" and {max_version}".format(
min_version=version_to_string(min_version),
max_version=version_to_string(max_version)))
self.min_microversion = discovered_data['min_microversion']
self.max_microversion = discovered_data['max_microversion']
discovered_url = discovered_data['url']
# NOTE(jamielennox): urljoin allows the url to be relative or even
# protocol-less. The additional trailing '/' make urljoin respect
# the current path as canonical even if the url doesn't include it.
# for example a "v2" path from http://host/admin should resolve as
# http://host/admin/v2 where it would otherwise be host/v2.
# This has no effect on absolute urls returned from url_for.
url = urllib.parse.urljoin(vers_url.rstrip('/') + '/', discovered_url)
# If we had to pop a project_id from the catalog_url, put it back on
@ -583,7 +713,14 @@ class EndpointData(object):
self._saved_project_id)
self.service_url = url
def _get_url_choices(self, version, project_id, allow_version_hack=True):
def _get_discovery_url_choices(
self, version=None, project_id=None, allow_version_hack=True,
min_version=None, max_version=None):
"""Find potential locations for version discovery URLs.
version, min_version and max_version are already normalized, so will
either be None, 'latest' or a tuple.
"""
if allow_version_hack:
url = urllib.parse.urlparse(self.url)
url_parts = url.path.split('/')
@ -593,30 +730,51 @@ class EndpointData(object):
if project_id and url_parts[-1].endswith(project_id):
self._saved_project_id = url_parts.pop()
catalog_discovery = None
# Next, check to see if the url indicates a version and if that
# version matches our request. If so, we can start by trying
# the given url as it has a high potential for success
url_version = None
if url_parts[-1].startswith('v'):
try:
url_version = normalize_version_number(url_parts[-1])
except TypeError:
pass
if url_version:
if version_match(version, url_version):
# version either matches our version request or is withing the
# range requested. If so, we can start by trying the given url
# as it has a high potential for success.
try:
url_version = normalize_version_number(url_parts[-1])
except TypeError:
pass
else:
is_between = version_between(
min_version, max_version, url_version)
exact_match = (version and version != 'latest'
and version_match(version, url_version))
high_match = (is_between and max_version
and max_version != 'latest' and version_match(
max_version, url_version))
if exact_match or is_between:
self._catalog_matches_version = True
# This endpoint matches the version request, try it first
yield urllib.parse.ParseResult(
# The endpoint from the catalog matches the version request
# We construct a URL minus any project_id, but we don't
# return it just yet. It's a good option, but unless we
# have an exact match or match the max requested, we want
# to try for an unversioned endpoint first.
catalog_discovery = urllib.parse.ParseResult(
url.scheme,
url.netloc,
'/'.join(url_parts),
url.params,
url.query,
url.fragment).geturl()
# If we found a viable catalog endpoint and it's
# an exact match or matches the max, go ahead and give
# it a go.
if catalog_discovery and (high_match or exact_match):
yield catalog_discovery
catalog_discovery = None
url_parts.pop()
# If there were projects or versions in the url they are now gone.
# That means we're left with the unversioned url
# That means we're left with what should be the unversioned url.
yield urllib.parse.ParseResult(
url.scheme,
url.netloc,
@ -625,14 +783,22 @@ class EndpointData(object):
url.query,
url.fragment).geturl()
# If we have a catalog discovery url, it either means we didn't
# return it earlier because it wasn't an exact enough match, or
# that we did and it failed. We don't double-request things when
# consuming this, so it's safe to return it here in case we didn't
# already return it.
if catalog_discovery:
yield catalog_discovery
# NOTE(mordred): For backwards compatibility people might have
# added version hacks using the version hack system. The logic
# above should handle most cases, so by the time we get here it's
# most likely to be a no-op
yield self._get_catalog_discover_hack()
# As a final fallthrough case, add the url from the catalog. If hacks
# are turned off, this will be the only choice.
# As a final fallthrough case, return the actual unmodified url from
# the catalog. If hacks are turned off, this will be the only choice.
yield self.catalog_url
def _get_catalog_discover_hack(self):

View File

@ -160,12 +160,16 @@ class BaseIdentityPlugin(plugin.BaseAuthPlugin):
region_name=None, service_name=None, version=None,
allow={}, allow_version_hack=True,
discover_versions=False, skip_discovery=False,
min_version=None, max_version=None,
**kwargs):
"""Return a valid endpoint data for a service.
If a valid token is not present then a new one will be fetched using
the session and kwargs.
version, min_version and max_version can all be given either as a
string or a tuple.
:param session: A session object that can be used for communication.
:type session: keystoneauth1.session.Session
:param string service_type: The type of service to lookup the endpoint
@ -180,7 +184,7 @@ class BaseIdentityPlugin(plugin.BaseAuthPlugin):
(optional)
:param string service_name: The name of the service in the catalog.
(optional)
:param tuple version: The minimum version number required for this
:param version: The minimum version number required for this
endpoint. (optional)
:param dict allow: Extra filters to pass when discovering API
versions. (optional)
@ -196,6 +200,14 @@ class BaseIdentityPlugin(plugin.BaseAuthPlugin):
if endpoint_override or similar has been
given and grabbing additional information
about the endpoint is not useful.
:param min_version: The minimum version that is acceptable. Mutually
exclusive with version. If min_version is given
with no max_version it is as if max version is
'latest'. (optional)
:param max_version: The maximum version that is acceptable. Mutually
exclusive with version. If min_version is given
with no max_version it is as if max version is
'latest'. (optional)
:raises keystoneauth1.exceptions.http.HttpError: An error from an
invalid HTTP response.
@ -244,6 +256,8 @@ class BaseIdentityPlugin(plugin.BaseAuthPlugin):
return endpoint_data.get_versioned_data(
session, version,
project_id=project_id,
min_version=min_version,
max_version=max_version,
authenticated=False,
cache=self._discovery_cache,
discover_versions=discover_versions,
@ -253,7 +267,7 @@ class BaseIdentityPlugin(plugin.BaseAuthPlugin):
exceptions.ConnectionError):
# If a version was requested, we didn't find it, return
# None.
if version:
if version or max_version or min_version:
return None
# If one wasn't, then the endpoint_data we already have
# should be fine
@ -262,12 +276,17 @@ class BaseIdentityPlugin(plugin.BaseAuthPlugin):
def get_endpoint(self, session, service_type=None, interface=None,
region_name=None, service_name=None, version=None,
allow={}, allow_version_hack=True,
discover_versions=False, skip_discovery=False, **kwargs):
discover_versions=False, skip_discovery=False,
min_version=None, max_version=None,
**kwargs):
"""Return a valid endpoint for a service.
If a valid token is not present then a new one will be fetched using
the session and kwargs.
version, min_version and max_version can all be given either as a
string or a tuple.
:param session: A session object that can be used for communication.
:type session: keystoneauth1.session.Session
:param string service_type: The type of service to lookup the endpoint
@ -282,8 +301,8 @@ class BaseIdentityPlugin(plugin.BaseAuthPlugin):
(optional)
:param string service_name: The name of the service in the catalog.
(optional)
:param tuple version: The minimum version number required for this
endpoint. (optional)
:param version: The minimum version number required for this
endpoint. (optional)
:param dict allow: Extra filters to pass when discovering API
versions. (optional)
:param bool allow_version_hack: Allow keystoneauth to hack up catalog
@ -298,6 +317,14 @@ class BaseIdentityPlugin(plugin.BaseAuthPlugin):
if endpoint_override or similar has been
given and grabbing additional information
about the endpoint is not useful.
:param min_version: The minimum version that is acceptable. Mutually
exclusive with version. If min_version is given
with no max_version it is as if max version is
'latest'. (optional)
:param max_version: The maximum version that is acceptable. Mutually
exclusive with version. If min_version is given
with no max_version it is as if max version is
'latest'. (optional)
:raises keystoneauth1.exceptions.http.HttpError: An error from an
invalid HTTP response.
@ -309,6 +336,8 @@ class BaseIdentityPlugin(plugin.BaseAuthPlugin):
session, service_type=service_type, interface=interface,
region_name=region_name, service_name=service_name,
version=version, allow=allow,
min_version=min_version,
max_version=max_version,
discover_versions=discover_versions,
skip_discovery=skip_discovery,
allow_version_hack=allow_version_hack, **kwargs)

View File

@ -1079,6 +1079,112 @@ class CatalogHackTests(utils.TestCase):
# And get the v3 url
self.assertEqual(self.V3_URL, endpoint)
# Make sure latest logic works for min and max version
endpoint = sess.get_endpoint(service_type=self.IDENTITY,
max_version='latest')
self.assertEqual(self.V3_URL, endpoint)
endpoint = sess.get_endpoint(service_type=self.IDENTITY,
min_version='latest')
self.assertEqual(self.V3_URL, endpoint)
endpoint = sess.get_endpoint(service_type=self.IDENTITY,
min_version='latest',
max_version='latest')
self.assertEqual(self.V3_URL, endpoint)
self.assertRaises(TypeError, sess.get_endpoint,
service_type=self.IDENTITY,
min_version='latest', max_version='3.0')
def test_version_range(self):
v2_disc = fixture.V2Discovery(self.V2_URL)
common_disc = fixture.DiscoveryList(href=self.BASE_URL)
def stub_urls():
v2_m = self.stub_url('GET',
['v2.0'],
base_url=self.BASE_URL,
status_code=200,
json={'version': v2_disc})
common_m = self.stub_url('GET',
base_url=self.BASE_URL,
status_code=200,
json=common_disc)
return v2_m, common_m
v2_m, common_m = stub_urls()
token = fixture.V2Token()
service = token.add_service(self.IDENTITY)
service.add_endpoint(public=self.V2_URL,
admin=self.V2_URL,
internal=self.V2_URL)
self.stub_url('POST',
['tokens'],
base_url=self.V2_URL,
json=token)
v2_auth = identity.V2Password(self.V2_URL,
username=uuid.uuid4().hex,
password=uuid.uuid4().hex)
sess = session.Session(auth=v2_auth)
# v2 auth with v2 url doesn't make any discovery calls.
self.assertFalse(v2_m.called)
endpoint = sess.get_endpoint(service_type=self.IDENTITY,
min_version='2.0', max_version='3.0')
# We should make the one call
self.assertFalse(v2_m.called)
self.assertTrue(common_m.called)
# And get the v3 url
self.assertEqual(self.V3_URL, endpoint)
v2_m, common_m = stub_urls()
endpoint = sess.get_endpoint(service_type=self.IDENTITY,
min_version='1', max_version='2')
# We should make one more calls
# TODO(mordred) optimize this - we can peek in the cache
self.assertTrue(v2_m.called)
self.assertFalse(common_m.called)
# And get the v2 url
self.assertEqual(self.V2_URL, endpoint)
v2_m, common_m = stub_urls()
endpoint = sess.get_endpoint(service_type=self.IDENTITY,
min_version='4')
# We should make no more calls
self.assertFalse(v2_m.called)
self.assertFalse(common_m.called)
# And get no url
self.assertEqual(None, endpoint)
v2_m, common_m = stub_urls()
endpoint = sess.get_endpoint(service_type=self.IDENTITY,
min_version='2')
# We should make no more calls
self.assertFalse(v2_m.called)
self.assertFalse(common_m.called)
# And get the v3 url
self.assertEqual(self.V3_URL, endpoint)
v2_m, common_m = stub_urls()
self.assertRaises(TypeError, sess.get_endpoint,
service_type=self.IDENTITY, version=3,
min_version='2')
# We should make no more calls
self.assertFalse(v2_m.called)
self.assertFalse(common_m.called)
def test_getting_endpoints_on_auth_interface(self):
disc = fixture.DiscoveryList(href=self.BASE_URL)
self.stub_url('GET',

View File

@ -380,7 +380,10 @@ class VersionDataTests(utils.TestCase):
disc = discover.Discover(self.session, V3_URL)
self.assertIsNone(disc.versioned_data_for(version=None))
data = disc.versioned_data_for(version=None)
self.assertEqual(data['version'], (3, 0))
self.assertEqual(data['raw_status'], 'stable')
self.assertEqual(data['url'], V3_URL)
self.assertRaises(TypeError, disc.data_for, version=None)
self.assertTrue(mock.called_once)