diff --git a/doc/source/user/guides/baremetal.rst b/doc/source/user/guides/baremetal.rst index 9af1a86d2..7189511a1 100644 --- a/doc/source/user/guides/baremetal.rst +++ b/doc/source/user/guides/baremetal.rst @@ -1,9 +1,64 @@ Using OpenStack Baremetal ========================= -Before working with the Baremetal service, you'll need to create a +Before working with the Bare Metal service, you'll need to create a connection to your OpenStack cloud by following the :doc:`connect` user guide. This will provide you with the ``conn`` variable used in the examples below. -.. TODO(Qiming): Implement this guide +.. contents:: Table of Contents + :local: + +The primary resource of the Bare Metal service is the **node**. + +CRUD operations +~~~~~~~~~~~~~~~ + +List Nodes +---------- + +A **node** is a bare metal machine. + +.. literalinclude:: ../examples/baremetal/list.py + :pyobject: list_nodes + +Full example: `baremetal resource list`_ + +Provisioning operations +~~~~~~~~~~~~~~~~~~~~~~~ + +Provisioning actions are the main way to manipulate the nodes. See `Bare Metal +service states documentation`_ for details. + +Manage and inspect Node +----------------------- + +*Managing* a node in the ``enroll`` provision state validates the management +(IPMI, Redfish, etc) credentials and moves the node to the ``manageable`` +state. *Managing* a node in the ``available`` state moves it to the +``manageable`` state. In this state additional actions, such as configuring +RAID or inspecting, are available. + +*Inspecting* a node detects its properties by either talking to its BMC or by +booting a special ramdisk. + +.. literalinclude:: ../examples/baremetal/provisioning.py + :pyobject: manage_and_inspect_node + +Full example: `baremetal provisioning`_ + +Provide Node +------------ + +*Providing* a node in the ``manageable`` provision state makes it available +for deployment. + +.. literalinclude:: ../examples/baremetal/provisioning.py + :pyobject: provide_node + +Full example: `baremetal provisioning`_ + + +.. _baremetal resource list: http://git.openstack.org/cgit/openstack/openstacksdk/tree/examples/baremetal/list.py +.. _baremetal provisioning: http://git.openstack.org/cgit/openstack/openstacksdk/tree/examples/baremetal/provisioning.py +.. _Bare Metal service states documentation: https://docs.openstack.org/ironic/latest/contributor/states.html diff --git a/doc/source/user/proxies/baremetal.rst b/doc/source/user/proxies/baremetal.rst index f3772bb8b..578724074 100644 --- a/doc/source/user/proxies/baremetal.rst +++ b/doc/source/user/proxies/baremetal.rst @@ -22,6 +22,8 @@ Node Operations .. automethod:: openstack.baremetal.v1._proxy.Proxy.get_node .. automethod:: openstack.baremetal.v1._proxy.Proxy.find_node .. automethod:: openstack.baremetal.v1._proxy.Proxy.nodes + .. automethod:: openstack.baremetal.v1._proxy.Proxy.set_node_provision_state + .. automethod:: openstack.baremetal.v1._proxy.Proxy.wait_for_nodes_provision_state Port Operations ^^^^^^^^^^^^^^^ diff --git a/examples/baremetal/list.py b/examples/baremetal/list.py new file mode 100644 index 000000000..a5595abd1 --- /dev/null +++ b/examples/baremetal/list.py @@ -0,0 +1,25 @@ +# 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. + +""" +List resources from the Bare Metal service. +""" + + +def list_nodes(conn): + print("List Nodes:") + + for node in conn.baremetal.nodes(): + print(node) + + +# TODO(dtantsur): other resources diff --git a/examples/baremetal/provisioning.py b/examples/baremetal/provisioning.py new file mode 100644 index 000000000..36ff49e13 --- /dev/null +++ b/examples/baremetal/provisioning.py @@ -0,0 +1,35 @@ +# 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. + +""" +Operations with the provision state in the Bare Metal service. +""" + +from __future__ import print_function + + +def manage_and_inspect_node(conn, uuid): + node = conn.baremetal.find_node(uuid) + print('Before:', node.provision_state) + conn.baremetal.set_node_provision_state(node, 'manage') + conn.baremetal.wait_for_nodes_provision_state([node], 'manageable') + conn.baremetal.set_node_provision_state(node, 'inspect') + res = conn.baremetal.wait_for_nodes_provision_state([node], 'manageable') + print('After:', res[0].provision_state) + + +def provide_node(conn, uuid): + node = conn.baremetal.find_node(uuid) + print('Before:', node.provision_state) + conn.baremetal.set_node_provision_state(node, 'provide') + res = conn.baremetal.wait_for_nodes_provision_state([node], 'available') + print('After:', res[0].provision_state) diff --git a/openstack/baremetal/v1/_common.py b/openstack/baremetal/v1/_common.py new file mode 100644 index 000000000..16b5506ae --- /dev/null +++ b/openstack/baremetal/v1/_common.py @@ -0,0 +1,48 @@ +# 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. + + +RETRIABLE_STATUS_CODES = [ + # HTTP Conflict - happens if a node is locked + 409, + # HTTP Service Unavailable happens if there's no free conductor + 503 +] +"""HTTP status codes that should be retried.""" + + +PROVISIONING_VERSIONS = { + 'abort': 13, + 'adopt': 17, + 'clean': 15, + 'inspect': 6, + 'manage': 4, + 'provide': 4, + 'rescue': 38, + 'unrescue': 38, +} +"""API microversions introducing provisioning verbs.""" + + +# Based on https://docs.openstack.org/ironic/latest/contributor/states.html +EXPECTED_STATES = { + 'active': 'active', + 'adopt': 'available', + 'clean': 'manageable', + 'deleted': 'available', + 'inspect': 'manageable', + 'manage': 'manageable', + 'provide': 'available', + 'rebuild': 'active', + 'rescue': 'rescue', +} +"""Mapping of provisioning actions to expected stable states.""" diff --git a/openstack/baremetal/v1/_proxy.py b/openstack/baremetal/v1/_proxy.py index ba28099c0..a838faa62 100644 --- a/openstack/baremetal/v1/_proxy.py +++ b/openstack/baremetal/v1/_proxy.py @@ -10,6 +10,7 @@ # License for the specific language governing permissions and limitations # under the License. +from openstack import _log from openstack.baremetal.v1 import chassis as _chassis from openstack.baremetal.v1 import driver as _driver from openstack.baremetal.v1 import node as _node @@ -19,6 +20,9 @@ from openstack import proxy from openstack import utils +_logger = _log.setup_logging('openstack') + + class Proxy(proxy.Proxy): def chassis(self, details=False, **query): @@ -240,6 +244,87 @@ class Proxy(proxy.Proxy): """ return self._update(_node.Node, node, **attrs) + def set_node_provision_state(self, node, target, config_drive=None, + clean_steps=None, rescue_password=None, + wait=False, timeout=None): + """Run an action modifying node's provision state. + + This call is asynchronous, it will return success as soon as the Bare + Metal service acknowledges the request. + + :param node: The value can be the name or ID of a node or a + :class:`~openstack.baremetal.v1.node.Node` instance. + :param target: Provisioning action, e.g. ``active``, ``provide``. + See the Bare Metal service documentation for available actions. + :param config_drive: Config drive to pass to the node, only valid + for ``active` and ``rebuild`` targets. + :param clean_steps: Clean steps to execute, only valid for ``clean`` + target. + :param rescue_password: Password for the rescue operation, only valid + for ``rescue`` target. + :param wait: Whether to wait for the node to get into the expected + state. The expected state is determined from a combination of + the current provision state and ``target``. + :param timeout: If ``wait`` is set to ``True``, specifies how much (in + seconds) to wait for the expected state to be reached. The value of + ``None`` (the default) means no client-side timeout. + + :returns: The updated :class:`~openstack.baremetal.v1.node.Node` + :raises: ValueError if ``config_drive``, ``clean_steps`` or + ``rescue_password`` are provided with an invalid ``target``. + """ + res = self._get_resource(_node.Node, node) + return res.set_provision_state(self, target, config_drive=config_drive, + clean_steps=clean_steps, + rescue_password=rescue_password, + wait=wait, timeout=timeout) + + def wait_for_nodes_provision_state(self, nodes, expected_state, + timeout=None, + abort_on_failed_state=True): + """Wait for the nodes to reach the expected state. + + :param nodes: List of nodes - name, ID or + :class:`~openstack.baremetal.v1.node.Node` instance. + :param expected_state: The expected provisioning state to reach. + :param timeout: If ``wait`` is set to ``True``, specifies how much (in + seconds) to wait for the expected state to be reached. The value of + ``None`` (the default) means no client-side timeout. + :param abort_on_failed_state: If ``True`` (the default), abort waiting + if any node reaches a failure state which does not match the + expected one. Note that the failure state for ``enroll`` -> + ``manageable`` transition is ``enroll`` again. + + :return: The list of :class:`~openstack.baremetal.v1.node.Node` + instances that reached the requested state. + """ + log_nodes = ', '.join(n.id if isinstance(n, _node.Node) else n + for n in nodes) + + finished = [] + remaining = nodes + for count in utils.iterate_timeout( + timeout, + "Timeout waiting for nodes %(nodes)s to reach " + "target state '%(state)s'" % {'nodes': log_nodes, + 'state': expected_state}): + nodes = [self.get_node(n) for n in remaining] + remaining = [] + for n in nodes: + if n._check_state_reached(self, expected_state, + abort_on_failed_state): + finished.append(n) + else: + remaining.append(n) + + if not remaining: + return finished + + _logger.debug('Still waiting for nodes %(nodes)s to reach state ' + '"%(target)s"', + {'nodes': ', '.join(n.id for n in remaining), + 'target': expected_state}) + def delete_node(self, node, ignore_missing=True): """Delete a node. diff --git a/openstack/baremetal/v1/node.py b/openstack/baremetal/v1/node.py index 3ba698cff..ac5b2c9fa 100644 --- a/openstack/baremetal/v1/node.py +++ b/openstack/baremetal/v1/node.py @@ -10,8 +10,15 @@ # License for the specific language governing permissions and limitations # under the License. +from openstack import _log from openstack.baremetal import baremetal_service +from openstack.baremetal.v1 import _common +from openstack import exceptions from openstack import resource +from openstack import utils + + +_logger = _log.setup_logging('openstack') class Node(resource.Resource): @@ -113,6 +120,160 @@ class Node(resource.Resource): #: Timestamp at which the node was last updated. updated_at = resource.Body("updated_at") + def set_provision_state(self, session, target, config_drive=None, + clean_steps=None, rescue_password=None, + wait=False, timeout=True): + """Run an action modifying this node's provision state. + + This call is asynchronous, it will return success as soon as the Bare + Metal service acknowledges the request. + + :param session: The session to use for making this request. + :type session: :class:`~keystoneauth1.adapter.Adapter` + :param target: Provisioning action, e.g. ``active``, ``provide``. + See the Bare Metal service documentation for available actions. + :param config_drive: Config drive to pass to the node, only valid + for ``active` and ``rebuild`` targets. + :param clean_steps: Clean steps to execute, only valid for ``clean`` + target. + :param rescue_password: Password for the rescue operation, only valid + for ``rescue`` target. + :param wait: Whether to wait for the target state to be reached. + :param timeout: Timeout (in seconds) to wait for the target state to be + reached. If ``None``, wait without timeout. + + :return: This :class:`Node` instance. + :raises: ValueError if ``config_drive``, ``clean_steps`` or + ``rescue_password`` are provided with an invalid ``target``. + """ + session = self._get_session(session) + + if target in _common.PROVISIONING_VERSIONS: + version = '1.%d' % _common.PROVISIONING_VERSIONS[target] + else: + if config_drive and target == 'rebuild': + version = '1.35' + else: + version = None + version = utils.pick_microversion(session, version) + + body = {'target': target} + if config_drive: + if target not in ('active', 'rebuild'): + raise ValueError('Config drive can only be provided with ' + '"active" and "rebuild" targets') + # Not a typo - ironic accepts "configdrive" (without underscore) + body['configdrive'] = config_drive + + if clean_steps is not None: + if target != 'clean': + raise ValueError('Clean steps can only be provided with ' + '"clean" target') + body['clean_steps'] = clean_steps + + if rescue_password is not None: + if target != 'rescue': + raise ValueError('Rescue password can only be provided with ' + '"rescue" target') + body['rescue_password'] = rescue_password + + if wait: + try: + expected_state = _common.EXPECTED_STATES[target] + except KeyError: + raise ValueError('For target %s the expected state is not ' + 'known, cannot wait for it' % target) + + request = self._prepare_request(requires_id=True) + request.url = utils.urljoin(request.url, 'states', 'provision') + response = session.put( + request.url, json=body, + headers=request.headers, microversion=version, + retriable_status_codes=_common.RETRIABLE_STATUS_CODES) + + msg = ("Failed to set provision state for bare metal node {node} " + "to {target}".format(node=self.id, target=target)) + exceptions.raise_from_response(response, error_message=msg) + + if wait: + return self.wait_for_provision_state(session, + expected_state, + timeout=timeout) + else: + return self.get(session) + + def wait_for_provision_state(self, session, expected_state, timeout=None, + abort_on_failed_state=True): + """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 expected_state: The expected provisioning state to reach. + :param timeout: If ``wait`` is set to ``True``, specifies how much (in + seconds) to wait for the expected state to be reached. The value of + ``None`` (the default) means no client-side timeout. + :param abort_on_failed_state: If ``True`` (the default), abort waiting + if the node reaches a failure state which does not match the + expected one. Note that the failure state for ``enroll`` -> + ``manageable`` transition is ``enroll`` again. + + :return: This :class:`Node` instance. + """ + for count in utils.iterate_timeout( + timeout, + "Timeout waiting for node %(node)s to reach " + "target state '%(state)s'" % {'node': self.id, + 'state': expected_state}): + self.get(session) + if self._check_state_reached(session, expected_state, + abort_on_failed_state): + return self + + _logger.debug('Still waiting for node %(node)s to reach state ' + '"%(target)s", the current state is "%(state)s"', + {'node': self.id, 'target': expected_state, + 'state': self.provision_state}) + + def _check_state_reached(self, session, expected_state, + abort_on_failed_state=True): + """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 expected_state: The expected provisioning state to reach. + :param abort_on_failed_state: If ``True`` (the default), abort waiting + if the node reaches a failure state which does not match the + expected one. Note that the failure state for ``enroll`` -> + ``manageable`` transition is ``enroll`` again. + + :return: ``True`` if the target state is reached + :raises: SDKException if ``abort_on_failed_state`` is ``True`` and + a failure state is reached. + """ + # NOTE(dtantsur): microversion 1.2 changed None to available + if (self.provision_state == expected_state or + (expected_state == 'available' and + self.provision_state is None)): + return True + elif not abort_on_failed_state: + return False + + if self.provision_state.endswith(' failed'): + raise exceptions.SDKException( + "Node %(node)s reached failure state \"%(state)s\"; " + "the last error is %(error)s" % + {'node': self.id, 'state': self.provision_state, + 'error': self.last_error}) + # Special case: a failure state for "manage" transition can be + # "enroll" + elif (expected_state == 'manageable' and + self.provision_state == 'enroll' and self.last_error): + raise exceptions.SDKException( + "Node %(node)s could not reach state manageable: " + "failed to verify management credentials; " + "the last error is %(error)s" % + {'node': self.id, 'error': self.last_error}) + class NodeDetail(Node): diff --git a/openstack/cloud/openstackcloud.py b/openstack/cloud/openstackcloud.py index eb21ab4e4..554906dfe 100755 --- a/openstack/cloud/openstackcloud.py +++ b/openstack/cloud/openstackcloud.py @@ -9607,7 +9607,7 @@ class OpenStackCloud(_normalize.Normalizer): config drive to be utilized. :param string name_or_id: The Name or UUID value representing the - baremetal node. + baremetal node. :param string state: The desired provision state for the baremetal node. :param string configdrive: An optional URL or file or path @@ -9629,46 +9629,10 @@ class OpenStackCloud(_normalize.Normalizer): :returns: ``munch.Munch`` representing the current state of the machine upon exit of the method. """ - # NOTE(TheJulia): Default microversion for this call is 1.6. - # Setting locally until we have determined our master plan regarding - # microversion handling. - version = "1.6" - msg = ("Baremetal machine node failed change provision state to " - "{state}".format(state=state)) - - url = '/nodes/{node_id}/states/provision'.format( - node_id=name_or_id) - payload = {'target': state} - if configdrive: - payload['configdrive'] = configdrive - - machine = _utils._call_client_and_retry(self._baremetal_client.put, - url, - retry_on=[409, 503], - json=payload, - error_message=msg, - microversion=version) - if wait: - for count in utils.iterate_timeout( - timeout, - "Timeout waiting for node transition to " - "target state of '%s'" % state): - machine = self.get_machine(name_or_id) - if 'failed' in machine['provision_state']: - raise exc.OpenStackCloudException( - "Machine encountered a failure.") - # NOTE(TheJulia): This performs matching if the requested - # end state matches the state the node has reached. - if state in machine['provision_state']: - break - # NOTE(TheJulia): This performs matching for cases where - # the reqeusted state action ends in available state. - if ("available" in machine['provision_state'] and - state in ["provide", "deleted"]): - break - else: - machine = self.get_machine(name_or_id) - return machine + node = self.baremetal.set_node_provision_state( + name_or_id, target=state, config_drive=configdrive, + wait=wait, timeout=timeout) + return node._to_munch() def set_machine_maintenance_state( self, diff --git a/openstack/config/defaults.json b/openstack/config/defaults.json index c17cc041c..17d69e264 100644 --- a/openstack/config/defaults.json +++ b/openstack/config/defaults.json @@ -2,6 +2,7 @@ "application_catalog_api_version": "1", "auth_type": "password", "baremetal_api_version": "1", + "baremetal_status_code_retries": 5, "block_storage_api_version": "2", "clustering_api_version": "1", "container_api_version": "1", diff --git a/openstack/resource.py b/openstack/resource.py index 34dfc2e20..9742d212d 100644 --- a/openstack/resource.py +++ b/openstack/resource.py @@ -35,6 +35,7 @@ import collections import itertools from keystoneauth1 import adapter +import munch from requests import structures from openstack import _log @@ -573,7 +574,8 @@ class Resource(object): """ return cls(_synchronized=synchronized, **obj) - def to_dict(self, body=True, headers=True, ignore_none=False): + def to_dict(self, body=True, headers=True, ignore_none=False, + original_names=False): """Return a dictionary of this resource's contents :param bool body: Include the :class:`~openstack.resource.Body` @@ -583,6 +585,8 @@ class Resource(object): :param bool ignore_none: When True, exclude key/value pairs where the value is None. This will exclude attributes that the server hasn't returned. + :param bool original_names: When True, use attribute names as they + were received from the server. :return: A dictionary of key/value pairs where keys are named as they exist as attributes of this class. @@ -608,12 +612,16 @@ class Resource(object): # Since we're looking at class definitions we need to include # subclasses, so check the whole MRO. for klass in self.__class__.__mro__: - for key, value in klass.__dict__.items(): - if isinstance(value, components): + for attr, component in klass.__dict__.items(): + if isinstance(component, components): + if original_names: + key = component.name + else: + key = attr # Make sure base classes don't end up overwriting # mappings we've found previously in subclasses. if key not in mapping: - value = getattr(self, key, None) + value = getattr(self, attr, None) if ignore_none and value is None: continue if isinstance(value, Resource): @@ -629,6 +637,11 @@ class Resource(object): return mapping + def _to_munch(self): + """Convert this resource into a Munch compatible with shade.""" + return munch.Munch(self.to_dict(body=True, headers=False, + original_names=True)) + def _prepare_request(self, requires_id=None, prepend_key=False): """Prepare a request to be sent to the server diff --git a/openstack/tests/base.py b/openstack/tests/base.py index 02b8e8965..128ec9c8e 100644 --- a/openstack/tests/base.py +++ b/openstack/tests/base.py @@ -107,3 +107,15 @@ class TestCase(base.BaseTestCase): self.addDetail(name, testtools.content.text_content( pprint.pformat(text))) self.addOnException(add_content) + + def assertSubdict(self, part, whole): + missing_keys = set(part) - set(whole) + if missing_keys: + self.fail("Keys %s are in %s but not in %s" % + (missing_keys, part, whole)) + wrong_values = [(key, part[key], whole[key]) + for key in part if part[key] != whole[key]] + if wrong_values: + self.fail("Mismatched values: %s" % + ", ".join("for %s got %s and %s" % tpl + for tpl in wrong_values)) diff --git a/openstack/tests/unit/baremetal/v1/test_node.py b/openstack/tests/unit/baremetal/v1/test_node.py index 396a9d9ad..5866ea256 100644 --- a/openstack/tests/unit/baremetal/v1/test_node.py +++ b/openstack/tests/unit/baremetal/v1/test_node.py @@ -10,9 +10,12 @@ # License for the specific language governing permissions and limitations # under the License. -from openstack.tests.unit import base +from keystoneauth1 import adapter +import mock from openstack.baremetal.v1 import node +from openstack import exceptions +from openstack.tests.unit import base # NOTE: Sample data from api-ref doc FAKE = { @@ -196,3 +199,78 @@ class TestNodeDetail(base.TestCase): self.assertEqual(FAKE['target_power_state'], sot.target_power_state) self.assertEqual(FAKE['target_raid_config'], sot.target_raid_config) self.assertEqual(FAKE['updated_at'], sot.updated_at) + + +@mock.patch('time.sleep', lambda _t: None) +@mock.patch.object(node.Node, 'get', autospec=True) +class TestNodeWaitForProvisionState(base.TestCase): + def setUp(self): + super(TestNodeWaitForProvisionState, self).setUp() + self.node = node.Node(**FAKE) + self.session = mock.Mock() + + def test_success(self, mock_get): + def _get_side_effect(_self, session): + self.node.provision_state = 'manageable' + self.assertIs(session, self.session) + + mock_get.side_effect = _get_side_effect + + node = self.node.wait_for_provision_state(self.session, 'manageable') + self.assertIs(node, self.node) + + def test_failure(self, mock_get): + def _get_side_effect(_self, session): + self.node.provision_state = 'deploy failed' + self.assertIs(session, self.session) + + mock_get.side_effect = _get_side_effect + + self.assertRaisesRegex(exceptions.SDKException, + 'failure state "deploy failed"', + self.node.wait_for_provision_state, + self.session, 'manageable') + + def test_enroll_as_failure(self, mock_get): + def _get_side_effect(_self, session): + self.node.provision_state = 'enroll' + self.node.last_error = 'power failure' + self.assertIs(session, self.session) + + mock_get.side_effect = _get_side_effect + + self.assertRaisesRegex(exceptions.SDKException, + 'failed to verify management credentials', + self.node.wait_for_provision_state, + self.session, 'manageable') + + def test_timeout(self, mock_get): + self.assertRaises(exceptions.ResourceTimeout, + self.node.wait_for_provision_state, + self.session, 'manageable', timeout=0.001) + + def test_not_abort_on_failed_state(self, mock_get): + def _get_side_effect(_self, session): + self.node.provision_state = 'deploy failed' + self.assertIs(session, self.session) + + mock_get.side_effect = _get_side_effect + + self.assertRaises(exceptions.ResourceTimeout, + self.node.wait_for_provision_state, + self.session, 'manageable', timeout=0.001, + abort_on_failed_state=False) + + +@mock.patch.object(node.Node, 'get', lambda self, session: self) +@mock.patch.object(exceptions, 'raise_from_response', mock.Mock()) +class TestNodeSetProvisionState(base.TestCase): + + def setUp(self): + super(TestNodeSetProvisionState, self).setUp() + self.node = node.Node(**FAKE) + self.session = mock.Mock(spec=adapter.Adapter, + default_microversion=None) + + def test_no_arguments(self): + self.node.set_provision_state(self.session, 'manage') diff --git a/openstack/tests/unit/baremetal/v1/test_proxy.py b/openstack/tests/unit/baremetal/v1/test_proxy.py index 4d874e89c..5193078c5 100644 --- a/openstack/tests/unit/baremetal/v1/test_proxy.py +++ b/openstack/tests/unit/baremetal/v1/test_proxy.py @@ -11,6 +11,7 @@ # under the License. import deprecation +import mock from openstack.baremetal.v1 import _proxy from openstack.baremetal.v1 import chassis @@ -18,6 +19,8 @@ from openstack.baremetal.v1 import driver from openstack.baremetal.v1 import node from openstack.baremetal.v1 import port from openstack.baremetal.v1 import port_group +from openstack import exceptions +from openstack.tests.unit import base from openstack.tests.unit import test_proxy_base @@ -162,3 +165,41 @@ class TestBaremetalProxy(test_proxy_base.TestProxyBase): def test_delete_portgroup_ignore(self): self.verify_delete(self.proxy.delete_portgroup, port_group.PortGroup, True) + + +@mock.patch('time.sleep', lambda _sec: None) +@mock.patch.object(_proxy.Proxy, 'get_node', autospec=True) +class TestWaitForNodesProvisionState(base.TestCase): + + def setUp(self): + super(TestWaitForNodesProvisionState, self).setUp() + self.session = mock.Mock() + self.proxy = _proxy.Proxy(self.session) + + def test_success(self, mock_get): + # two attempts, one node succeeds after the 1st + nodes = [mock.Mock(spec=node.Node, id=str(i)) + for i in range(3)] + for i, n in enumerate(nodes): + # 1st attempt on 1st node, 2nd attempt on 2nd node + n._check_state_reached.return_value = not (i % 2) + mock_get.side_effect = nodes + + result = self.proxy.wait_for_nodes_provision_state( + ['abcd', node.Node(id='1234')], 'fake state') + self.assertEqual([nodes[0], nodes[2]], result) + + for n in nodes: + n._check_state_reached.assert_called_once_with( + self.proxy, 'fake state', True) + + def test_timeout(self, mock_get): + mock_get.return_value._check_state_reached.return_value = False + mock_get.return_value.id = '1234' + + self.assertRaises(exceptions.ResourceTimeout, + self.proxy.wait_for_nodes_provision_state, + ['abcd', node.Node(id='1234')], 'fake state', + timeout=0.001) + mock_get.return_value._check_state_reached.assert_called_with( + self.proxy, 'fake state', True) diff --git a/openstack/tests/unit/cloud/test_baremetal_node.py b/openstack/tests/unit/cloud/test_baremetal_node.py index a433f3385..83ac06b77 100644 --- a/openstack/tests/unit/cloud/test_baremetal_node.py +++ b/openstack/tests/unit/cloud/test_baremetal_node.py @@ -764,7 +764,7 @@ class TestBaremetalNode(base.IronicTestCase): 'active', wait=True) - self.assertEqual(active_node, return_value) + self.assertSubdict(active_node, return_value) self.assert_calls() def test_node_set_provision_state_wait_timeout_fails(self): @@ -817,7 +817,7 @@ class TestBaremetalNode(base.IronicTestCase): 'active', wait=True) - self.assertEqual(self.fake_baremetal_node, return_value) + self.assertSubdict(self.fake_baremetal_node, return_value) self.assert_calls() def test_node_set_provision_state_wait_failure_cases(self): @@ -875,7 +875,7 @@ class TestBaremetalNode(base.IronicTestCase): 'provide', wait=True) - self.assertEqual(available_node, return_value) + self.assertSubdict(available_node, return_value) self.assert_calls() def test_wait_for_baremetal_node_lock_locked(self): diff --git a/openstack/utils.py b/openstack/utils.py index 8af0b07bf..4f67f3603 100644 --- a/openstack/utils.py +++ b/openstack/utils.py @@ -154,3 +154,32 @@ def supports_microversion(adapter, microversion): microversion)): return True return False + + +def pick_microversion(session, required): + """Get a new microversion if it is higher than session's default. + + :param session: The session to use for making this request. + :type session: :class:`~keystoneauth1.adapter.Adapter` + :param required: Version that is required for an action. + :type required: String or tuple or None. + :return: ``required`` as a string if the ``session``'s default is too low, + the ``session``'s default otherwise. Returns ``None`` of both + are ``None``. + :raises: TypeError if ``required`` is invalid. + """ + if required is not None: + required = discover.normalize_version_number(required) + + if session.default_microversion is not None: + default = discover.normalize_version_number( + session.default_microversion) + + if required is None: + required = default + else: + required = (default if discover.version_match(required, default) + else required) + + if required is not None: + return discover.version_to_string(required) diff --git a/releasenotes/notes/node-set-provision-state-3472cbd81c47458f.yaml b/releasenotes/notes/node-set-provision-state-3472cbd81c47458f.yaml new file mode 100644 index 000000000..f75f6dfec --- /dev/null +++ b/releasenotes/notes/node-set-provision-state-3472cbd81c47458f.yaml @@ -0,0 +1,11 @@ +--- +features: + - | + Adds ``set_provision_state`` and ``wait_for_provision_state`` to + ``openstack.baremetal.v1.Node``. + - | + Adds ``node_set_provision_state`` and ``wait_for_nodes_provision_state`` + to the baremetal Proxy. + - | + The ``node_set_provision_state`` call now supports provision states + up to the Queens release.