Facilitate latest Rest API use

In order to provide insight into the remote
API verison, we need the ability to negotiate
upon the latest API version available, and then
report what that version is.

In order to understand if this has occured,
we also need to provide insight into if version
negotiation has occured.

Adds logic to the session/http clients to faciltate
version negotiation on the latest available version,
and provide user insight into that verison.

Change-Id: I813237eee4b122211f95558f677b25e0675569d5
Related-Bug: #1739440
Related-Bug: #1671145
This commit is contained in:
Julia Kreger 2018-01-02 21:46:59 -08:00
parent a91f2e4623
commit 5b01c8f2ba
8 changed files with 174 additions and 14 deletions

View File

@ -66,6 +66,11 @@ def get_client(api_version, os_auth_token=None, ironic_url=None,
:param ignored_kwargs: all the other params that are passed. Left for
backwards compatibility. They are ignored.
"""
# TODO(TheJulia): At some point, we should consider possibly noting
# the "latest" flag for os_ironic_api_version to cause the client to
# auto-negotiate to the greatest available version, however we do not
# have the ability yet for a caller to cap the version, and will hold
# off doing so until then.
os_service_type = os_service_type or 'baremetal'
os_endpoint_type = os_endpoint_type or 'publicURL'
project_id = (os_project_id or os_tenant_id)

View File

@ -44,7 +44,8 @@ from ironicclient import exc
# http://specs.openstack.org/openstack/ironic-specs/specs/kilo/api-microversions.html # noqa
# for full details.
DEFAULT_VER = '1.9'
LAST_KNOWN_API_VERSION = 35
LATEST_VERSION = '1.{}'.format(LAST_KNOWN_API_VERSION)
LOG = logging.getLogger(__name__)
USER_AGENT = 'python-ironicclient'
@ -98,6 +99,17 @@ class VersionNegotiationMixin(object):
param conn: A connection object
param resp: The response object from http request
"""
def _query_server(conn):
if (self.os_ironic_api_version and
self.os_ironic_api_version != 'latest'):
base_version = ("/v%s" %
str(self.os_ironic_api_version).split('.')[0])
else:
base_version = API_VERSION
return self._make_simple_request(conn, 'GET', base_version)
if not resp:
resp = _query_server(conn)
if self.api_version_select_state not in API_VERSION_SELECTED_STATES:
raise RuntimeError(
_('Error: self.api_version_select_state should be one of the '
@ -110,22 +122,30 @@ class VersionNegotiationMixin(object):
# the supported version range
if not max_ver:
LOG.debug('No version header in response, requesting from server')
if self.os_ironic_api_version:
base_version = ("/v%s" %
str(self.os_ironic_api_version).split('.')[0])
else:
base_version = API_VERSION
resp = self._make_simple_request(conn, 'GET', base_version)
resp = _query_server(conn)
min_ver, max_ver = self._parse_version_headers(resp)
# Reset the maximum version that we permit
if StrictVersion(max_ver) > StrictVersion(LATEST_VERSION):
LOG.debug("Remote API version %(max_ver)s is greater than the "
"version supported by ironicclient. Maximum available "
"version is %(client_ver)s",
{'max_ver': max_ver,
'client_ver': LATEST_VERSION})
max_ver = LATEST_VERSION
# If the user requested an explicit version or we have negotiated a
# version and still failing then error now. The server could
# support the version requested but the requested operation may not
# be supported by the requested version.
if self.api_version_select_state == 'user':
# TODO(TheJulia): We should break this method into several parts,
# such as a sanity check/error method.
if (self.api_version_select_state == 'user' and
self.os_ironic_api_version != 'latest'):
raise exc.UnsupportedVersion(textwrap.fill(
_("Requested API version %(req)s is not supported by the "
"server or the requested operation is not supported by the "
"requested version. Supported version range is %(min)s to "
"server, client, or the requested operation is not "
"supported by the requested version."
"Supported version range is %(min)s to "
"%(max)s")
% {'req': self.os_ironic_api_version,
'min': min_ver, 'max': max_ver}))
@ -137,6 +157,9 @@ class VersionNegotiationMixin(object):
% {'req': self.os_ironic_api_version,
'min': min_ver, 'max': max_ver}))
if self.os_ironic_api_version == 'latest':
negotiated_ver = max_ver
else:
negotiated_ver = str(min(StrictVersion(self.os_ironic_api_version),
StrictVersion(max_ver)))
if StrictVersion(negotiated_ver) < StrictVersion(min_ver):
@ -310,6 +333,13 @@ class HTTPClient(VersionNegotiationMixin):
Wrapper around request.Session.request to handle tasks such
as setting headers and error handling.
"""
# NOTE(TheJulia): self.os_ironic_api_version is reset in
# the self.negotiate_version() call if negotiation occurs.
if (self.os_ironic_api_version and
self.api_version_select_state == 'user' and
self.os_ironic_api_version == 'latest'):
self.negotiate_version(self.session, None)
# Copy the kwargs so we can reuse the original in case of redirects
kwargs['headers'] = copy.deepcopy(kwargs.get('headers', {}))
kwargs['headers'].setdefault('User-Agent', USER_AGENT)
@ -517,6 +547,14 @@ class SessionClient(VersionNegotiationMixin, adapter.LegacyJsonAdapter):
@with_retries
def _http_request(self, url, method, **kwargs):
# NOTE(TheJulia): self.os_ironic_api_version is reset in
# the self.negotiate_version() call if negotiation occurs.
if (self.os_ironic_api_version and
self.api_version_select_state == 'user' and
self.os_ironic_api_version == 'latest'):
self.negotiate_version(self.session, None)
kwargs.setdefault('user_agent', USER_AGENT)
kwargs.setdefault('auth', self.auth)
if isinstance(self.endpoint_override, six.string_types):

View File

@ -19,6 +19,7 @@
import argparse
import logging
from ironicclient.common import http
from osc_lib import utils
LOG = logging.getLogger(__name__)
@ -26,8 +27,13 @@ LOG = logging.getLogger(__name__)
CLIENT_CLASS = 'ironicclient.v1.client.Client'
API_VERSION_OPTION = 'os_baremetal_api_version'
API_NAME = 'baremetal'
LAST_KNOWN_API_VERSION = 35
LATEST_VERSION = "1.{}".format(LAST_KNOWN_API_VERSION)
# NOTE(TheJulia) Latest known version tracking has been moved
# to the ironicclient/common/http.py file as the OSC committment
# is latest known, and we should only store it in one location.
LAST_KNOWN_API_VERSION = http.LAST_KNOWN_API_VERSION
LATEST_VERSION = http.LATEST_VERSION
API_VERSIONS = {
'1.%d' % i: CLIENT_CLASS
for i in range(1, LAST_KNOWN_API_VERSION + 1)

View File

@ -200,6 +200,29 @@ class VersionNegotiationMixinTest(utils.BaseTestCase):
mock_save_data.assert_called_once_with(host=host, port=port,
data=max_ver)
@mock.patch.object(filecache, 'save_data', autospec=True)
@mock.patch.object(http.VersionNegotiationMixin, '_make_simple_request',
autospec=True)
@mock.patch.object(http.VersionNegotiationMixin, '_parse_version_headers',
autospec=True)
def test_negotiate_version_server_user_latest(
self, mock_pvh, mock_msr, mock_save_data):
# have to retry with simple get
mock_pvh.side_effect = iter([(None, None), ('1.1', '1.99')])
mock_conn = mock.MagicMock()
self.test_object.api_version_select_state = 'user'
self.test_object.os_ironic_api_version = 'latest'
result = self.test_object.negotiate_version(mock_conn, None)
self.assertEqual(http.LATEST_VERSION, result)
self.assertEqual('negotiated',
self.test_object.api_version_select_state)
self.assertEqual(http.LATEST_VERSION,
self.test_object.os_ironic_api_version)
self.assertTrue(mock_msr.called)
self.assertEqual(2, mock_pvh.call_count)
self.assertEqual(1, mock_save_data.call_count)
def test_get_server(self):
host = 'ironic-host'
port = '6385'

View File

@ -59,6 +59,9 @@ class ClientTest(utils.BaseTestCase):
region_name=kwargs.get('os_region_name'))
if 'os_ironic_api_version' in kwargs:
self.assertEqual(0, mock_retrieve_data.call_count)
self.assertEqual(kwargs['os_ironic_api_version'],
client.current_api_version)
self.assertFalse(client.is_api_version_negotiated)
else:
mock_retrieve_data.assert_called_once_with(
host='localhost',

View File

@ -49,6 +49,16 @@ class ClientTest(utils.BaseTestCase):
os_ironic_api_version=os_ironic_api_version,
api_version_select_state='default')
def test_client_user_api_version_latest_with_downgrade(self,
http_client_mock):
endpoint = 'http://ironic:6385'
token = 'safe_token'
os_ironic_api_version = 'latest'
self.assertRaises(ValueError, client.Client, endpoint,
token=token, allow_api_version_downgrade=True,
os_ironic_api_version=os_ironic_api_version)
@mock.patch.object(filecache, 'retrieve_data', autospec=True)
def test_client_cache_api_version(self, cache_mock, http_client_mock):
endpoint = 'http://ironic:6385'
@ -93,3 +103,19 @@ class ClientTest(utils.BaseTestCase):
self.assertIsInstance(cl.port, client.port.PortManager)
self.assertIsInstance(cl.driver, client.driver.DriverManager)
self.assertIsInstance(cl.chassis, client.chassis.ChassisManager)
def test_negotiate_api_version(self, http_client_mock):
endpoint = 'http://ironic:6385'
token = 'safe_token'
os_ironic_api_version = 'latest'
cl = client.Client(endpoint, token=token,
os_ironic_api_version=os_ironic_api_version)
cl.negotiate_api_version()
http_client_mock.assert_called_once_with(
endpoint, api_version_select_state='user',
os_ironic_api_version='latest', token=token)
# TODO(TheJulia): We should verify that negotiate_version
# is being called in the client and returns a version,
# although mocking might need to be restrutured to
# properly achieve that.

View File

@ -41,7 +41,17 @@ class Client(object):
"""Initialize a new client for the Ironic v1 API."""
allow_downgrade = kwargs.pop('allow_api_version_downgrade', False)
if kwargs.get('os_ironic_api_version'):
# TODO(TheJulia): We should sanity check os_ironic_api_version
# against our maximum suported version, so the client fails
# immediately upon an unsupported version being provided.
# This logic should also likely live in common/http.py
if allow_downgrade:
if kwargs['os_ironic_api_version'] == 'latest':
raise ValueError(
"Invalid configuration defined. "
"The os_ironic_api_versioncan not be set "
"to 'latest' while allow_api_version_downgrade "
"is set.")
# NOTE(dtantsur): here we allow the HTTP client to negotiate a
# lower version if the requested is too high
kwargs['api_version_select_state'] = "default"
@ -76,3 +86,26 @@ class Client(object):
self.http_client)
self.driver = driver.DriverManager(self.http_client)
self.portgroup = portgroup.PortgroupManager(self.http_client)
@property
def current_api_version(self):
"""Return the current API version in use.
This returns the version of the REST API that the API client
is presently set to request. This value may change as a result
of API version negotiation.
"""
return self.http_client.os_ironic_api_version
@property
def is_api_version_negotiated(self):
"""Returns True if microversion negotiation has occured."""
return self.http_client.api_version_select_state == 'negotiated'
def negotiate_api_version(self):
"""Triggers negotiation with the remote API endpoint.
:returns: the negotiated API version.
"""
return self.http_client.negotiate_version(
self.http_client.session, None)

View File

@ -0,0 +1,26 @@
---
features:
- |
Allows a python API user to pass ``latest`` to the client creation request
for the ``os_ironic_api_version`` parameter. The version utilized for REST
API requests will, as a result, be the highest available version
understood by both the ironicclient library and the server.
- |
Adds base client properties to provide insight to a python API user of
what the current REST API version that will be utilized, and if API
version negotiation has occured.
These new properties are ``client.current_api_version`` and
``client.is_api_version_negotiated`` respectively.
- |
Adds additional base client method to allow a python API user to trigger
version negotiation and return the negotiated version. This new method is
``client.negotiate_api_version()``.
other:
- |
The maximum supported version supported for negotiation is now defined
in the ``common/http.py`` file. Any new feature added to the API client
library must increment this version.
- |
The maximum known version supported by the ``OpenStackClient`` plugin is
now defined by the maximum supported version for API negotiation as
defined in the ``common/http.py`` file.