f21def7061
The positional library was spun directly out of what keystoneauth1 was using so this is a fairly trivial change. Change-Id: I7931ed1547d2a05e2d248bc3240a576dc68a0a40
359 lines
12 KiB
Python
359 lines
12 KiB
Python
# 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.
|
|
|
|
"""The passive components to version discovery.
|
|
|
|
The Discover object in discover.py contains functions that can create objects
|
|
on your behalf. These functions are not usable from within the keystoneauth1
|
|
library because you will get dependency resolution issues.
|
|
|
|
The Discover object in this file provides the querying components of Discovery.
|
|
This includes functions like url_for which allow you to retrieve URLs and the
|
|
raw data specified in version discovery responses.
|
|
"""
|
|
|
|
import re
|
|
|
|
from positional import positional
|
|
|
|
from keystoneauth1 import _utils as utils
|
|
from keystoneauth1 import exceptions
|
|
|
|
|
|
_LOGGER = utils.get_logger(__name__)
|
|
|
|
|
|
@positional()
|
|
def get_version_data(session, url, authenticated=None):
|
|
"""Retrieve raw version data from a url."""
|
|
headers = {'Accept': 'application/json'}
|
|
|
|
resp = session.get(url, headers=headers, authenticated=authenticated)
|
|
|
|
try:
|
|
body_resp = resp.json()
|
|
except ValueError:
|
|
pass
|
|
else:
|
|
# In the event of querying a root URL we will get back a list of
|
|
# available versions.
|
|
try:
|
|
return body_resp['versions']['values']
|
|
except (KeyError, TypeError):
|
|
pass
|
|
|
|
# Most servers don't have a 'values' element so accept a simple
|
|
# versions dict if available.
|
|
try:
|
|
return body_resp['versions']
|
|
except KeyError:
|
|
pass
|
|
|
|
# Otherwise if we query an endpoint like /v2.0 then we will get back
|
|
# just the one available version.
|
|
try:
|
|
return [body_resp['version']]
|
|
except KeyError:
|
|
pass
|
|
|
|
err_text = resp.text[:50] + '...' if len(resp.text) > 50 else resp.text
|
|
raise exceptions.DiscoveryFailure('Invalid Response - Bad version data '
|
|
'returned: %s' % err_text)
|
|
|
|
|
|
def normalize_version_number(version):
|
|
"""Turn a version representation into a tuple."""
|
|
|
|
# trim the v from a 'v2.0' or similar
|
|
try:
|
|
version = version.lstrip('v')
|
|
except AttributeError:
|
|
pass
|
|
|
|
# if it's an integer or a numeric as a string then normalize it
|
|
# to a string, this ensures 1 decimal point
|
|
try:
|
|
num = float(version)
|
|
except Exception:
|
|
pass
|
|
else:
|
|
version = str(num)
|
|
|
|
# if it's a string (or an integer) from above break it on .
|
|
try:
|
|
return tuple(map(int, version.split('.')))
|
|
except Exception:
|
|
pass
|
|
|
|
# last attempt, maybe it's a list or iterable.
|
|
try:
|
|
return tuple(map(int, version))
|
|
except Exception:
|
|
pass
|
|
|
|
raise TypeError('Invalid version specified: %s' % version)
|
|
|
|
|
|
def version_match(required, candidate):
|
|
"""Test that an available version satisfies the required version.
|
|
|
|
To be suitable a version must be of the same major version as required
|
|
and be at least a match in minor/patch level.
|
|
|
|
eg. 3.3 is a match for a required 3.1 but 4.1 is not.
|
|
|
|
:param tuple required: the version that must be met.
|
|
:param tuple candidate: the version to test against required.
|
|
|
|
:returns: True if candidate is suitable False otherwise.
|
|
:rtype: bool
|
|
"""
|
|
# major versions must be the same (e.g. even though v2 is a lower
|
|
# version than v3 we can't use it if v2 was requested)
|
|
if candidate[0] != required[0]:
|
|
return False
|
|
|
|
# prevent selecting a minor version less than what is required
|
|
if candidate < required:
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
class Discover(object):
|
|
|
|
CURRENT_STATUSES = ('stable', 'current', 'supported')
|
|
DEPRECATED_STATUSES = ('deprecated',)
|
|
EXPERIMENTAL_STATUSES = ('experimental',)
|
|
|
|
@positional()
|
|
def __init__(self, session, url, authenticated=None):
|
|
self._data = get_version_data(session, url,
|
|
authenticated=authenticated)
|
|
|
|
def raw_version_data(self, allow_experimental=False,
|
|
allow_deprecated=True, allow_unknown=False):
|
|
"""Get raw version information from URL.
|
|
|
|
Raw data indicates that only minimal validation processing is performed
|
|
on the data, so what is returned here will be the data in the same
|
|
format it was received from the endpoint.
|
|
|
|
:param bool allow_experimental: Allow experimental version endpoints.
|
|
:param bool allow_deprecated: Allow deprecated version endpoints.
|
|
:param bool allow_unknown: Allow endpoints with an unrecognised status.
|
|
|
|
:returns: The endpoints returned from the server that match the
|
|
criteria.
|
|
:rtype: list
|
|
"""
|
|
versions = []
|
|
for v in self._data:
|
|
try:
|
|
status = v['status']
|
|
except KeyError:
|
|
_LOGGER.warning('Skipping over invalid version data. '
|
|
'No stability status in version.')
|
|
continue
|
|
|
|
status = status.lower()
|
|
|
|
if status in self.CURRENT_STATUSES:
|
|
versions.append(v)
|
|
elif status in self.DEPRECATED_STATUSES:
|
|
if allow_deprecated:
|
|
versions.append(v)
|
|
elif status in self.EXPERIMENTAL_STATUSES:
|
|
if allow_experimental:
|
|
versions.append(v)
|
|
elif allow_unknown:
|
|
versions.append(v)
|
|
|
|
return versions
|
|
|
|
@positional()
|
|
def version_data(self, reverse=False, **kwargs):
|
|
"""Get normalized version data.
|
|
|
|
Return version data in a structured way.
|
|
|
|
:param bool reverse: Reverse the list. reverse=true will mean the
|
|
returned list is sorted from newest to oldest
|
|
version.
|
|
:returns: A list of version data dictionaries sorted by version number.
|
|
Each data element in the returned list is a dictionary
|
|
consisting of at least:
|
|
|
|
:version tuple: The normalized version of the endpoint.
|
|
:url str: The url for the endpoint.
|
|
:raw_status str: The status as provided by the server
|
|
:rtype: list(dict)
|
|
"""
|
|
data = self.raw_version_data(**kwargs)
|
|
versions = []
|
|
|
|
for v in data:
|
|
try:
|
|
version_str = v['id']
|
|
except KeyError:
|
|
_LOGGER.info('Skipping invalid version data. Missing ID.')
|
|
continue
|
|
|
|
try:
|
|
links = v['links']
|
|
except KeyError:
|
|
_LOGGER.info('Skipping invalid version data. Missing links')
|
|
continue
|
|
|
|
version_number = normalize_version_number(version_str)
|
|
|
|
for link in links:
|
|
try:
|
|
rel = link['rel']
|
|
url = link['href']
|
|
except (KeyError, TypeError):
|
|
_LOGGER.info('Skipping invalid version link. '
|
|
'Missing link URL or relationship.')
|
|
continue
|
|
|
|
if rel.lower() == 'self':
|
|
break
|
|
else:
|
|
_LOGGER.info('Skipping invalid version data. '
|
|
'Missing link to endpoint.')
|
|
continue
|
|
|
|
versions.append({'version': version_number,
|
|
'url': url,
|
|
'raw_status': v['status']})
|
|
|
|
versions.sort(key=lambda v: v['version'], reverse=reverse)
|
|
return versions
|
|
|
|
def data_for(self, version, **kwargs):
|
|
"""Return endpoint data for a version.
|
|
|
|
:param tuple 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.
|
|
|
|
: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 = normalize_version_number(version)
|
|
|
|
for data in self.version_data(reverse=True, **kwargs):
|
|
if version_match(version, data['version']):
|
|
return data
|
|
|
|
return None
|
|
|
|
def url_for(self, version, **kwargs):
|
|
"""Get the endpoint url for a version.
|
|
|
|
:param tuple 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.
|
|
|
|
:returns: The url for the specified version or None if no match.
|
|
:rtype: str
|
|
"""
|
|
data = self.data_for(version, **kwargs)
|
|
return data['url'] if data else None
|
|
|
|
|
|
class _VersionHacks(object):
|
|
"""A container to abstract the list of version hacks.
|
|
|
|
This could be done as simply a dictionary but is abstracted like this to
|
|
make for easier testing.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self._discovery_data = {}
|
|
|
|
def add_discover_hack(self, service_type, old, new=''):
|
|
"""Add a new hack for a service type.
|
|
|
|
:param str service_type: The service_type in the catalog.
|
|
:param re.RegexObject old: The pattern to use.
|
|
:param str new: What to replace the pattern with.
|
|
"""
|
|
hacks = self._discovery_data.setdefault(service_type, [])
|
|
hacks.append((old, new))
|
|
|
|
def get_discover_hack(self, service_type, url):
|
|
"""Apply the catalog hacks and figure out an unversioned endpoint.
|
|
|
|
:param str service_type: the service_type to look up.
|
|
:param str url: The original url that came from a service_catalog.
|
|
|
|
:returns: Either the unversioned url or the one from the catalog
|
|
to try.
|
|
"""
|
|
for old, new in self._discovery_data.get(service_type, []):
|
|
new_string, number_of_subs_made = old.subn(new, url)
|
|
if number_of_subs_made > 0:
|
|
return new_string
|
|
|
|
return url
|
|
|
|
|
|
_VERSION_HACKS = _VersionHacks()
|
|
_VERSION_HACKS.add_discover_hack('identity', re.compile('/v2.0/?$'), '/')
|
|
|
|
|
|
def _get_catalog_discover_hack(service_type, url):
|
|
"""Apply the catalog hacks and figure out an unversioned endpoint.
|
|
|
|
This function is internal to keystoneauth1.
|
|
|
|
:param str service_type: the service_type to look up.
|
|
:param str url: The original url that came from a service_catalog.
|
|
|
|
:returns: Either the unversioned url or the one from the catalog to try.
|
|
"""
|
|
return _VERSION_HACKS.get_discover_hack(service_type, url)
|
|
|
|
|
|
def add_catalog_discover_hack(service_type, old, new):
|
|
"""Adds a version removal rule for a particular service.
|
|
|
|
Originally deployments of OpenStack would contain a versioned endpoint in
|
|
the catalog for different services. E.g. an identity service might look
|
|
like ``http://localhost:5000/v2.0``. This is a problem when we want to use
|
|
a different version like v3.0 as there is no way to tell where it is
|
|
located. We cannot simply change all service catalogs either so there must
|
|
be a way to handle the older style of catalog.
|
|
|
|
This function adds a rule for a given service type that if part of the URL
|
|
matches a given regular expression in *old* then it will be replaced with
|
|
the *new* value. This will replace all instances of old with new. It should
|
|
therefore contain a regex anchor.
|
|
|
|
For example the included rule states::
|
|
|
|
add_catalog_version_hack('identity', re.compile('/v2.0/?$'), '/')
|
|
|
|
so if the catalog retrieves an *identity* URL that ends with /v2.0 or
|
|
/v2.0/ then it should replace it simply with / to fix the user's catalog.
|
|
|
|
:param str service_type: The service type as defined in the catalog that
|
|
the rule will apply to.
|
|
:param re.RegexObject old: The regular expression to search for and replace
|
|
if found.
|
|
:param str new: The new string to replace the pattern with.
|
|
"""
|
|
_VERSION_HACKS.add_discover_hack(service_type, old, new)
|