From 598c994e11f840f7a9996e9526a3c906e66ed993 Mon Sep 17 00:00:00 2001 From: Dmitry Tantsur Date: Thu, 4 Apr 2019 16:52:31 +0200 Subject: [PATCH] Support for the baremetal introspection service This change adds support for the baremetal introspection (aka ironic-inspector) API. The initial patch includes starting, stopping introspection, retrieving introspection statuses and introspection data. Change-Id: I4b8448316b1b6f6777ed0044374175ed794937f1 --- doc/source/user/index.rst | 2 + .../user/proxies/baremetal_introspection.rst | 25 +++ .../baremetal_introspection/index.rst | 7 + .../v1/introspection.rst | 13 ++ openstack/baremetal_introspection/__init__.py | 0 .../baremetal_introspection_service.py | 22 +++ .../baremetal_introspection/v1/__init__.py | 0 .../baremetal_introspection/v1/_proxy.py | 130 ++++++++++++++++ .../v1/introspection.py | 131 ++++++++++++++++ openstack/config/defaults.json | 1 + openstack/resource.py | 19 ++- .../unit/baremetal_introspection/__init__.py | 0 .../baremetal_introspection/v1/__init__.py | 0 .../baremetal_introspection/v1/test_proxy.py | 142 ++++++++++++++++++ openstack/tests/unit/test_resource.py | 3 +- ...emetal-introspection-973351b3ee76309e.yaml | 4 + 16 files changed, 495 insertions(+), 4 deletions(-) create mode 100644 doc/source/user/proxies/baremetal_introspection.rst create mode 100644 doc/source/user/resources/baremetal_introspection/index.rst create mode 100644 doc/source/user/resources/baremetal_introspection/v1/introspection.rst create mode 100644 openstack/baremetal_introspection/__init__.py create mode 100644 openstack/baremetal_introspection/baremetal_introspection_service.py create mode 100644 openstack/baremetal_introspection/v1/__init__.py create mode 100644 openstack/baremetal_introspection/v1/_proxy.py create mode 100644 openstack/baremetal_introspection/v1/introspection.py create mode 100644 openstack/tests/unit/baremetal_introspection/__init__.py create mode 100644 openstack/tests/unit/baremetal_introspection/v1/__init__.py create mode 100644 openstack/tests/unit/baremetal_introspection/v1/test_proxy.py create mode 100644 releasenotes/notes/baremetal-introspection-973351b3ee76309e.yaml diff --git a/doc/source/user/index.rst b/doc/source/user/index.rst index 4fa8a02f6..1070284cc 100644 --- a/doc/source/user/index.rst +++ b/doc/source/user/index.rst @@ -95,6 +95,7 @@ control which services can be used. :maxdepth: 1 Baremetal + Baremetal Introspection Block Storage Clustering Compute @@ -128,6 +129,7 @@ The following services have exposed *Resource* classes. :maxdepth: 1 Baremetal + Baremetal Introspection Block Storage Clustering Compute diff --git a/doc/source/user/proxies/baremetal_introspection.rst b/doc/source/user/proxies/baremetal_introspection.rst new file mode 100644 index 000000000..d6d712b9e --- /dev/null +++ b/doc/source/user/proxies/baremetal_introspection.rst @@ -0,0 +1,25 @@ +Baremetal Introspection API +=========================== + +.. automodule:: openstack.baremetal_introspection.v1._proxy + +The Baremetal Introspection Proxy +--------------------------------- + +The baremetal introspection high-level interface is available through +the ``baremetal_introspection`` member of a +:class:`~openstack.connection.Connection` object. +The ``baremetal_introspection`` member will only be added if the service is +detected. + +Introspection Process Operations +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. autoclass:: openstack.baremetal_introspection.v1._proxy.Proxy + + .. automethod:: openstack.baremetal_introspection.v1._proxy.Proxy.introspections + .. automethod:: openstack.baremetal_introspection.v1._proxy.Proxy.get_introspection + .. automethod:: openstack.baremetal_introspection.v1._proxy.Proxy.get_introspection_data + .. automethod:: openstack.baremetal_introspection.v1._proxy.Proxy.start_introspection + .. automethod:: openstack.baremetal_introspection.v1._proxy.Proxy.wait_for_introspection + .. automethod:: openstack.baremetal_introspection.v1._proxy.Proxy.abort_introspection diff --git a/doc/source/user/resources/baremetal_introspection/index.rst b/doc/source/user/resources/baremetal_introspection/index.rst new file mode 100644 index 000000000..4f1b371c7 --- /dev/null +++ b/doc/source/user/resources/baremetal_introspection/index.rst @@ -0,0 +1,7 @@ +Baremetal Introspection Resources +================================= + +.. toctree:: + :maxdepth: 1 + + v1/introspection diff --git a/doc/source/user/resources/baremetal_introspection/v1/introspection.rst b/doc/source/user/resources/baremetal_introspection/v1/introspection.rst new file mode 100644 index 000000000..6275e254b --- /dev/null +++ b/doc/source/user/resources/baremetal_introspection/v1/introspection.rst @@ -0,0 +1,13 @@ +openstack.baremetal_introspection.v1.Introspection +================================================== + +.. automodule:: openstack.baremetal_introspection.v1.introspection + +The Introspection Class +----------------------- + +The ``Introspection`` class inherits from +:class:`~openstack.resource.Resource`. + +.. autoclass:: openstack.baremetal_introspection.v1.introspection.Introspection + :members: diff --git a/openstack/baremetal_introspection/__init__.py b/openstack/baremetal_introspection/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openstack/baremetal_introspection/baremetal_introspection_service.py b/openstack/baremetal_introspection/baremetal_introspection_service.py new file mode 100644 index 000000000..aec3adbab --- /dev/null +++ b/openstack/baremetal_introspection/baremetal_introspection_service.py @@ -0,0 +1,22 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from openstack import service_description +from openstack.baremetal_introspection.v1 import _proxy + + +class BaremetalIntrospectionService(service_description.ServiceDescription): + """The bare metal introspection service.""" + + supported_versions = { + '1': _proxy.Proxy, + } diff --git a/openstack/baremetal_introspection/v1/__init__.py b/openstack/baremetal_introspection/v1/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openstack/baremetal_introspection/v1/_proxy.py b/openstack/baremetal_introspection/v1/_proxy.py new file mode 100644 index 000000000..23b7ff2b8 --- /dev/null +++ b/openstack/baremetal_introspection/v1/_proxy.py @@ -0,0 +1,130 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from openstack import _log +from openstack.baremetal_introspection.v1 import introspection as _introspect +from openstack import exceptions +from openstack import proxy + + +_logger = _log.setup_logging('openstack') + + +class Proxy(proxy.Proxy): + + def introspections(self, **query): + """Retrieve a generator of introspection records. + + :param dict query: Optional query parameters to be sent to restrict + the records to be returned. Available parameters include: + + * ``fields``: A list containing one or more fields to be returned + in the response. This may lead to some performance gain + because other fields of the resource are not refreshed. + * ``limit``: Requests at most the specified number of items be + returned from the query. + * ``marker``: Specifies the ID of the last-seen introspection. Use + the ``limit`` parameter to make an initial limited request and + use the ID of the last-seen introspection from the response as + the ``marker`` value in a subsequent limited request. + * ``sort_dir``: Sorts the response by the requested sort direction. + A valid value is ``asc`` (ascending) or ``desc`` + (descending). Default is ``asc``. You can specify multiple + pairs of sort key and sort direction query parameters. If + you omit the sort direction in a pair, the API uses the + natural sorting direction of the server attribute that is + provided as the ``sort_key``. + * ``sort_key``: Sorts the response by the this attribute value. + Default is ``id``. You can specify multiple pairs of sort + key and sort direction query parameters. If you omit the + sort direction in a pair, the API uses the natural sorting + direction of the server attribute that is provided as the + ``sort_key``. + + :returns: A generator of :class:`~.introspection.Introspection` + objects + """ + return _introspect.Introspection.list(self, **query) + + def start_introspection(self, node): + """Create a new introspection from attributes. + + :param node: The value can be either the name or ID of a node or + a :class:`~openstack.baremetal.v1.node.Node` instance. + + :returns: :class:`~.introspection.Introspection` instance. + """ + return self._create(_introspect.Introspection, id=node) + + def get_introspection(self, introspection): + """Get a specific introspection. + + :param introspection: The value can be the name or ID of an + introspection (matching bare metal node name or ID) or + an :class:`~.introspection.Introspection` instance. + :returns: :class:`~.introspection.Introspection` instance. + :raises: :class:`~openstack.exceptions.ResourceNotFound` when no + introspection matching the name or ID could be found. + """ + return self._get(_introspect.Introspection, introspection) + + def get_introspection_data(self, introspection): + """Get introspection data. + + :param introspection: The value can be the name or ID of an + introspection (matching bare metal node name or ID) or + an :class:`~.introspection.Introspection` instance. + :returns: introspection data from the most recent successful run. + :rtype: dict + """ + res = self._get_resource(_introspect.Introspection, introspection) + return res.get_data(self) + + def abort_introspection(self, introspection, ignore_missing=True): + """Abort an introspection. + + Note that the introspection is not aborted immediately, you may use + `wait_for_introspection` with `ignore_error=True`. + + :param introspection: The value can be the name or ID of an + introspection (matching bare metal node name or ID) or + an :class:`~.introspection.Introspection` instance. + :param bool ignore_missing: When set to ``False``, an exception + :class:`~openstack.exceptions.ResourceNotFound` will be raised + when the introspection could not be found. When set to ``True``, no + exception will be raised when attempting to abort a non-existent + introspection. + :returns: nothing + """ + res = self._get_resource(_introspect.Introspection, introspection) + try: + res.abort(self) + except exceptions.ResourceNotFound: + if not ignore_missing: + raise + + def wait_for_introspection(self, introspection, timeout=None, + ignore_error=False): + """Wait for the introspection to finish. + + :param introspection: The value can be the name or ID of an + introspection (matching bare metal node name or ID) or + an :class:`~.introspection.Introspection` instance. + :param timeout: How much (in seconds) to wait for the introspection. + The value of ``None`` (the default) means no client-side timeout. + :param ignore_error: If ``True``, this call will raise an exception + if the introspection reaches the ``error`` state. Otherwise the + error state is considered successful and the call returns. + :returns: :class:`~.introspection.Introspection` instance. + """ + res = self._get_resource(_introspect.Introspection, introspection) + return res.wait(self, timeout=timeout, ignore_error=ignore_error) diff --git a/openstack/baremetal_introspection/v1/introspection.py b/openstack/baremetal_introspection/v1/introspection.py new file mode 100644 index 000000000..e560f38a5 --- /dev/null +++ b/openstack/baremetal_introspection/v1/introspection.py @@ -0,0 +1,131 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from openstack import _log +from openstack.baremetal.v1 import _common +from openstack import exceptions +from openstack import resource +from openstack import utils + + +_logger = _log.setup_logging('openstack') + + +class Introspection(resource.Resource): + + resources_key = 'introspection' + base_path = '/introspection' + + # capabilities + allow_create = True + allow_fetch = True + allow_commit = False + allow_delete = True + allow_list = True + + # created via POST with ID + create_method = 'POST' + create_requires_id = True + create_returns_body = False + + #: Timestamp at which the introspection was finished. + finished_at = resource.Body('finished_at') + #: The last error message (if any). + error = resource.Body('error') + #: The UUID of the introspection (matches the node UUID). + id = resource.Body('uuid', alternate_id=True) + #: Whether introspection is finished. + is_finished = resource.Body('finished', type=bool) + #: A list of relative links, including the self and bookmark links. + links = resource.Body('links', type=list) + #: Timestamp at which the introspection was started. + started_at = resource.Body('started_at') + #: The current introspection state. + state = resource.Body('state') + + def abort(self, session): + """Abort introspection. + + :param session: The session to use for making this request. + :type session: :class:`~keystoneauth1.adapter.Adapter` + """ + if self.is_finished: + return + + session = self._get_session(session) + + version = self._get_microversion_for(session, 'delete') + request = self._prepare_request(requires_id=True) + request.url = utils.urljoin(request.url, 'abort') + response = session.post( + request.url, headers=request.headers, microversion=version, + retriable_status_codes=_common.RETRIABLE_STATUS_CODES) + msg = ("Failed to abort introspection for node {id}" + .format(id=self.id)) + exceptions.raise_from_response(response, error_message=msg) + + def get_data(self, session): + """Get introspection data. + + Note that the introspection data format is not stable and can vary + from environment to environment. + + :param session: The session to use for making this request. + :type session: :class:`~keystoneauth1.adapter.Adapter` + :returns: introspection data from the most recent successful run. + :rtype: dict + """ + session = self._get_session(session) + + version = self._get_microversion_for(session, 'fetch') + request = self._prepare_request(requires_id=True) + request.url = utils.urljoin(request.url, 'data') + response = session.get( + request.url, headers=request.headers, microversion=version) + msg = ("Failed to fetch introspection data for node {id}" + .format(id=self.id)) + exceptions.raise_from_response(response, error_message=msg) + return response.json() + + def wait(self, session, timeout=None, ignore_error=False): + """Wait for the node to reach the expected state. + + :param session: The session to use for making this request. + :type session: :class:`~keystoneauth1.adapter.Adapter` + :param timeout: How much (in seconds) to wait for the introspection. + The value of ``None`` (the default) means no client-side timeout. + :param ignore_error: If ``True``, this call will raise an exception + if the introspection reaches the ``error`` state. Otherwise the + error state is considered successful and the call returns. + :return: This :class:`Introspection` instance. + """ + if self._check_state(ignore_error): + return self + + for count in utils.iterate_timeout( + timeout, + "Timeout waiting for introspection on node %s" % self.id): + self.fetch(session) + if self._check_state(ignore_error): + return self + + _logger.debug('Still waiting for introspection of node %(node)s, ' + 'the current state is "%(state)s"', + {'node': self.id, 'state': self.state}) + + def _check_state(self, ignore_error): + if self.state == 'error' and not ignore_error: + raise exceptions.SDKException( + "Introspection of node %(node)s failed: %(error)s" % + {'node': self.id, 'error': self.error}) + else: + return self.is_finished diff --git a/openstack/config/defaults.json b/openstack/config/defaults.json index 3eb5213c7..2d154e3a9 100644 --- a/openstack/config/defaults.json +++ b/openstack/config/defaults.json @@ -1,6 +1,7 @@ { "auth_type": "password", "baremetal_status_code_retries": 5, + "baremetal_introspection_status_code_retries": 5, "image_status_code_retries": 5, "disable_vendor_agent": {}, "interface": "public", diff --git a/openstack/resource.py b/openstack/resource.py index fb88d2087..7b1007700 100644 --- a/openstack/resource.py +++ b/openstack/resource.py @@ -406,8 +406,12 @@ class Resource(dict): #: Do calls for this resource require an id requires_id = True + #: Whether create requires an ID (determined from method if None). + create_requires_id = None #: Do responses for this resource have bodies has_body = True + #: Does create returns a body (if False requires ID), defaults to has_body + create_returns_body = None #: Maximum microversion to use for getting/creating/updating the Resource _max_microversion = None @@ -1104,15 +1108,18 @@ class Resource(dict): session = self._get_session(session) microversion = self._get_microversion_for(session, 'create') + requires_id = (self.create_requires_id + if self.create_requires_id is not None + else self.create_method == 'PUT') if self.create_method == 'PUT': - request = self._prepare_request(requires_id=True, + request = self._prepare_request(requires_id=requires_id, prepend_key=prepend_key, base_path=base_path) response = session.put(request.url, json=request.body, headers=request.headers, microversion=microversion) elif self.create_method == 'POST': - request = self._prepare_request(requires_id=False, + request = self._prepare_request(requires_id=requires_id, prepend_key=prepend_key, base_path=base_path) response = session.post(request.url, @@ -1122,8 +1129,14 @@ class Resource(dict): raise exceptions.ResourceFailure( msg="Invalid create method: %s" % self.create_method) + has_body = (self.has_body if self.create_returns_body is None + else self.create_returns_body) self.microversion = microversion - self._translate_response(response) + self._translate_response(response, has_body=has_body) + # direct comparision to False since we need to rule out None + if self.has_body and self.create_returns_body is False: + # fetch the body if it's required but not returned by create + return self.fetch(session) return self def fetch(self, session, requires_id=True, diff --git a/openstack/tests/unit/baremetal_introspection/__init__.py b/openstack/tests/unit/baremetal_introspection/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openstack/tests/unit/baremetal_introspection/v1/__init__.py b/openstack/tests/unit/baremetal_introspection/v1/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openstack/tests/unit/baremetal_introspection/v1/test_proxy.py b/openstack/tests/unit/baremetal_introspection/v1/test_proxy.py new file mode 100644 index 000000000..92eb50eaf --- /dev/null +++ b/openstack/tests/unit/baremetal_introspection/v1/test_proxy.py @@ -0,0 +1,142 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import mock + +from keystoneauth1 import adapter + +from openstack.baremetal_introspection.v1 import _proxy +from openstack.baremetal_introspection.v1 import introspection +from openstack import exceptions +from openstack.tests.unit import base +from openstack.tests.unit import test_proxy_base + + +class TestBaremetalIntrospectionProxy(test_proxy_base.TestProxyBase): + + def setUp(self): + super(TestBaremetalIntrospectionProxy, self).setUp() + self.proxy = _proxy.Proxy(self.session) + + def test_create_introspection(self): + self.verify_create(self.proxy.start_introspection, + introspection.Introspection, + method_kwargs={'node': 'abcd'}, + expected_kwargs={'id': 'abcd'}) + + def test_get_introspection(self): + self.verify_get(self.proxy.get_introspection, + introspection.Introspection) + + +@mock.patch('time.sleep', lambda _sec: None) +@mock.patch.object(introspection.Introspection, 'fetch', autospec=True) +class TestWaitForIntrospection(base.TestCase): + + def setUp(self): + super(TestWaitForIntrospection, self).setUp() + self.session = mock.Mock(spec=adapter.Adapter) + self.proxy = _proxy.Proxy(self.session) + self.fake = {'state': 'waiting', 'error': None, 'finished': False} + self.introspection = introspection.Introspection(**self.fake) + + def test_already_finished(self, mock_fetch): + self.introspection.is_finished = True + self.introspection.state = 'finished' + result = self.proxy.wait_for_introspection(self.introspection) + self.assertIs(result, self.introspection) + self.assertFalse(mock_fetch.called) + + def test_wait(self, mock_fetch): + marker = [False] # mutable object to modify in the closure + + def _side_effect(allocation, session): + if marker[0]: + self.introspection.state = 'finished' + self.introspection.is_finished = True + else: + self.introspection.state = 'processing' + marker[0] = True + + mock_fetch.side_effect = _side_effect + result = self.proxy.wait_for_introspection(self.introspection) + self.assertIs(result, self.introspection) + self.assertEqual(2, mock_fetch.call_count) + + def test_timeout(self, mock_fetch): + self.assertRaises(exceptions.ResourceTimeout, + self.proxy.wait_for_introspection, + self.introspection, + timeout=0.001) + mock_fetch.assert_called_with(self.introspection, self.proxy) + + def test_failure(self, mock_fetch): + def _side_effect(allocation, session): + self.introspection.state = 'error' + self.introspection.is_finished = True + self.introspection.error = 'boom' + + mock_fetch.side_effect = _side_effect + self.assertRaisesRegex(exceptions.SDKException, 'boom', + self.proxy.wait_for_introspection, + self.introspection) + mock_fetch.assert_called_once_with(self.introspection, self.proxy) + + def test_failure_ignored(self, mock_fetch): + def _side_effect(allocation, session): + self.introspection.state = 'error' + self.introspection.is_finished = True + self.introspection.error = 'boom' + + mock_fetch.side_effect = _side_effect + result = self.proxy.wait_for_introspection(self.introspection, + ignore_error=True) + self.assertIs(result, self.introspection) + mock_fetch.assert_called_once_with(self.introspection, self.proxy) + + +@mock.patch.object(_proxy.Proxy, 'request', autospec=True) +class TestAbortIntrospection(base.TestCase): + + def setUp(self): + super(TestAbortIntrospection, self).setUp() + self.session = mock.Mock(spec=adapter.Adapter) + self.proxy = _proxy.Proxy(self.session) + self.fake = {'id': '1234', 'finished': False} + self.introspection = introspection.Introspection(**self.fake) + + def test_abort(self, mock_request): + mock_request.return_value.status_code = 202 + self.proxy.abort_introspection(self.introspection) + mock_request.assert_called_once_with( + self.proxy, 'introspection/1234/abort', 'POST', + headers=mock.ANY, microversion=mock.ANY, + retriable_status_codes=[409, 503]) + + +@mock.patch.object(_proxy.Proxy, 'request', autospec=True) +class TestGetData(base.TestCase): + + def setUp(self): + super(TestGetData, self).setUp() + self.session = mock.Mock(spec=adapter.Adapter) + self.proxy = _proxy.Proxy(self.session) + self.fake = {'id': '1234', 'finished': False} + self.introspection = introspection.Introspection(**self.fake) + + def test_get_data(self, mock_request): + mock_request.return_value.status_code = 200 + data = self.proxy.get_introspection_data(self.introspection) + mock_request.assert_called_once_with( + self.proxy, 'introspection/1234/data', 'GET', + headers=mock.ANY, microversion=mock.ANY) + self.assertIs(data, mock_request.return_value.json.return_value) diff --git a/openstack/tests/unit/test_resource.py b/openstack/tests/unit/test_resource.py index cf14a1bda..9042de7bf 100644 --- a/openstack/tests/unit/test_resource.py +++ b/openstack/tests/unit/test_resource.py @@ -1120,7 +1120,8 @@ class TestResourceActions(base.TestCase): microversion=microversion) self.assertEqual(sot.microversion, microversion) - sot._translate_response.assert_called_once_with(self.response) + sot._translate_response.assert_called_once_with(self.response, + has_body=sot.has_body) self.assertEqual(result, sot) def test_put_create(self): diff --git a/releasenotes/notes/baremetal-introspection-973351b3ee76309e.yaml b/releasenotes/notes/baremetal-introspection-973351b3ee76309e.yaml new file mode 100644 index 000000000..7ab2885bc --- /dev/null +++ b/releasenotes/notes/baremetal-introspection-973351b3ee76309e.yaml @@ -0,0 +1,4 @@ +--- +features: + - | + Adds support for the bare metal introspection service.