diff --git a/doc/source/user/support-matrix.ini b/doc/source/user/support-matrix.ini index 06cfa7d55fc7..bdd8e07569ea 100644 --- a/doc/source/user/support-matrix.ini +++ b/doc/source/user/support-matrix.ini @@ -562,7 +562,7 @@ driver-impl-libvirt-lxc=missing driver-impl-libvirt-xen=complete driver-impl-vmware=complete driver-impl-hyperv=complete -driver-impl-ironic=missing +driver-impl-ironic=complete driver-impl-libvirt-vz-vm=complete driver-impl-libvirt-vz-ct=complete driver-impl-powervm=missing diff --git a/nova/exception.py b/nova/exception.py index 30a66bc1c6c0..2d20ff312f32 100644 --- a/nova/exception.py +++ b/nova/exception.py @@ -2302,5 +2302,13 @@ class CertificateValidationNotYetAvailable(NovaException): code = 409 +class InstanceRescueFailure(NovaException): + msg_fmt = _("Failed to move instance to rescue mode: %(reason)s") + + +class InstanceUnRescueFailure(NovaException): + msg_fmt = _("Failed to unrescue instance: %(reason)s") + + class IronicAPIVersionNotAvailable(NovaException): msg_fmt = _('Ironic API version %(version)s is not available.') diff --git a/nova/tests/unit/virt/ironic/test_client_wrapper.py b/nova/tests/unit/virt/ironic/test_client_wrapper.py index 79046fb3e482..a726530c39ee 100644 --- a/nova/tests/unit/virt/ironic/test_client_wrapper.py +++ b/nova/tests/unit/virt/ironic/test_client_wrapper.py @@ -81,12 +81,12 @@ class IronicClientWrapperTestCase(test.NoDBTestCase): # nova.utils.get_ksa_adapter().get_endpoint() self.get_ksa_adapter.assert_called_once_with( 'baremetal', ksa_auth=self.get_auth_plugin.return_value, - ksa_session='session', min_version=(1, 37), + ksa_session='session', min_version=(1, 38), max_version=(1, ksa_disc.LATEST)) expected = {'session': 'session', 'max_retries': CONF.ironic.api_max_retries, 'retry_interval': CONF.ironic.api_retry_interval, - 'os_ironic_api_version': ['1.37', '1.37'], + 'os_ironic_api_version': ['1.38', '1.37'], 'ironic_url': self.get_ksa_adapter.return_value.get_endpoint.return_value} mock_ir_cli.assert_called_once_with(1, **expected) @@ -106,13 +106,13 @@ class IronicClientWrapperTestCase(test.NoDBTestCase): # nova.utils.get_endpoint_data self.get_ksa_adapter.assert_called_once_with( 'baremetal', ksa_auth=self.get_auth_plugin.return_value, - ksa_session='session', min_version=(1, 37), + ksa_session='session', min_version=(1, 38), max_version=(1, ksa_disc.LATEST)) # When get_endpoint_data raises any ServiceNotFound, None is returned. expected = {'session': 'session', 'max_retries': CONF.ironic.api_max_retries, 'retry_interval': CONF.ironic.api_retry_interval, - 'os_ironic_api_version': ['1.37', '1.37'], + 'os_ironic_api_version': ['1.38', '1.37'], 'ironic_url': None} mock_ir_cli.assert_called_once_with(1, **expected) @@ -130,7 +130,7 @@ class IronicClientWrapperTestCase(test.NoDBTestCase): expected = {'session': 'session', 'max_retries': CONF.ironic.api_max_retries, 'retry_interval': CONF.ironic.api_retry_interval, - 'os_ironic_api_version': ['1.37', '1.37'], + 'os_ironic_api_version': ['1.38', '1.37'], 'ironic_url': endpoint} mock_ir_cli.assert_called_once_with(1, **expected) diff --git a/nova/tests/unit/virt/ironic/test_driver.py b/nova/tests/unit/virt/ironic/test_driver.py index b09f98d28f7d..4051377b8e72 100644 --- a/nova/tests/unit/virt/ironic/test_driver.py +++ b/nova/tests/unit/virt/ironic/test_driver.py @@ -2816,6 +2816,123 @@ class IronicDriverSyncTestCase(IronicDriverTestCase): self.driver._pike_flavor_migration([uuids.node1]) mock_normalize.assert_not_called() + @mock.patch.object(loopingcall, 'FixedIntervalLoopingCall') + @mock.patch.object(FAKE_CLIENT.node, 'set_provision_state') + def test_rescue(self, mock_sps, mock_looping): + node = ironic_utils.get_test_node() + + fake_looping_call = FakeLoopingCall() + mock_looping.return_value = fake_looping_call + instance = fake_instance.fake_instance_obj(self.ctx, + node=node.uuid) + + self.driver.rescue(self.ctx, instance, None, None, 'xyz') + mock_sps.assert_called_once_with(node.uuid, 'rescue', + rescue_password='xyz') + + @mock.patch.object(loopingcall, 'FixedIntervalLoopingCall') + @mock.patch.object(FAKE_CLIENT.node, 'set_provision_state') + def test_rescue_provision_state_fail(self, mock_sps, mock_looping): + node = ironic_utils.get_test_node() + + fake_looping_call = FakeLoopingCall() + mock_looping.return_value = fake_looping_call + mock_sps.side_effect = ironic_exception.BadRequest() + instance = fake_instance.fake_instance_obj(self.ctx, + node=node.uuid) + + self.assertRaises(exception.InstanceRescueFailure, + self.driver.rescue, + self.ctx, instance, None, None, 'xyz') + + @mock.patch.object(ironic_driver.IronicDriver, + '_validate_instance_and_node') + @mock.patch.object(FAKE_CLIENT.node, 'set_provision_state') + def test_rescue_instance_not_found(self, mock_sps, fake_validate): + node = ironic_utils.get_test_node(driver='fake') + + instance = fake_instance.fake_instance_obj(self.ctx, node=node.uuid) + fake_validate.side_effect = exception.InstanceNotFound( + instance_id='fake') + + self.assertRaises(exception.InstanceRescueFailure, + self.driver.rescue, + self.ctx, instance, None, None, 'xyz') + + @mock.patch.object(ironic_driver.IronicDriver, + '_validate_instance_and_node') + @mock.patch.object(FAKE_CLIENT.node, 'set_provision_state') + def test_rescue_rescue_fail(self, mock_sps, fake_validate): + node = ironic_utils.get_test_node( + provision_state=ironic_states.RESCUEFAIL, + last_error='rescue failed') + + fake_validate.return_value = node + instance = fake_instance.fake_instance_obj(self.ctx, + node=node.uuid) + + self.assertRaises(exception.InstanceRescueFailure, + self.driver.rescue, + self.ctx, instance, None, None, 'xyz') + + @mock.patch.object(loopingcall, 'FixedIntervalLoopingCall') + @mock.patch.object(FAKE_CLIENT.node, 'set_provision_state') + def test_unrescue(self, mock_sps, mock_looping): + node = ironic_utils.get_test_node() + + fake_looping_call = FakeLoopingCall() + mock_looping.return_value = fake_looping_call + instance = fake_instance.fake_instance_obj(self.ctx, + node=node.uuid) + + self.driver.unrescue(instance, None) + mock_sps.assert_called_once_with(node.uuid, 'unrescue') + + @mock.patch.object(loopingcall, 'FixedIntervalLoopingCall') + @mock.patch.object(FAKE_CLIENT.node, 'set_provision_state') + def test_unrescue_provision_state_fail(self, mock_sps, mock_looping): + node = ironic_utils.get_test_node() + + fake_looping_call = FakeLoopingCall() + mock_looping.return_value = fake_looping_call + mock_sps.side_effect = ironic_exception.BadRequest() + + instance = fake_instance.fake_instance_obj(self.ctx, + node=node.uuid) + self.assertRaises(exception.InstanceUnRescueFailure, + self.driver.unrescue, + instance, None) + + @mock.patch.object(ironic_driver.IronicDriver, + '_validate_instance_and_node') + @mock.patch.object(FAKE_CLIENT.node, 'set_provision_state') + def test_unrescue_instance_not_found(self, mock_sps, fake_validate): + node = ironic_utils.get_test_node(driver='fake') + + instance = fake_instance.fake_instance_obj(self.ctx, node=node.uuid) + fake_validate.side_effect = exception.InstanceNotFound( + instance_id='fake') + + self.assertRaises(exception.InstanceUnRescueFailure, + self.driver.unrescue, + instance, None) + + @mock.patch.object(ironic_driver.IronicDriver, + '_validate_instance_and_node') + @mock.patch.object(FAKE_CLIENT.node, 'set_provision_state') + def test_unrescue_unrescue_fail(self, mock_sps, fake_validate): + node = ironic_utils.get_test_node( + provision_state=ironic_states.UNRESCUEFAIL, + last_error='unrescue failed') + + fake_validate.return_value = node + instance = fake_instance.fake_instance_obj(self.ctx, + node=node.uuid) + + self.assertRaises(exception.InstanceUnRescueFailure, + self.driver.unrescue, + instance, None) + def test__can_send_version(self): self.assertIsNone( self.driver._can_send_version( diff --git a/nova/virt/ironic/client_wrapper.py b/nova/virt/ironic/client_wrapper.py index cc40e932a8a2..ca1ebf888ec4 100644 --- a/nova/virt/ironic/client_wrapper.py +++ b/nova/virt/ironic/client_wrapper.py @@ -32,7 +32,7 @@ ironic = None IRONIC_GROUP = nova.conf.ironic.ironic_group # The API version required by the Ironic driver -IRONIC_API_VERSION = (1, 37) +IRONIC_API_VERSION = (1, 38) # NOTE(TheJulia): This version should ALWAYS be the _last_ release # supported version of the API version used by nova. If a feature # needs 1.38 to be negotiated to operate properly, then the version diff --git a/nova/virt/ironic/driver.py b/nova/virt/ironic/driver.py index c848cbdfe89e..fe7d4dd4b940 100644 --- a/nova/virt/ironic/driver.py +++ b/nova/virt/ironic/driver.py @@ -74,7 +74,10 @@ _POWER_STATE_MAP = { _UNPROVISION_STATES = (ironic_states.ACTIVE, ironic_states.DEPLOYFAIL, ironic_states.ERROR, ironic_states.DEPLOYWAIT, - ironic_states.DEPLOYING) + ironic_states.DEPLOYING, ironic_states.RESCUE, + ironic_states.RESCUING, ironic_states.RESCUEWAIT, + ironic_states.RESCUEFAIL, ironic_states.UNRESCUING, + ironic_states.UNRESCUEFAIL) _NODE_FIELDS = ('uuid', 'power_state', 'target_power_state', 'provision_state', 'target_provision_state', 'last_error', 'maintenance', @@ -1976,3 +1979,87 @@ class IronicDriver(virt_driver.ComputeDriver): version.StrictVersion(max_version)): raise exception.IronicAPIVersionNotAvailable( version=max_version) + + def rescue(self, context, instance, network_info, image_meta, + rescue_password): + """Rescue the specified instance. + + :param nova.context.RequestContext context: + The context for the rescue. + :param nova.objects.instance.Instance instance: + The instance being rescued. + :param nova.network.model.NetworkInfo network_info: + Necessary network information for the rescue. Ignored by this + driver. + :param nova.objects.ImageMeta image_meta: + The metadata of the image of the instance. Ignored by this driver. + :param rescue_password: new root password to set for rescue. + :raise InstanceRescueFailure if rescue fails. + """ + LOG.debug('Rescue called for instance', instance=instance) + + node_uuid = instance.node + + def _wait_for_rescue(): + try: + node = self._validate_instance_and_node(instance) + except exception.InstanceNotFound as e: + raise exception.InstanceRescueFailure(reason=six.text_type(e)) + + if node.provision_state == ironic_states.RESCUE: + raise loopingcall.LoopingCallDone() + + if node.provision_state == ironic_states.RESCUEFAIL: + raise exception.InstanceRescueFailure( + reason=node.last_error) + + try: + self._can_send_version(min_version='1.38') + self.ironicclient.call("node.set_provision_state", + node_uuid, ironic_states.RESCUE, + rescue_password=rescue_password) + except Exception as e: + raise exception.InstanceRescueFailure(reason=six.text_type(e)) + + timer = loopingcall.FixedIntervalLoopingCall(_wait_for_rescue) + timer.start(interval=CONF.ironic.api_retry_interval).wait() + LOG.info('Successfully rescued Ironic node %(node)s', + {'node': node_uuid}, instance=instance) + + def unrescue(self, instance, network_info): + """Unrescue the specified instance. + + :param instance: nova.objects.instance.Instance + :param nova.network.model.NetworkInfo network_info: + Necessary network information for the unrescue. Ignored by this + driver. + """ + LOG.debug('Unrescue called for instance', instance=instance) + + node_uuid = instance.node + + def _wait_for_unrescue(): + try: + node = self._validate_instance_and_node(instance) + except exception.InstanceNotFound as e: + raise exception.InstanceUnRescueFailure( + reason=six.text_type(e)) + + if node.provision_state == ironic_states.ACTIVE: + raise loopingcall.LoopingCallDone() + + if node.provision_state == ironic_states.UNRESCUEFAIL: + raise exception.InstanceUnRescueFailure( + reason=node.last_error) + + try: + self._can_send_version(min_version='1.38') + self.ironicclient.call("node.set_provision_state", + node_uuid, ironic_states.UNRESCUE) + except Exception as e: + raise exception.InstanceUnRescueFailure(reason=six.text_type(e)) + + timer = loopingcall.FixedIntervalLoopingCall(_wait_for_unrescue) + timer.start(interval=CONF.ironic.api_retry_interval).wait() + LOG.info('Successfully unrescued Ironic node %(node)s', + {'node': node_uuid}, instance=instance) diff --git a/nova/virt/ironic/ironic_states.py b/nova/virt/ironic/ironic_states.py index 162dbcd74767..aafdd47e64a1 100644 --- a/nova/virt/ironic/ironic_states.py +++ b/nova/virt/ironic/ironic_states.py @@ -132,6 +132,32 @@ INSPECTFAIL = 'inspect failed' """ Node inspection failed. """ +RESCUE = 'rescue' +""" Node is in rescue mode. +This is also used as a "verb" when changing the node's provision_state via the +REST API""" + +RESCUEFAIL = 'rescue failed' +""" Node rescue failed. """ + +RESCUEWAIT = 'rescue wait' +""" Node is waiting for rescue callback. """ + +RESCUING = 'rescuing' +""" Node is waiting to be rescued. """ + +UNRESCUE = 'unrescue' +""" Node is to be unrescued. +This is not used as a state, but rather as a "verb" when changing the node's +provision_state via the REST API. +""" + +UNRESCUEFAIL = 'unrescue failed' +""" Node unrescue failed. """ + +UNRESCUING = "unrescuing" +""" Node is unrescuing. """ + ############## # Power states ############## diff --git a/releasenotes/notes/bp-ironic-rescue-mode-c305f37e20fba203.yaml b/releasenotes/notes/bp-ironic-rescue-mode-c305f37e20fba203.yaml new file mode 100644 index 000000000000..b8f13efbf571 --- /dev/null +++ b/releasenotes/notes/bp-ironic-rescue-mode-c305f37e20fba203.yaml @@ -0,0 +1,7 @@ +--- +features: + - | + Supports instance rescue and unrescue with ironic virt driver. This feature + requires an ironic service supporting API version 1.38 or later, which is + present in ironic releases >= 10.1. It also requires python-ironicclient >= + 2.3.0.