diff --git a/README.rst b/README.rst index 20eb23e..939d54a 100644 --- a/README.rst +++ b/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 diff --git a/ironic_inspector_client/client.py b/ironic_inspector_client/client.py index 0e2d346..6dfff5a 100644 --- a/ironic_inspector_client/client.py +++ b/ironic_inspector_client/client.py @@ -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://: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'))) diff --git a/ironic_inspector_client/shell.py b/ironic_inspector_client/shell.py index 6181fd0..e726cc0 100644 --- a/ironic_inspector_client/shell.py +++ b/ironic_inspector_client/shell.py @@ -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())) diff --git a/ironic_inspector_client/test/functional.py b/ironic_inspector_client/test/functional.py index 6a57805..4d115a5 100644 --- a/ironic_inspector_client/test/functional.py +++ b/ironic_inspector_client/test/functional.py @@ -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(): diff --git a/ironic_inspector_client/test/test_client.py b/ironic_inspector_client/test/test_client.py index c525d0f..4b53aad 100644 --- a/ironic_inspector_client/test/test_client.py +++ b/ironic_inspector_client/test/test_client.py @@ -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)