diff --git a/HTTP-API.rst b/HTTP-API.rst index 0b9b79755..3ed5bdc37 100644 --- a/HTTP-API.rst +++ b/HTTP-API.rst @@ -4,8 +4,6 @@ HTTP API By default **ironic-inspector** listens on ``0.0.0.0:5050``, port can be changed in configuration. Protocol is JSON over HTTP. -The HTTP API consist of these endpoints: - Start Introspection ~~~~~~~~~~~~~~~~~~~ @@ -88,6 +86,10 @@ the ramdisk. Request body: JSON dictionary with at least these keys: This list highly depends on enabled plugins, provided above are expected keys for the default set of plugins. See Plugins_ for details. +.. note:: + This endpoint is not expected to be versioned, though versioning will work + on it. + Response: * 200 - OK @@ -104,3 +106,31 @@ body will contain the following keys: .. _Setting IPMI Credentials: https://github.com/openstack/ironic-inspector#setting-ipmi-credentials .. _Plugins: https://github.com/openstack/ironic-inspector#plugins + +API Versioning +~~~~~~~~~~~~~~ + +The API supports optional API versioning. You can query for minimum and +maximum API version supported by the server. You can also declare required API +version in your requests, so that the server rejects request of unsupported +version. + +.. note:: + Versioning was introduced in **Ironic Inspector 2.1.0**. + +All versions must be supplied as string in form of ``X.Y``, where ``X`` is a +major version and is always ``1`` for now, ``Y`` is a minor version. + +* If ``X-OpenStack-Ironic-Inspector-API-Version`` header is sent with request, + the server will check if it supports this version. HTTP error 406 will be + returned for unsupported API version. + +* All HTTP responses contain + ``X-OpenStack-Ironic-Inspector-API-Minimum-Version`` and + ``X-OpenStack-Ironic-Inspector-API-Maximum-Version`` headers with minimum + and maximum API versions supported by the server. + +Version History +^^^^^^^^^^^^^^^ + +**1.0** version of API at the moment of introducing versioning. diff --git a/ironic_inspector/main.py b/ironic_inspector/main.py index a13aad46a..1c5acff38 100644 --- a/ironic_inspector/main.py +++ b/ironic_inspector/main.py @@ -40,10 +40,24 @@ CONF = cfg.CONF app = flask.Flask(__name__) LOG = logging.getLogger('ironic_inspector.main') +MINIMUM_API_VERSION = (1, 0) +CURRENT_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 _format_version(ver): + return '%d.%d' % ver + + +_DEFAULT_API_VERSION = _format_version(MINIMUM_API_VERSION) + def error_response(exc, code=500): res = flask.jsonify(error={'message': str(exc)}) res.status_code = code + LOG.debug('Returning error to client: %s', exc) return res @@ -63,6 +77,41 @@ def convert_exceptions(func): return wrapper +@app.before_request +def check_api_version(): + requested = flask.request.headers.get(_VERSION_HEADER, + _DEFAULT_API_VERSION) + try: + requested = tuple(int(x) for x in requested.split('.')) + except (ValueError, TypeError): + return error_response(_('Malformed API version: expected string ' + 'in form of X.Y'), code=400) + + if requested < MINIMUM_API_VERSION or requested > CURRENT_API_VERSION: + return error_response(_('Unsupported API version %(requested)s, ' + 'supported range is %(min)s to %(max)s') % + {'requested': _format_version(requested), + 'min': _format_version(MINIMUM_API_VERSION), + 'max': _format_version(CURRENT_API_VERSION)}, + code=406) + + +@app.after_request +def add_version_headers(res): + res.headers[_MIN_VERSION_HEADER] = '%s.%s' % MINIMUM_API_VERSION + res.headers[_MAX_VERSION_HEADER] = '%s.%s' % CURRENT_API_VERSION + return res + + +@app.route('/', methods=['GET']) +@app.route('/v1', methods=['GET']) +@convert_exceptions +def api_root(): + # TODO(dtantsur): this endpoint only returns API version now, it's possible + # we'll return something meaningful in addition later + return '{}', 200, {'Content-Type': 'application/json'} + + @app.route('/v1/continue', methods=['POST']) @convert_exceptions def api_continue(): diff --git a/ironic_inspector/test/functional.py b/ironic_inspector/test/functional.py index ba080e056..e5068f127 100644 --- a/ironic_inspector/test/functional.py +++ b/ironic_inspector/test/functional.py @@ -82,11 +82,14 @@ class Base(base.NodeTest): self.node.power_state = 'power off' - def call(self, method, endpoint, data=None, expect_errors=False): + def call(self, method, endpoint, data=None, expect_errors=False, + api_version=None): if data is not None: data = json.dumps(data) endpoint = 'http://127.0.0.1:5050' + endpoint headers = {'X-Auth-Token': 'token'} + if api_version: + headers[main._VERSION_HEADER] = '%d.%d' % api_version res = getattr(requests, method.lower())(endpoint, data=data, headers=headers) if not expect_errors: diff --git a/ironic_inspector/test/test_main.py b/ironic_inspector/test/test_main.py index fc0a262f7..300bbf89c 100644 --- a/ironic_inspector/test/test_main.py +++ b/ironic_inspector/test/test_main.py @@ -37,14 +37,16 @@ def _get_error(res): return json.loads(res.data.decode('utf-8'))['error']['message'] -class TestApi(test_base.BaseTest): +class BaseAPITest(test_base.BaseTest): def setUp(self): - super(TestApi, self).setUp() + super(BaseAPITest, self).setUp() main.app.config['TESTING'] = True self.app = main.app.test_client() CONF.set_override('auth_strategy', 'noauth') self.uuid = uuidutils.generate_uuid() + +class TestApiIntrospect(BaseAPITest): @mock.patch.object(introspect, 'introspect', autospec=True) def test_introspect_no_authentication(self, introspect_mock): CONF.set_override('auth_strategy', 'noauth') @@ -100,6 +102,8 @@ class TestApi(test_base.BaseTest): res = self.app.post('/v1/introspection/%s' % uuid_dummy) self.assertEqual(400, res.status_code) + +class TestApiContinue(BaseAPITest): @mock.patch.object(process, 'process', autospec=True) def test_continue(self, process_mock): # should be ignored @@ -118,6 +122,8 @@ class TestApi(test_base.BaseTest): process_mock.assert_called_once_with("JSON") self.assertEqual('boom', _get_error(res)) + +class TestApiGetStatus(BaseAPITest): @mock.patch.object(node_cache, 'get_node', autospec=True) def test_get_introspection_in_progress(self, get_mock): get_mock.return_value = node_cache.NodeInfo(uuid=self.uuid, @@ -138,6 +144,8 @@ class TestApi(test_base.BaseTest): self.assertEqual({'finished': True, 'error': 'boom'}, json.loads(res.data.decode('utf-8'))) + +class TestApiMisc(BaseAPITest): @mock.patch.object(node_cache, 'get_node', autospec=True) def test_404_expected(self, get_mock): get_mock.side_effect = iter([utils.Error('boom', code=404)]) @@ -169,6 +177,53 @@ class TestApi(test_base.BaseTest): _get_error(res)) +class TestApiVersions(BaseAPITest): + def _check_version_present(self, res): + self.assertEqual('%d.%d' % main.MINIMUM_API_VERSION, + res.headers.get(main._MIN_VERSION_HEADER)) + self.assertEqual('%d.%d' % main.CURRENT_API_VERSION, + res.headers.get(main._MAX_VERSION_HEADER)) + + def test_root_endpoints(self): + for endpoint in '/', '/v1': + res = self.app.get(endpoint) + self.assertEqual(200, res.status_code) + self._check_version_present(res) + + def test_404_unexpected(self): + # API version on unknown pages + self._check_version_present(self.app.get('/v1/foobar')) + + @mock.patch.object(node_cache, 'get_node', autospec=True) + def test_usual_requests(self, get_mock): + get_mock.return_value = node_cache.NodeInfo(uuid=self.uuid, + started_at=42.0) + # Successfull + self._check_version_present( + self.app.post('/v1/introspection/%s' % self.uuid)) + # With error + self._check_version_present( + self.app.post('/v1/introspection/foobar')) + + def test_request_correct_version(self): + headers = {main._VERSION_HEADER: + main._format_version(main.CURRENT_API_VERSION)} + self._check_version_present(self.app.get('/', headers=headers)) + + def test_request_unsupported_version(self): + bad_version = (main.CURRENT_API_VERSION[0], + main.CURRENT_API_VERSION[1] + 1) + headers = {main._VERSION_HEADER: + main._format_version(bad_version)} + res = self.app.get('/', headers=headers) + self._check_version_present(res) + self.assertEqual(406, res.status_code) + error = _get_error(res) + self.assertIn('%d.%d' % bad_version, error) + self.assertIn('%d.%d' % main.MINIMUM_API_VERSION, error) + self.assertIn('%d.%d' % main.CURRENT_API_VERSION, error) + + class TestPlugins(unittest.TestCase): @mock.patch.object(example_plugin.ExampleProcessingHook, 'before_processing', autospec=True)