diff --git a/doc/source/contributor/webapi-version-history.rst b/doc/source/contributor/webapi-version-history.rst index 5492a348bf..dfb8c38fa6 100644 --- a/doc/source/contributor/webapi-version-history.rst +++ b/doc/source/contributor/webapi-version-history.rst @@ -2,6 +2,11 @@ REST API Version History ======================== +1.41 (Rocky, master) +-------------------- + +Added support to abort inspection of a node in the ``inspect wait`` state. + 1.40 (Rocky, master) --------------------- diff --git a/doc/source/images/states.svg b/doc/source/images/states.svg index 1988d04eac..f4d7ed0440 100644 --- a/doc/source/images/states.svg +++ b/doc/source/images/states.svg @@ -4,138 +4,138 @@ - - + + Ironic states - + enroll - -enroll + +enroll verifying - -verifying + +verifying enroll->verifying - - -manage (via API) + + +manage (via API) verifying->enroll - - -fail + + +fail manageable - -manageable + +manageable verifying->manageable - - -done + + +done cleaning - -cleaning + +cleaning manageable->cleaning - - -provide (via API) + + +provide (via API) manageable->cleaning - - -clean (via API) + + +clean (via API) inspecting - -inspecting + +inspecting manageable->inspecting - - -inspect (via API) + + +inspect (via API) adopting - -adopting + +adopting manageable->adopting - - -adopt (via API) + + +adopt (via API) cleaning->manageable - - -manage + + +manage available - -available + +available cleaning->available - - -done + + +done clean failed - -clean failed + +clean failed cleaning->clean failed - - -fail + + +fail clean wait - -clean wait + +clean wait cleaning->clean wait - - -wait + + +wait inspecting->manageable - - -done + + +done inspect failed - -inspect failed + +inspect failed inspecting->inspect failed - - -fail + + +fail inspect wait @@ -144,362 +144,368 @@ inspecting->inspect wait - - -wait + + +wait active - -active + +active -adopting->active - - -done +adopting->active + + +done adopt failed - -adopt failed + +adopt failed -adopting->adopt failed - - -fail +adopting->adopt failed + + +fail available->manageable - - -manage (via API) + + +manage (via API) deploying - -deploying + +deploying available->deploying - - -active (via API) + + +active (via API) deploying->active - - -done + + +done deploy failed - -deploy failed + +deploy failed deploying->deploy failed - - -fail + + +fail wait call-back - -wait call-back + +wait call-back deploying->wait call-back - - -wait + + +wait active->deploying - - -rebuild (via API) + + +rebuild (via API) deleting - -deleting + +deleting active->deleting - - -deleted (via API) + + +deleted (via API) rescuing - -rescuing + +rescuing active->rescuing - - -rescue (via API) + + +rescue (via API) deleting->cleaning - - -clean + + +clean error - -error + +error deleting->error - - -error + + +error rescue - -rescue + +rescue -rescuing->rescue - - -done +rescuing->rescue + + +done rescue wait - -rescue wait + +rescue wait -rescuing->rescue wait - - -wait +rescuing->rescue wait + + +wait rescue failed - -rescue failed + +rescue failed -rescuing->rescue failed - - -fail +rescuing->rescue failed + + +fail error->deploying - - -rebuild (via API) + + +rebuild (via API) error->deleting - - -deleted (via API) + + +deleted (via API) rescue->deleting - - -deleted (via API) + + +deleted (via API) rescue->rescuing - - -rescue (via API) + + +rescue (via API) unrescuing - -unrescuing + +unrescuing rescue->unrescuing - - -unrescue (via API) + + +unrescue (via API) -unrescuing->active - - -done +unrescuing->active + + +done unrescue failed - -unrescue failed + +unrescue failed -unrescuing->unrescue failed - - -fail +unrescuing->unrescue failed + + +fail deploy failed->deploying - - -rebuild (via API) + + +rebuild (via API) deploy failed->deploying - - -active (via API) + + +active (via API) deploy failed->deleting - - -deleted (via API) + + +deleted (via API) wait call-back->deploying - - -resume + + +resume wait call-back->deleting - - -deleted (via API) + + +deleted (via API) wait call-back->deploy failed - - -fail + + +fail clean failed->manageable - - -manage (via API) + + +manage (via API) clean wait->cleaning - - -resume + + +resume clean wait->clean failed - - -fail + + +fail clean wait->clean failed - - -abort (via API) + + +abort (via API) inspect failed->manageable - - -manage (via API) + + +manage (via API) inspect failed->inspecting - - -inspect (via API) + + +inspect (via API) inspect wait->manageable - - -done + + +done inspect wait->inspect failed - - -fail + + +fail + + +inspect wait->inspect failed + + +abort (via API) -adopt failed->manageable - - -manage (via API) +adopt failed->manageable + + +manage (via API) -adopt failed->adopting - - -adopt (via API) +adopt failed->adopting + + +adopt (via API) -rescue wait->deleting - - -deleted (via API) +rescue wait->deleting + + +deleted (via API) -rescue wait->rescuing - - -resume - - -rescue wait->rescue failed - - -fail +rescue wait->rescuing + + +resume rescue wait->rescue failed - - -abort (via API) + + +fail + + +rescue wait->rescue failed + + +abort (via API) -rescue failed->deleting - - -deleted (via API) +rescue failed->deleting + + +deleted (via API) -rescue failed->rescuing - - -rescue (via API) +rescue failed->rescuing + + +rescue (via API) -rescue failed->unrescuing - - -unrescue (via API) +rescue failed->unrescuing + + +unrescue (via API) -unrescue failed->deleting - - -deleted (via API) +unrescue failed->deleting + + +deleted (via API) -unrescue failed->rescuing - - -rescue (via API) +unrescue failed->rescuing + + +rescue (via API) -unrescue failed->unrescuing - - -unrescue (via API) +unrescue failed->unrescuing + + +unrescue (via API) diff --git a/ironic/api/controllers/v1/node.py b/ironic/api/controllers/v1/node.py index 5cb9456722..e54446cf20 100644 --- a/ironic/api/controllers/v1/node.py +++ b/ironic/api/controllers/v1/node.py @@ -555,6 +555,54 @@ class NodeStatesController(rest.RestController): url_args = '/'.join([node_ident, 'states']) pecan.response.location = link.build_url('nodes', url_args) + def _do_provision_action(self, rpc_node, target, configdrive=None, + clean_steps=None, rescue_password=None): + topic = pecan.request.rpcapi.get_topic_for(rpc_node) + # Note that there is a race condition. The node state(s) could change + # by the time the RPC call is made and the TaskManager manager gets a + # lock. + if target in (ir_states.ACTIVE, ir_states.REBUILD): + rebuild = (target == ir_states.REBUILD) + pecan.request.rpcapi.do_node_deploy(context=pecan.request.context, + node_id=rpc_node.uuid, + rebuild=rebuild, + configdrive=configdrive, + topic=topic) + elif (target == ir_states.VERBS['unrescue']): + pecan.request.rpcapi.do_node_unrescue( + pecan.request.context, rpc_node.uuid, topic) + elif (target == ir_states.VERBS['rescue']): + if not (rescue_password and rescue_password.strip()): + msg = (_('A non-empty "rescue_password" is required when ' + 'setting target provision state to %s') % + ir_states.VERBS['rescue']) + raise wsme.exc.ClientSideError( + msg, status_code=http_client.BAD_REQUEST) + pecan.request.rpcapi.do_node_rescue( + pecan.request.context, rpc_node.uuid, rescue_password, topic) + elif target == ir_states.DELETED: + pecan.request.rpcapi.do_node_tear_down( + pecan.request.context, rpc_node.uuid, topic) + elif target == ir_states.VERBS['inspect']: + pecan.request.rpcapi.inspect_hardware( + pecan.request.context, rpc_node.uuid, topic=topic) + elif target == ir_states.VERBS['clean']: + if not clean_steps: + msg = (_('"clean_steps" is required when setting target ' + 'provision state to %s') % ir_states.VERBS['clean']) + raise wsme.exc.ClientSideError( + msg, status_code=http_client.BAD_REQUEST) + _check_clean_steps(clean_steps) + pecan.request.rpcapi.do_node_clean( + pecan.request.context, rpc_node.uuid, clean_steps, topic) + elif target in PROVISION_ACTION_STATES: + pecan.request.rpcapi.do_provisioning_action( + pecan.request.context, rpc_node.uuid, target, topic) + else: + msg = (_('The requested action "%(action)s" could not be ' + 'understood.') % {'action': target}) + raise exception.InvalidStateRequested(message=msg) + @METRICS.timer('NodeStatesController.provision') @expose.expose(None, types.uuid_or_name, wtypes.text, wtypes.text, types.jsontype, wtypes.text, @@ -614,7 +662,6 @@ class NodeStatesController(rest.RestController): api_utils.check_allow_management_verbs(target) rpc_node = api_utils.get_rpc_node(node_ident) - topic = pecan.request.rpcapi.get_topic_for(rpc_node) if (target in (ir_states.ACTIVE, ir_states.REBUILD) and rpc_node.maintenance): @@ -655,50 +702,13 @@ class NodeStatesController(rest.RestController): raise wsme.exc.ClientSideError( msg, status_code=http_client.BAD_REQUEST) - # Note that there is a race condition. The node state(s) could change - # by the time the RPC call is made and the TaskManager manager gets a - # lock. - if target in (ir_states.ACTIVE, ir_states.REBUILD): - rebuild = (target == ir_states.REBUILD) - pecan.request.rpcapi.do_node_deploy(context=pecan.request.context, - node_id=rpc_node.uuid, - rebuild=rebuild, - configdrive=configdrive, - topic=topic) - elif (target == ir_states.VERBS['unrescue']): - pecan.request.rpcapi.do_node_unrescue( - pecan.request.context, rpc_node.uuid, topic) - elif (target == ir_states.VERBS['rescue']): - if not (rescue_password and rescue_password.strip()): - msg = (_('A non-empty "rescue_password" is required when ' - 'setting target provision state to %s') % - ir_states.VERBS['rescue']) - raise wsme.exc.ClientSideError( - msg, status_code=http_client.BAD_REQUEST) - pecan.request.rpcapi.do_node_rescue( - pecan.request.context, rpc_node.uuid, rescue_password, topic) - elif target == ir_states.DELETED: - pecan.request.rpcapi.do_node_tear_down( - pecan.request.context, rpc_node.uuid, topic) - elif target == ir_states.VERBS['inspect']: - pecan.request.rpcapi.inspect_hardware( - pecan.request.context, rpc_node.uuid, topic=topic) - elif target == ir_states.VERBS['clean']: - if not clean_steps: - msg = (_('"clean_steps" is required when setting target ' - 'provision state to %s') % ir_states.VERBS['clean']) - raise wsme.exc.ClientSideError( - msg, status_code=http_client.BAD_REQUEST) - _check_clean_steps(clean_steps) - pecan.request.rpcapi.do_node_clean( - pecan.request.context, rpc_node.uuid, clean_steps, topic) - elif target in PROVISION_ACTION_STATES: - pecan.request.rpcapi.do_provisioning_action( - pecan.request.context, rpc_node.uuid, target, topic) - else: - msg = (_('The requested action "%(action)s" could not be ' - 'understood.') % {'action': target}) - raise exception.InvalidStateRequested(message=msg) + if (rpc_node.provision_state == ir_states.INSPECTWAIT and + target == ir_states.VERBS['abort']): + if not api_utils.allow_inspect_abort(): + raise exception.NotAcceptable() + + self._do_provision_action(rpc_node, target, configdrive, clean_steps, + rescue_password) # Set the HTTP Location Header url_args = '/'.join([node_ident, 'states']) diff --git a/ironic/api/controllers/v1/utils.py b/ironic/api/controllers/v1/utils.py index dbd579201f..7ea7cf5782 100644 --- a/ironic/api/controllers/v1/utils.py +++ b/ironic/api/controllers/v1/utils.py @@ -740,6 +740,14 @@ def allow_inspect_wait_state(): return pecan.request.version.minor >= versions.MINOR_39_INSPECT_WAIT +def allow_inspect_abort(): + """Check if inspection abort is allowed. + + Version 1.41 of the API added support for inspection abort + """ + return pecan.request.version.minor >= versions.MINOR_41_INSPECTION_ABORT + + def handle_post_port_like_extra_vif(p_dict): """Handle a Post request that sets .extra['vif_port_id']. diff --git a/ironic/api/controllers/v1/versions.py b/ironic/api/controllers/v1/versions.py index 171f3d1af0..dd6fc52a06 100644 --- a/ironic/api/controllers/v1/versions.py +++ b/ironic/api/controllers/v1/versions.py @@ -78,6 +78,7 @@ BASE_VERSION = 1 # v1.39: Add inspect wait provision state. # v1.40: Add bios.properties. # Add bios_interface to the node object. +# v1.41: Add inspection abort support. MINOR_0_JUNO = 0 MINOR_1_INITIAL_VERSION = 1 @@ -120,6 +121,7 @@ MINOR_37_NODE_TRAITS = 37 MINOR_38_RESCUE_INTERFACE = 38 MINOR_39_INSPECT_WAIT = 39 MINOR_40_BIOS_INTERFACE = 40 +MINOR_41_INSPECTION_ABORT = 41 # When adding another version, update: # - MINOR_MAX_VERSION @@ -127,7 +129,7 @@ MINOR_40_BIOS_INTERFACE = 40 # explanation of what changed in the new version # - common/release_mappings.py, RELEASE_MAPPING['master']['api'] -MINOR_MAX_VERSION = MINOR_40_BIOS_INTERFACE +MINOR_MAX_VERSION = MINOR_41_INSPECTION_ABORT # String representations of the minor and maximum versions _MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION) diff --git a/ironic/common/release_mappings.py b/ironic/common/release_mappings.py index e1259e7cd5..4bd3155d87 100644 --- a/ironic/common/release_mappings.py +++ b/ironic/common/release_mappings.py @@ -100,7 +100,7 @@ RELEASE_MAPPING = { } }, 'master': { - 'api': '1.40', + 'api': '1.41', 'rpc': '1.44', 'objects': { 'Node': ['1.25'], diff --git a/ironic/common/states.py b/ironic/common/states.py index a45e1f7b34..cef2459f54 100644 --- a/ironic/common/states.py +++ b/ironic/common/states.py @@ -413,6 +413,9 @@ machine.add_transition(INSPECTWAIT, MANAGEABLE, 'done') # Inspection failed. machine.add_transition(INSPECTWAIT, INSPECTFAIL, 'fail') +# Inspection is aborted. +machine.add_transition(INSPECTWAIT, INSPECTFAIL, 'abort') + # Move the node to manageable state for any other # action. machine.add_transition(INSPECTFAIL, MANAGEABLE, 'manage') diff --git a/ironic/conductor/manager.py b/ironic/conductor/manager.py index 66750fe215..76d493c0fd 100644 --- a/ironic/conductor/manager.py +++ b/ironic/conductor/manager.py @@ -1396,7 +1396,8 @@ class ConductorManager(base_manager.BaseConductorManager): @messaging.expected_exceptions(exception.NoFreeConductorWorker, exception.NodeLocked, exception.InvalidParameterValue, - exception.InvalidStateRequested) + exception.InvalidStateRequested, + exception.UnsupportedDriverExtension) def do_provisioning_action(self, context, node_id, action): """RPC method to initiate certain provisioning state transitions. @@ -1443,52 +1444,11 @@ class ConductorManager(base_manager.BaseConductorManager): err_handler=utils.provisioning_error_handler) return - if (action == states.VERBS['abort'] - and node.provision_state == states.CLEANWAIT): - - # Check if the clean step is abortable; if so abort it. - # Otherwise, indicate in that clean step, that cleaning - # should be aborted after that step is done. - if (node.clean_step - and not node.clean_step.get('abortable')): - LOG.info('The current clean step "%(clean_step)s" for ' - 'node %(node)s is not abortable. Adding a ' - 'flag to abort the cleaning after the clean ' - 'step is completed.', - {'clean_step': node.clean_step['step'], - 'node': node.uuid}) - clean_step = node.clean_step - if not clean_step.get('abort_after'): - clean_step['abort_after'] = True - node.clean_step = clean_step - node.save() - return - - LOG.debug('Aborting the cleaning operation during clean step ' - '"%(step)s" for node %(node)s in provision state ' - '"%(prov)s".', - {'node': node.uuid, - 'prov': node.provision_state, - 'step': node.clean_step.get('step')}) - target_state = None - if node.target_provision_state == states.MANAGEABLE: - target_state = states.MANAGEABLE - task.process_event( - 'abort', - callback=self._spawn_worker, - call_args=(self._do_node_clean_abort, task), - err_handler=utils.provisioning_error_handler, - target_state=target_state) - return - - if (action == states.VERBS['abort'] - and node.provision_state == states.RESCUEWAIT): - utils.remove_node_rescue_password(node, save=True) - task.process_event( - 'abort', - callback=self._spawn_worker, - call_args=(self._do_node_rescue_abort, task), - err_handler=utils.provisioning_error_handler) + if (action == states.VERBS['abort'] and + node.provision_state in (states.CLEANWAIT, + states.RESCUEWAIT, + states.INSPECTWAIT)): + self._do_abort(task) return try: @@ -1498,6 +1458,78 @@ class ConductorManager(base_manager.BaseConductorManager): action=action, node=node.uuid, state=node.provision_state) + def _do_abort(self, task): + """Handle node abort for certain states.""" + node = task.node + + if node.provision_state == states.CLEANWAIT: + # Check if the clean step is abortable; if so abort it. + # Otherwise, indicate in that clean step, that cleaning + # should be aborted after that step is done. + if (node.clean_step and not + node.clean_step.get('abortable')): + LOG.info('The current clean step "%(clean_step)s" for ' + 'node %(node)s is not abortable. Adding a ' + 'flag to abort the cleaning after the clean ' + 'step is completed.', + {'clean_step': node.clean_step['step'], + 'node': node.uuid}) + clean_step = node.clean_step + if not clean_step.get('abort_after'): + clean_step['abort_after'] = True + node.clean_step = clean_step + node.save() + return + + LOG.debug('Aborting the cleaning operation during clean step ' + '"%(step)s" for node %(node)s in provision state ' + '"%(prov)s".', + {'node': node.uuid, + 'prov': node.provision_state, + 'step': node.clean_step.get('step')}) + target_state = None + if node.target_provision_state == states.MANAGEABLE: + target_state = states.MANAGEABLE + task.process_event( + 'abort', + callback=self._spawn_worker, + call_args=(self._do_node_clean_abort, task), + err_handler=utils.provisioning_error_handler, + target_state=target_state) + return + + if node.provision_state == states.RESCUEWAIT: + utils.remove_node_rescue_password(node, save=True) + task.process_event( + 'abort', + callback=self._spawn_worker, + call_args=(self._do_node_rescue_abort, task), + err_handler=utils.provisioning_error_handler) + return + + if node.provision_state == states.INSPECTWAIT: + try: + task.driver.inspect.abort(task) + except exception.UnsupportedDriverExtension: + with excutils.save_and_reraise_exception(): + intf_name = task.driver.inspect.__class__.__name__ + LOG.error('Inspect interface %(intf)s does not ' + 'support abort operation when aborting ' + 'inspection of node %(node)s', + {'intf': intf_name, 'node': node.uuid}) + except Exception as e: + with excutils.save_and_reraise_exception(): + LOG.exception('Error in aborting the inspection of ' + 'node %(node)s', {'node': node.uuid}) + node.last_error = _('Failed to abort inspection. ' + 'Error: %s') % e + node.save() + node.last_error = _('Inspection was aborted by request.') + task.process_event('abort') + LOG.info('Successfully aborted inspection of node %(node)s', + {'node': node.uuid}) + return + @METRICS.timer('ConductorManager._sync_power_states') @periodics.periodic(spacing=CONF.conductor.sync_power_state_interval) def _sync_power_states(self, context): diff --git a/ironic/drivers/base.py b/ironic/drivers/base.py index 697128a8fa..3c45437268 100644 --- a/ironic/drivers/base.py +++ b/ironic/drivers/base.py @@ -923,6 +923,22 @@ class InspectInterface(BaseInterface): or None. """ + def abort(self, task): + """Abort asynchronized hardware inspection. + + Abort an ongoing hardware introspection, this is only used for + asynchronize based inspect interface. + + NOTE: This interface is called with node exclusive lock held, the + interface implementation is expected to be a quick processing. + + :param task: a task from TaskManager. + :raises: UnsupportedDriverExtension, if the method is not implemented + by specific inspect interface. + """ + raise exception.UnsupportedDriverExtension( + driver=task.node.driver, extension='abort') + class BIOSInterface(BaseInterface): interface_type = 'bios' diff --git a/ironic/drivers/modules/inspector.py b/ironic/drivers/modules/inspector.py index 06d10a42d3..d39acc06c2 100644 --- a/ironic/drivers/modules/inspector.py +++ b/ironic/drivers/modules/inspector.py @@ -34,7 +34,7 @@ LOG = logging.getLogger(__name__) client = importutils.try_import('ironic_inspector_client') -INSPECTOR_API_VERSION = (1, 0) +INSPECTOR_API_VERSION = (1, 3) _INSPECTOR_SESSION = None @@ -130,6 +130,16 @@ class Inspector(base.InspectInterface): eventlet.spawn_n(_start_inspection, task.node.uuid, task.context) return states.INSPECTWAIT + def abort(self, task): + """Abort hardware inspection. + + :param task: a task from TaskManager. + """ + node_uuid = task.node.uuid + LOG.debug('Aborting inspection for node %(uuid)s using ' + 'ironic-inspector', {'uuid': node_uuid}) + _get_client(task.context).abort(node_uuid) + @periodics.periodic(spacing=CONF.inspector.status_check_period, enabled=CONF.inspector.enabled) def _periodic_check_result(self, manager, context): diff --git a/ironic/tests/unit/api/controllers/v1/test_node.py b/ironic/tests/unit/api/controllers/v1/test_node.py index 8d03032809..79aab4394e 100644 --- a/ironic/tests/unit/api/controllers/v1/test_node.py +++ b/ironic/tests/unit/api/controllers/v1/test_node.py @@ -4128,6 +4128,24 @@ class TestPut(test_api_base.BaseApiTest): obj_fields.NotificationLevel.ERROR, obj_fields.NotificationStatus.ERROR)]) + def test_inspect_abort_raises_before_1_41(self): + self.node.provision_state = states.INSPECTWAIT + self.node.save() + ret = self.put_json('/nodes/%s/states/provision' % self.node.uuid, + {'target': states.VERBS['abort']}, + headers={api_base.Version.string: "1.40"}, + expect_errors=True) + self.assertEqual(http_client.NOT_ACCEPTABLE, ret.status_code) + + @mock.patch.object(rpcapi.ConductorAPI, 'do_provisioning_action') + def test_inspect_abort_accepted_after_1_41(self, mock_provision): + self.node.provision_state = states.INSPECTWAIT + self.node.save() + ret = self.put_json('/nodes/%s/states/provision' % self.node.uuid, + {'target': states.VERBS['abort']}, + headers={api_base.Version.string: "1.41"}) + self.assertEqual(http_client.ACCEPTED, ret.status_code) + class TestCheckCleanSteps(base.TestCase): def test__check_clean_steps_not_list(self): diff --git a/ironic/tests/unit/api/controllers/v1/test_utils.py b/ironic/tests/unit/api/controllers/v1/test_utils.py index c47f7eedf4..1dd1fb2c4d 100644 --- a/ironic/tests/unit/api/controllers/v1/test_utils.py +++ b/ironic/tests/unit/api/controllers/v1/test_utils.py @@ -514,6 +514,13 @@ class TestApiUtils(base.TestCase): mock_request.version.minor = 37 self.assertFalse(utils.allow_rescue_interface()) + @mock.patch.object(pecan, 'request', spec_set=['version']) + def test_allow_inspect_abort(self, mock_request): + mock_request.version.minor = 41 + self.assertTrue(utils.allow_inspect_abort()) + mock_request.version.minor = 40 + self.assertFalse(utils.allow_inspect_abort()) + class TestNodeIdent(base.TestCase): diff --git a/ironic/tests/unit/conductor/test_manager.py b/ironic/tests/unit/conductor/test_manager.py index adc903639e..2ea75f90bb 100644 --- a/ironic/tests/unit/conductor/test_manager.py +++ b/ironic/tests/unit/conductor/test_manager.py @@ -7580,3 +7580,64 @@ class NodeTraitsTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase): def test_remove_node_traits_node_trait_not_found(self): self._test_remove_node_traits_exception(exception.NodeTraitNotFound) + + +@mgr_utils.mock_record_keepalive +class DoNodeInspectAbortTestCase(mgr_utils.CommonMixIn, + mgr_utils.ServiceSetUpMixin, + db_base.DbTestCase): + @mock.patch.object(manager, 'LOG') + @mock.patch('ironic.drivers.modules.fake.FakeInspect.abort') + @mock.patch('ironic.conductor.task_manager.acquire', autospec=True) + def test_do_inspect_abort_interface_not_support(self, mock_acquire, + mock_abort, mock_log): + node = obj_utils.create_test_node(self.context, + driver='fake-hardware', + provision_state=states.INSPECTWAIT) + task = task_manager.TaskManager(self.context, node.uuid) + mock_acquire.side_effect = self._get_acquire_side_effect(task) + mock_abort.side_effect = exception.UnsupportedDriverExtension( + driver='fake', extension='inspect') + self._start_service() + exc = self.assertRaises(messaging.rpc.ExpectedException, + self.service.do_provisioning_action, + self.context, task.node.uuid, + "abort") + self.assertEqual(exception.UnsupportedDriverExtension, + exc.exc_info[0]) + self.assertTrue(mock_log.error.called) + + @mock.patch.object(manager, 'LOG') + @mock.patch('ironic.drivers.modules.fake.FakeInspect.abort') + @mock.patch('ironic.conductor.task_manager.acquire', autospec=True) + def test_do_inspect_abort_interface_return_failed(self, mock_acquire, + mock_abort, mock_log): + mock_abort.side_effect = exception.IronicException('Oops') + self._start_service() + node = obj_utils.create_test_node(self.context, + driver='fake-hardware', + provision_state=states.INSPECTWAIT) + task = task_manager.TaskManager(self.context, node.uuid) + mock_acquire.side_effect = self._get_acquire_side_effect(task) + self.assertRaises(exception.IronicException, + self.service.do_provisioning_action, + self.context, task.node.uuid, + "abort") + node.refresh() + self.assertTrue(mock_log.exception.called) + self.assertIn('Failed to abort inspection.', node.last_error) + + @mock.patch('ironic.drivers.modules.fake.FakeInspect.abort') + @mock.patch('ironic.conductor.task_manager.acquire', autospec=True) + def test_do_inspect_abort_succeeded(self, mock_acquire, mock_abort): + self._start_service() + node = obj_utils.create_test_node(self.context, + driver='fake-hardware', + provision_state=states.INSPECTWAIT) + task = task_manager.TaskManager(self.context, node.uuid) + mock_acquire.side_effect = self._get_acquire_side_effect(task) + self.service.do_provisioning_action(self.context, task.node.uuid, + "abort") + node.refresh() + self.assertEqual('inspect failed', node.provision_state) + self.assertIn('Inspection was aborted', node.last_error) diff --git a/ironic/tests/unit/drivers/modules/test_inspector.py b/ironic/tests/unit/drivers/modules/test_inspector.py index 17d1f301fd..4b9fdf0e3f 100644 --- a/ironic/tests/unit/drivers/modules/test_inspector.py +++ b/ironic/tests/unit/drivers/modules/test_inspector.py @@ -66,7 +66,7 @@ class GetClientTestCase(db_base.DbTestCase): super(GetClientTestCase, self).setUp() # NOTE(pas-ha) force-reset global inspector session object inspector._INSPECTOR_SESSION = None - self.api_version = (1, 0) + self.api_version = (1, 3) self.context = context.RequestContext(global_request_id='global') def test__get_client(self, mock_init, mock_session, mock_auth, @@ -216,3 +216,17 @@ class CheckStatusTestCase(BaseTestCase): mock_get.assert_called_once_with(self.node.uuid) self.task.process_event.assert_called_once_with('fail') self.assertIn('boom', self.node.last_error) + + +@mock.patch('ironic.drivers.modules.inspector._get_client', autospec=True) +class InspectHardwareAbortTestCase(BaseTestCase): + def test_abort_ok(self, mock_client): + mock_abort = mock_client.return_value.abort + self.driver.inspect.abort(self.task) + mock_abort.assert_called_once_with(self.node.uuid) + + def test_abort_error(self, mock_client): + mock_abort = mock_client.return_value.abort + mock_abort.side_effect = RuntimeError('boom') + self.assertRaises(RuntimeError, self.driver.inspect.abort, self.task) + mock_abort.assert_called_once_with(self.node.uuid) diff --git a/releasenotes/notes/add-inspection-abort-a187e6e5c1f6311d.yaml b/releasenotes/notes/add-inspection-abort-a187e6e5c1f6311d.yaml new file mode 100644 index 0000000000..c3b4af78dd --- /dev/null +++ b/releasenotes/notes/add-inspection-abort-a187e6e5c1f6311d.yaml @@ -0,0 +1,8 @@ +--- +features: + - | + Adds support to abort the inspection of a node in the ``inspect wait`` + state, as long as this operation is supported by the inspect interface in + use. Starting from API 1.41, the node in the ``inspect wait`` state + accepts ``abort`` provisioning verb to initiate the abort process. + This feature is supported by the ``inspector`` inspect interface.