Merge "Implement optional API versioning"

This commit is contained in:
Jenkins
2015-08-12 08:09:17 +00:00
committed by Gerrit Code Review
5 changed files with 193 additions and 54 deletions

View File

@@ -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

View File

@@ -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')))

View File

@@ -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()))

View File

@@ -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():

View File

@@ -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)