Merge "Implement optional API versioning"
This commit is contained in:
35
README.rst
35
README.rst
@@ -47,17 +47,38 @@ Every call accepts additional optional arguments:
|
||||
``127.0.0.1:5050``,
|
||||
* ``auth_token`` Keystone authentication token.
|
||||
* ``api_version`` requested API version; can be a tuple (MAJ, MIN), string
|
||||
"MAJ.MIN" or integer (only major). Right now only (1, 0) is supported, other
|
||||
versions will raise an exception. Defaults to ``DEFAULT_API_VERSION``.
|
||||
"MAJ.MIN" or integer (only major). Defaults to ``DEFAULT_API_VERSION``.
|
||||
|
||||
Refer to HTTP-API.rst_ for information on the **Ironic Inspector** HTTP API.
|
||||
|
||||
API Versioning
|
||||
~~~~~~~~~~~~~~
|
||||
|
||||
Starting with version 2.1.0 **Ironic Inspector** supports optional API
|
||||
versioning. Version is a tuple (X, Y), where X is always 1 for now.
|
||||
|
||||
The server has maximum and minimum supported versions. If no version is
|
||||
requested, the server assumes (1, 0).
|
||||
|
||||
* There is a helper function to figure out the current server API versions
|
||||
range:
|
||||
|
||||
``ironic_inspector_client.server_api_versions()``
|
||||
|
||||
Returns a tuple (minimum version, maximum version).
|
||||
Supports optional argument:
|
||||
|
||||
* ``base_url`` **Ironic Inspector** API endpoint, defaults to
|
||||
``127.0.0.1:5050``,
|
||||
|
||||
Two constants are exposed by the client:
|
||||
|
||||
* ``DEFAULT_API_VERSION`` server API version used by default.
|
||||
* ``MAX_API_VERSION`` maximum API version this client was designed to work
|
||||
with. Right now providing bigger value for ``api_version`` argument raises
|
||||
on exception, this limitation may be lifted later.
|
||||
* ``DEFAULT_API_VERSION`` server API version used by default, always (1, 0)
|
||||
for now.
|
||||
|
||||
Refer to HTTP-API.rst_ for information on the **Ironic Inspector** HTTP API.
|
||||
* ``MAX_API_VERSION`` maximum API version this client was designed to work
|
||||
with. This does not mean that other versions won't work at all - the server
|
||||
might still support them.
|
||||
|
||||
|
||||
.. _Gerrit Workflow: http://docs.openstack.org/infra/manual/developers.html#development-workflow
|
||||
|
||||
@@ -28,36 +28,46 @@ LOG = logging.getLogger('ironic_inspector_client')
|
||||
|
||||
DEFAULT_API_VERSION = (1, 0)
|
||||
MAX_API_VERSION = (1, 0)
|
||||
_MIN_VERSION_HEADER = 'X-OpenStack-Ironic-Inspector-API-Minimum-Version'
|
||||
_MAX_VERSION_HEADER = 'X-OpenStack-Ironic-Inspector-API-Maximum-Version'
|
||||
_VERSION_HEADER = 'X-OpenStack-Ironic-Inspector-API-Version'
|
||||
|
||||
|
||||
def _prepare(base_url, auth_token):
|
||||
def _prepare(base_url, auth_token, api_version=None):
|
||||
base_url = (base_url or _DEFAULT_URL).rstrip('/')
|
||||
if not base_url.endswith('v1'):
|
||||
base_url += '/v1'
|
||||
headers = {'X-Auth-Token': auth_token} if auth_token else {}
|
||||
if api_version:
|
||||
api_version = _check_api_version(api_version, base_url)
|
||||
headers[_VERSION_HEADER] = '%d.%d' % api_version
|
||||
return base_url, headers
|
||||
|
||||
|
||||
def _check_api_version(api_version):
|
||||
def _parse_version(api_version):
|
||||
try:
|
||||
return tuple(int(x) for x in api_version.split('.'))
|
||||
except (ValueError, TypeError):
|
||||
raise ValueError(_("Malformed API version: expect tuple, string "
|
||||
"in form of X.Y or integer"))
|
||||
|
||||
|
||||
def _check_api_version(api_version, base_url=None):
|
||||
if isinstance(api_version, int):
|
||||
api_version = (api_version, 0)
|
||||
if isinstance(api_version, six.string_types):
|
||||
try:
|
||||
api_version = tuple(int(x) for x in api_version.split('.'))
|
||||
except (ValueError, TypeError):
|
||||
raise ValueError(_("Malformed API version: expect tuple, string "
|
||||
"in form of X.Y or integer"))
|
||||
api_version = _parse_version(api_version)
|
||||
api_version = tuple(api_version)
|
||||
if not all(isinstance(x, int) for x in api_version):
|
||||
raise TypeError(_("All API version components should be integers"))
|
||||
if len(api_version) != 2:
|
||||
raise ValueError(_("API version should be of length 2"))
|
||||
if len(api_version) == 1:
|
||||
api_version += (0,)
|
||||
elif len(api_version) > 2:
|
||||
raise ValueError(_("API version should be of length 1 or 2"))
|
||||
|
||||
# TODO(dtantsur): support more than one API version
|
||||
if api_version != (1, 0):
|
||||
raise RuntimeError(_("Unsupported API version %s, only (1, 0) is "
|
||||
"supported in this version of client"),
|
||||
api_version)
|
||||
minv, maxv = server_api_versions(base_url=base_url)
|
||||
if api_version < minv or api_version > maxv:
|
||||
raise VersionNotSupported(api_version, (minv, maxv))
|
||||
|
||||
return api_version
|
||||
|
||||
@@ -83,6 +93,17 @@ class ClientError(requests.HTTPError):
|
||||
raise cls(response)
|
||||
|
||||
|
||||
class VersionNotSupported(Exception):
|
||||
"""Denotes that requested API versions is not supported by the server."""
|
||||
def __init__(self, expected, supported):
|
||||
msg = (_('Version %(expected)s is supported not by the server, '
|
||||
'supported range is %(supported)s') %
|
||||
{'expected': expected, 'supported': supported})
|
||||
self.expected_version = expected
|
||||
self.supported_versions = supported
|
||||
super(Exception, self).__init__(msg)
|
||||
|
||||
|
||||
def introspect(uuid, base_url=None, auth_token=None,
|
||||
new_ipmi_password=None, new_ipmi_username=None,
|
||||
api_version=DEFAULT_API_VERSION):
|
||||
@@ -100,15 +121,16 @@ def introspect(uuid, base_url=None, auth_token=None,
|
||||
:param api_version: requested Ironic Inspector API version, defaults to
|
||||
``DEFAULT_API_VERSION`` attribute.
|
||||
:raises: ClientError on error reported from a server
|
||||
:raises: VersionNotSupported if requested api_version is not supported
|
||||
:raises: *requests* library exception on connection problems.
|
||||
"""
|
||||
if not isinstance(uuid, six.string_types):
|
||||
raise TypeError(_("Expected string for uuid argument, got %r") % uuid)
|
||||
if new_ipmi_username and not new_ipmi_password:
|
||||
raise ValueError(_("Setting IPMI user name requires a new password"))
|
||||
_check_api_version(api_version)
|
||||
|
||||
base_url, headers = _prepare(base_url, auth_token)
|
||||
base_url, headers = _prepare(base_url, auth_token, api_version=api_version)
|
||||
|
||||
params = {'new_ipmi_username': new_ipmi_username,
|
||||
'new_ipmi_password': new_ipmi_password}
|
||||
res = requests.post("%s/introspection/%s" % (base_url, uuid),
|
||||
@@ -128,14 +150,34 @@ def get_status(uuid, base_url=None, auth_token=None,
|
||||
:param api_version: requested Ironic Inspector API version, defaults to
|
||||
``DEFAULT_API_VERSION`` attribute.
|
||||
:raises: ClientError on error reported from a server
|
||||
:raises: VersionNotSupported if requested api_version is not supported
|
||||
:raises: *requests* library exception on connection problems.
|
||||
"""
|
||||
if not isinstance(uuid, six.string_types):
|
||||
raise TypeError(_("Expected string for uuid argument, got %r") % uuid)
|
||||
_check_api_version(api_version)
|
||||
|
||||
base_url, headers = _prepare(base_url, auth_token)
|
||||
base_url, headers = _prepare(base_url, auth_token, api_version=api_version)
|
||||
|
||||
res = requests.get("%s/introspection/%s" % (base_url, uuid),
|
||||
headers=headers)
|
||||
ClientError.raise_if_needed(res)
|
||||
return res.json()
|
||||
|
||||
|
||||
def server_api_versions(base_url=None):
|
||||
"""Get minimum and maximum supported API versions from a server.
|
||||
|
||||
:param base_url: *Ironic Inspector* URL in form: http://host:port[/ver],
|
||||
defaults to ``http://<current host>:5050/v1``.
|
||||
:return: tuple (minimum version, maximum version) each version is returned
|
||||
as a tuple (X, Y)
|
||||
:raises: *requests* library exception on connection problems.
|
||||
:raises: ValueError if returned version cannot be parsed
|
||||
"""
|
||||
base_url, _headers = _prepare(base_url, auth_token=None)
|
||||
res = requests.get(base_url)
|
||||
# HTTP Not Found is a valid response for older (2.0.0) servers
|
||||
if res.status_code >= 400 and res.status_code != 404:
|
||||
ClientError.raise_if_needed(res)
|
||||
return (_parse_version(res.headers.get(_MIN_VERSION_HEADER, '1.0')),
|
||||
_parse_version(res.headers.get(_MAX_VERSION_HEADER, '1.0')))
|
||||
|
||||
@@ -27,16 +27,19 @@ from ironic_inspector_client import client
|
||||
LOG = logging.getLogger('ironic_inspector.shell')
|
||||
API_NAME = 'baremetal-introspection'
|
||||
API_VERSION_OPTION = 'inspector_api_version'
|
||||
DEFAULT_VERSION = '1'
|
||||
DEFAULT_API_VERSION = '1'
|
||||
API_VERSIONS = {
|
||||
"1": "ironic_inspector.shell",
|
||||
}
|
||||
|
||||
for mversion in range(client.MAX_API_VERSION[1] + 1):
|
||||
API_VERSIONS["1.%d" % mversion] = API_VERSIONS["1"]
|
||||
|
||||
|
||||
def build_option_parser(parser):
|
||||
parser.add_argument('--inspector-api-version',
|
||||
default=utils.env('INSPECTOR_VERSION',
|
||||
default=DEFAULT_VERSION),
|
||||
default=DEFAULT_API_VERSION),
|
||||
help='inspector API version, only 1 is supported now '
|
||||
'(env: INSPECTOR_VERSION).')
|
||||
return parser
|
||||
@@ -60,10 +63,12 @@ class StartCommand(command.Command):
|
||||
|
||||
def take_action(self, parsed_args):
|
||||
auth_token = self.app.client_manager.auth_ref.auth_token
|
||||
api_version = self.app.client_manager._api_version[API_NAME]
|
||||
client.introspect(parsed_args.uuid, base_url=parsed_args.inspector_url,
|
||||
auth_token=auth_token,
|
||||
new_ipmi_username=parsed_args.new_ipmi_username,
|
||||
new_ipmi_password=parsed_args.new_ipmi_password)
|
||||
new_ipmi_password=parsed_args.new_ipmi_password,
|
||||
api_version=api_version)
|
||||
if parsed_args.new_ipmi_password:
|
||||
print('Setting IPMI credentials requested, please power on '
|
||||
'the machine manually')
|
||||
@@ -79,9 +84,12 @@ class StatusCommand(show.ShowOne):
|
||||
|
||||
def take_action(self, parsed_args):
|
||||
auth_token = self.app.client_manager.auth_ref.auth_token
|
||||
status = client.get_status(parsed_args.uuid,
|
||||
base_url=parsed_args.inspector_url,
|
||||
auth_token=auth_token)
|
||||
api_version = self.app.client_manager._api_version[API_NAME]
|
||||
status = client.get_status(
|
||||
parsed_args.uuid,
|
||||
base_url=parsed_args.inspector_url,
|
||||
auth_token=auth_token,
|
||||
api_version=api_version)
|
||||
return zip(*sorted(status.items()))
|
||||
|
||||
|
||||
|
||||
@@ -54,6 +54,20 @@ class TestPythonAPI(functional.Base):
|
||||
self.assertEqual('pwd', res['ipmi_password'])
|
||||
self.assertTrue(res['ipmi_setup_credentials'])
|
||||
|
||||
def test_api_versions(self):
|
||||
minv, maxv = client.server_api_versions()
|
||||
self.assertEqual((1, 0), minv)
|
||||
self.assertGreaterEqual(maxv, (1, 0))
|
||||
self.assertLess(maxv, (2, 0))
|
||||
|
||||
self.assertRaises(client.VersionNotSupported,
|
||||
client.introspect, self.uuid, api_version=(1, 999))
|
||||
self.assertRaises(client.VersionNotSupported,
|
||||
client.get_status, self.uuid, api_version=(1, 999))
|
||||
# Error 404
|
||||
self.assertRaises(client.ClientError,
|
||||
client.get_status, self.uuid, api_version=(1, 0))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
with functional.mocked_server():
|
||||
|
||||
@@ -20,20 +20,27 @@ from oslo_utils import uuidutils
|
||||
from ironic_inspector_client import client
|
||||
|
||||
|
||||
@mock.patch.object(client.requests, 'post', autospec=True,
|
||||
**{'return_value.status_code': 200})
|
||||
class TestIntrospect(unittest.TestCase):
|
||||
class BaseTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
super(TestIntrospect, self).setUp()
|
||||
super(BaseTest, self).setUp()
|
||||
self.uuid = uuidutils.generate_uuid()
|
||||
self.my_ip = 'http://' + netutils.get_my_ipv4() + ':5050/v1'
|
||||
self.token = "token"
|
||||
self.headers = {'X-OpenStack-Ironic-Inspector-API-Version': '1.0',
|
||||
'X-Auth-Token': self.token}
|
||||
|
||||
|
||||
@mock.patch.object(client, 'server_api_versions',
|
||||
lambda *args, **kwargs: ((1, 0), (1, 99)))
|
||||
@mock.patch.object(client.requests, 'post', autospec=True,
|
||||
**{'return_value.status_code': 200})
|
||||
class TestIntrospect(BaseTest):
|
||||
def test(self, mock_post):
|
||||
client.introspect(self.uuid, base_url="http://host:port",
|
||||
auth_token="token")
|
||||
auth_token=self.token)
|
||||
mock_post.assert_called_once_with(
|
||||
"http://host:port/v1/introspection/%s" % self.uuid,
|
||||
headers={'X-Auth-Token': 'token'},
|
||||
headers=self.headers,
|
||||
params={'new_ipmi_username': None, 'new_ipmi_password': None}
|
||||
)
|
||||
|
||||
@@ -44,38 +51,39 @@ class TestIntrospect(unittest.TestCase):
|
||||
|
||||
def test_full_url(self, mock_post):
|
||||
client.introspect(self.uuid, base_url="http://host:port/v1/",
|
||||
auth_token="token")
|
||||
auth_token=self.token)
|
||||
mock_post.assert_called_once_with(
|
||||
"http://host:port/v1/introspection/%s" % self.uuid,
|
||||
headers={'X-Auth-Token': 'token'},
|
||||
headers=self.headers,
|
||||
params={'new_ipmi_username': None, 'new_ipmi_password': None}
|
||||
)
|
||||
|
||||
def test_default_url(self, mock_post):
|
||||
client.introspect(self.uuid, auth_token="token")
|
||||
client.introspect(self.uuid, auth_token=self.token)
|
||||
mock_post.assert_called_once_with(
|
||||
"%(my_ip)s/introspection/%(uuid)s" %
|
||||
{'my_ip': self.my_ip, 'uuid': self.uuid},
|
||||
headers={'X-Auth-Token': 'token'},
|
||||
headers=self.headers,
|
||||
params={'new_ipmi_username': None, 'new_ipmi_password': None}
|
||||
)
|
||||
|
||||
def test_set_ipmi_credentials(self, mock_post):
|
||||
client.introspect(self.uuid, base_url="http://host:port",
|
||||
auth_token="token", new_ipmi_password='p',
|
||||
auth_token=self.token, new_ipmi_password='p',
|
||||
new_ipmi_username='u')
|
||||
mock_post.assert_called_once_with(
|
||||
"http://host:port/v1/introspection/%s" % self.uuid,
|
||||
headers={'X-Auth-Token': 'token'},
|
||||
headers=self.headers,
|
||||
params={'new_ipmi_username': 'u', 'new_ipmi_password': 'p'}
|
||||
)
|
||||
|
||||
def test_none_ok(self, mock_post):
|
||||
client.introspect(self.uuid)
|
||||
del self.headers['X-Auth-Token']
|
||||
mock_post.assert_called_once_with(
|
||||
"%(my_ip)s/introspection/%(uuid)s" %
|
||||
{'my_ip': self.my_ip, 'uuid': self.uuid},
|
||||
headers={},
|
||||
headers=self.headers,
|
||||
params={'new_ipmi_username': None, 'new_ipmi_password': None}
|
||||
)
|
||||
|
||||
@@ -98,23 +106,20 @@ class TestIntrospect(unittest.TestCase):
|
||||
client.introspect, self.uuid)
|
||||
|
||||
|
||||
@mock.patch.object(client, 'server_api_versions',
|
||||
lambda *args, **kwargs: ((1, 0), (1, 99)))
|
||||
@mock.patch.object(client.requests, 'get', autospec=True,
|
||||
**{'return_value.status_code': 200})
|
||||
class TestGetStatus(unittest.TestCase):
|
||||
def setUp(self):
|
||||
super(TestGetStatus, self).setUp()
|
||||
self.uuid = uuidutils.generate_uuid()
|
||||
self.my_ip = 'http://' + netutils.get_my_ipv4() + ':5050/v1'
|
||||
|
||||
class TestGetStatus(BaseTest):
|
||||
def test(self, mock_get):
|
||||
mock_get.return_value.json.return_value = 'json'
|
||||
|
||||
client.get_status(self.uuid, auth_token='token')
|
||||
client.get_status(self.uuid, auth_token=self.token)
|
||||
|
||||
mock_get.assert_called_once_with(
|
||||
"%(my_ip)s/introspection/%(uuid)s" %
|
||||
{'my_ip': self.my_ip, 'uuid': self.uuid},
|
||||
headers={'X-Auth-Token': 'token'}
|
||||
headers=self.headers
|
||||
)
|
||||
|
||||
def test_invalid_input(self, _):
|
||||
@@ -139,10 +144,15 @@ class TestGetStatus(unittest.TestCase):
|
||||
client.get_status, self.uuid)
|
||||
|
||||
|
||||
@mock.patch.object(client, 'server_api_versions',
|
||||
lambda *args, **kwargs: ((1, 0), (1, 99)))
|
||||
class TestCheckVesion(unittest.TestCase):
|
||||
def test_tuple(self):
|
||||
self.assertEqual((1, 0), client._check_api_version((1, 0)))
|
||||
|
||||
def test_small_tuple(self):
|
||||
self.assertEqual((1, 0), client._check_api_version((1,)))
|
||||
|
||||
def test_int(self):
|
||||
self.assertEqual((1, 0), client._check_api_version(1))
|
||||
|
||||
@@ -158,5 +168,49 @@ class TestCheckVesion(unittest.TestCase):
|
||||
self.assertRaises(ValueError, client._check_api_version, "1.2.3")
|
||||
self.assertRaises(ValueError, client._check_api_version, "foo")
|
||||
|
||||
def test_only_1_0_supported(self):
|
||||
self.assertRaises(RuntimeError, client._check_api_version, (1, 1))
|
||||
def test_unsupported(self):
|
||||
self.assertRaises(client.VersionNotSupported,
|
||||
client._check_api_version, (99, 42))
|
||||
|
||||
|
||||
@mock.patch.object(client.requests, 'get', autospec=True,
|
||||
**{'return_value.status_code': 200})
|
||||
class TestServerApiVersions(BaseTest):
|
||||
def test_no_headers(self, mock_get):
|
||||
mock_get.return_value.headers = {}
|
||||
|
||||
minv, maxv = client.server_api_versions()
|
||||
|
||||
self.assertEqual((1, 0), minv)
|
||||
self.assertEqual((1, 0), maxv)
|
||||
mock_get.assert_called_once_with(self.my_ip)
|
||||
|
||||
def test_with_headers(self, mock_get):
|
||||
mock_get.return_value.headers = {
|
||||
'X-OpenStack-Ironic-Inspector-API-Minimum-Version': '1.1',
|
||||
'X-OpenStack-Ironic-Inspector-API-Maximum-Version': '1.42',
|
||||
}
|
||||
|
||||
minv, maxv = client.server_api_versions()
|
||||
|
||||
self.assertEqual((1, 1), minv)
|
||||
self.assertEqual((1, 42), maxv)
|
||||
mock_get.assert_called_once_with(self.my_ip)
|
||||
|
||||
def test_with_404(self, mock_get):
|
||||
mock_get.return_value.status_code = 404
|
||||
mock_get.return_value.headers = {}
|
||||
|
||||
minv, maxv = client.server_api_versions()
|
||||
|
||||
self.assertEqual((1, 0), minv)
|
||||
self.assertEqual((1, 0), maxv)
|
||||
mock_get.assert_called_once_with(self.my_ip)
|
||||
|
||||
def test_with_other_error(self, mock_get):
|
||||
mock_get.return_value.status_code = 500
|
||||
mock_get.return_value.headers = {}
|
||||
|
||||
self.assertRaises(client.ClientError, client.server_api_versions)
|
||||
|
||||
mock_get.assert_called_once_with(self.my_ip)
|
||||
|
||||
Reference in New Issue
Block a user