Merge "Add ability to specify a microversion in a request"

This commit is contained in:
Jenkins 2017-07-19 16:39:13 +00:00 committed by Gerrit Code Review
commit fc950862c3
5 changed files with 217 additions and 4 deletions

View File

@ -365,6 +365,25 @@ If the user knows a service does not support microversions and is merely
curious as to which major version was discovered, `discover_versions` can be
set to `False` to prevent fetching microversion metadata.
Requesting a Microversion
-------------------------
A user who wants to specify a microversion for a given request can pass it to
the ``microversion`` parameter of the `request` method on the
:class:`keystoneauth1.session.Session` object, or the
:class:`keystoneauth1.adapter.Adapter` object. This will cause `keystoneauth`
to pass the appropriate header to the service informing the service of the
microversion the user wants.
If the user is using a :class:`keystoneauth1.adapter.Adapter`, the
`service_type`, which is a part of the data sent in the microversion header,
will be taken from the Adapter's `service_type`.
If the user is using a :class:`keystoneauth1.session.Session`, the
`service_type` will be taken from the `service_type` in `endpoint_filter`
or alternately from the parameter `microversion_service_type` in case there
is no `service_type` in `endpoint_filter` for some reason.
.. _API-WG Specs: http://specs.openstack.org/openstack/api-wg/
.. _Consuming the Catalog: http://specs.openstack.org/openstack/api-wg/guidelines/consuming-catalog.html
.. _Microversions: http://specs.openstack.org/openstack/api-wg/guidelines/microversion_specification.html#version-discovery

View File

@ -27,8 +27,8 @@ class Adapter(object):
particular client that is using the session. An adapter provides a wrapper
of client local data around the global session object.
version, min_version and max_version can all be given either as a
string or a tuple.
version, min_version, max_version and default_microversion can all be
given either as a string or a tuple.
:param session: The session object to wrap.
:type session: keystoneauth1.session.Session
@ -77,6 +77,11 @@ class Adapter(object):
:param max_version: The maximum major version of a given API, intended to
be used as the upper bound of a range with min_version.
Mutually exclusive with version. (optional)
:param default_microversion: The default microversion value to send
with API requests. While microversions are
a per-request feature, a user may know they
want to default to sending a specific value.
(optional)
"""
client_name = None
@ -90,7 +95,8 @@ class Adapter(object):
additional_headers=None, client_name=None,
client_version=None, allow_version_hack=None,
global_request_id=None,
min_version=None, max_version=None):
min_version=None, max_version=None,
default_microversion=None):
if version and (min_version or max_version):
raise TypeError(
"version is mutually exclusive with min_version and"
@ -116,6 +122,7 @@ class Adapter(object):
self.allow_version_hack = allow_version_hack
self.min_version = min_version
self.max_version = max_version
self.default_microversion = default_microversion
self.global_request_id = global_request_id
@ -159,6 +166,8 @@ class Adapter(object):
kwargs.setdefault('logger', self.logger)
if self.allow:
kwargs.setdefault('allow', self.allow)
if self.default_microversion is not None:
kwargs.setdefault('microversion', self.default_microversion)
if isinstance(self.session, (session.Session, Adapter)):
# these things are unsupported by keystoneclient's session so be

View File

@ -29,6 +29,7 @@ from six.moves import urllib
import keystoneauth1
from keystoneauth1 import _utils as utils
from keystoneauth1 import discover
from keystoneauth1 import exceptions
try:
@ -65,6 +66,22 @@ def _construct_session(session_obj=None):
return session_obj
def _mv_legacy_headers_for_service(mv_service_type):
"""Workaround for services that predate standardization.
TODO(sdague): eventually convert this to using os-service-types
and put the logic there. However, right now this is so little
logic, inlining it for release is a better call.
"""
headers = []
if mv_service_type == "compute":
headers.append("X-OpenStack-Nova-API-Version")
elif mv_service_type == "baremetal":
headers.append("X-OpenStack-Ironic-API-Version")
return headers
class _JSONEncoder(json.JSONEncoder):
def default(self, o):
@ -405,13 +422,57 @@ class Session(object):
logger.debug(' '.join(string_parts))
@staticmethod
def _set_microversion_headers(
headers, microversion, service_type, endpoint_filter):
# We're converting it to normalized version number for two reasons.
# First, to validate it's a real version number. Second, so that in
# the future we can pre-validate that it is within the range of
# available microversions before we send the request.
# TODO(mordred) Validate when we get the response back that
# the server executed in the microversion we expected.
# TODO(mordred) Validate that the requested microversion works
# with the microversion range we found in discovery.
microversion = discover.normalize_version_number(microversion)
microversion = discover.version_to_string(microversion)
if not service_type:
if endpoint_filter and 'service_type' in endpoint_filter:
service_type = endpoint_filter['service_type']
else:
raise TypeError(
"microversion {microversion} was requested but no"
" service_type information is available. Either provide a"
" service_type in endpoint_filter or pass"
" microversion_service_type as an argument.".format(
microversion=discover.version_to_string(microversion)))
# TODO(mordred) cinder uses volume in its microversion header. This
# logic should be handled in the future by os-service-types but for
# now hard-code for cinder.
if (service_type.startswith('volume')
or service_type == 'block-storage'):
service_type = 'volume'
# TODO(mordred) Fix this as part of the service-types generalized
# fix. Ironic does not yet support the new version header
if service_type != 'baremetal':
headers.setdefault('OpenStack-API-Version',
'{service_type} {microversion}'.format(
service_type=service_type,
microversion=microversion))
header_names = _mv_legacy_headers_for_service(service_type)
for h in header_names:
headers.setdefault(h, microversion)
return headers
@positional()
def request(self, url, method, json=None, original_ip=None,
user_agent=None, redirect=None, authenticated=None,
endpoint_filter=None, auth=None, requests_auth=None,
raise_exc=True, allow_reauth=True, log=True,
endpoint_override=None, connect_retries=0, logger=_logger,
allow={}, client_name=None, client_version=None, **kwargs):
allow={}, client_name=None, client_version=None,
microversion=None, microversion_service_type=None,
**kwargs):
"""Send an HTTP request with the specified characteristics.
Wrapper around `requests.Session.request` to handle tasks such as
@ -479,6 +540,16 @@ class Session(object):
:type logger: logging.Logger
:param dict allow: Extra filters to pass when discovering API
versions. (optional)
:param microversion: Microversion to send for this request.
microversion can be given as a string or a tuple.
(optional)
:param str microversion_service_type: The service_type to be sent in
the microversion header, if a microversion is given.
Defaults to the value of service_type from
endpoint_filter if one exists. If endpoint_filter
does not have a service_type, microversion is given and
microversion_service_type is not provided, an exception
will be raised.
:param kwargs: any other parameter that can be passed to
:meth:`requests.Session.request` (such as `headers`).
Except:
@ -494,6 +565,10 @@ class Session(object):
:returns: The response to the request.
"""
headers = kwargs.setdefault('headers', dict())
if microversion:
self._set_microversion_headers(
headers, microversion, microversion_service_type,
endpoint_filter)
if authenticated is None:
authenticated = bool(auth or self.auth)

View File

@ -105,6 +105,85 @@ class SessionTests(utils.TestCase):
self.assertEqual(resp.text, 'response')
self.assertRequestBodyIs(json={'hello': 'world'})
def test_set_microversion_headers(self):
# String microversion, specified service type
headers = {}
client_session.Session._set_microversion_headers(
headers, '2.30', 'compute', None)
self.assertEqual(headers['OpenStack-API-Version'], 'compute 2.30')
self.assertEqual(headers['X-OpenStack-Nova-API-Version'], '2.30')
self.assertEqual(len(headers.keys()), 2)
# Tuple microversion, service type via endpoint_filter
headers = {}
client_session.Session._set_microversion_headers(
headers, (2, 30), None, {'service_type': 'compute'})
self.assertEqual(headers['OpenStack-API-Version'], 'compute 2.30')
self.assertEqual(headers['X-OpenStack-Nova-API-Version'], '2.30')
self.assertEqual(len(headers.keys()), 2)
# ironic microversion, specified service type
headers = {}
client_session.Session._set_microversion_headers(
headers, '2.30', 'baremetal', None)
self.assertEqual(headers['X-OpenStack-Ironic-API-Version'], '2.30')
self.assertEqual(len(headers.keys()), 1)
# volumev2 service-type - volume microversion
headers = {}
client_session.Session._set_microversion_headers(
headers, (2, 30), None, {'service_type': 'volumev2'})
self.assertEqual(headers['OpenStack-API-Version'], 'volume 2.30')
self.assertEqual(len(headers.keys()), 1)
# block-storage service-type - volume microversion
headers = {}
client_session.Session._set_microversion_headers(
headers, (2, 30), None, {'service_type': 'block-storage'})
self.assertEqual(headers['OpenStack-API-Version'], 'volume 2.30')
self.assertEqual(len(headers.keys()), 1)
# Headers already exist - no change
headers = {
'OpenStack-API-Version': 'compute 2.30',
'X-OpenStack-Nova-API-Version': '2.30',
}
client_session.Session._set_microversion_headers(
headers, (2, 31), None, {'service_type': 'volume'})
self.assertEqual(headers['OpenStack-API-Version'], 'compute 2.30')
self.assertEqual(headers['X-OpenStack-Nova-API-Version'], '2.30')
# Normalization error
self.assertRaises(TypeError,
client_session.Session._set_microversion_headers,
{}, 'bogus', 'service_type', None)
# No service type in param or endpoint filter
self.assertRaises(TypeError,
client_session.Session._set_microversion_headers,
{}, (2, 30), None, None)
self.assertRaises(TypeError,
client_session.Session._set_microversion_headers,
{}, (2, 30), None, {'no_service_type': 'here'})
def test_microversion(self):
# microversion not specified
session = client_session.Session()
self.stub_url('GET', text='response')
resp = session.get(self.TEST_URL)
self.assertTrue(resp.ok)
self.assertRequestNotInHeader('OpenStack-API-Version')
session = client_session.Session()
self.stub_url('GET', text='response')
resp = session.get(self.TEST_URL, microversion='2.30',
microversion_service_type='compute',
endpoint_filter={'endpoint': 'filter'})
self.assertTrue(resp.ok)
self.assertRequestHeaderEqual('OpenStack-API-Version', 'compute 2.30')
def test_user_agent(self):
session = client_session.Session()
self.stub_url('GET', text='response')
@ -1279,6 +1358,29 @@ class AdapterTest(utils.TestCase):
last_token = self.requests_mock.last_request.headers['X-Auth-Token']
self.assertEqual(token, last_token)
def test_default_microversion(self):
sess = client_session.Session()
url = 'http://url'
def validate(adap_kwargs, get_kwargs, exp_kwargs):
with mock.patch.object(sess, 'request') as m:
adapter.Adapter(sess, **adap_kwargs).get(url, **get_kwargs)
m.assert_called_once_with(url, 'GET', endpoint_filter={},
**exp_kwargs)
# No default_microversion in Adapter, no microversion in get()
validate({}, {}, {})
# default_microversion in Adapter, no microversion in get()
validate({'default_microversion': '1.2'}, {}, {'microversion': '1.2'})
# No default_microversion in Adapter, microversion specified in get()
validate({}, {'microversion': '1.2'}, {'microversion': '1.2'})
# microversion in get() overrides default_microversion in Adapter
validate({'default_microversion': '1.2'}, {'microversion': '1.5'},
{'microversion': '1.5'})
class TCPKeepAliveAdapterTest(utils.TestCase):

View File

@ -109,6 +109,14 @@ class TestCase(testtools.TestCase):
headers = self.requests_mock.last_request.headers
self.assertEqual(headers.get(name), val)
def assertRequestNotInHeader(self, name):
"""Verify that the last request made does not contain a header key.
The request must have already been made.
"""
headers = self.requests_mock.last_request.headers
self.assertNotIn(name, headers)
class TestResponse(requests.Response):
"""Class used to wrap requests.Response.