Merge "Allow aborting deployments in "wait call-back" state"
This commit is contained in:
@@ -2,6 +2,16 @@
|
||||
REST API Version History
|
||||
========================
|
||||
|
||||
1.110 (Gazpacho)
|
||||
----------------------
|
||||
|
||||
Add support for aborting deployment in the ``wait call-back`` (DEPLOYWAIT) state.
|
||||
This provides consistency with other wait states that already support the abort
|
||||
verb, allowing operators to issue ``abort`` on a node in DEPLOYWAIT state to
|
||||
transition it to DEPLOYFAIL. The node remains in DEPLOYFAIL state without
|
||||
triggering automated cleaning, allowing operators to investigate the issue or
|
||||
retry deployment.
|
||||
|
||||
1.109 (Gazpacho)
|
||||
----------------------
|
||||
|
||||
|
||||
@@ -1289,9 +1289,13 @@ class NodeStatesController(rest.RestController):
|
||||
raise exception.ClientSideError(
|
||||
msg, status_code=http_client.BAD_REQUEST)
|
||||
|
||||
if (rpc_node.provision_state == ir_states.INSPECTWAIT
|
||||
and target == ir_states.VERBS['abort']):
|
||||
if not api_utils.allow_inspect_abort():
|
||||
if target == ir_states.VERBS['abort']:
|
||||
if (rpc_node.provision_state == ir_states.INSPECTWAIT
|
||||
and not api_utils.allow_inspect_abort()):
|
||||
raise exception.NotAcceptable()
|
||||
|
||||
if (rpc_node.provision_state == ir_states.DEPLOYWAIT
|
||||
and not api_utils.allow_deploy_abort()):
|
||||
raise exception.NotAcceptable()
|
||||
|
||||
if target == ir_states.VERBS['unhold']:
|
||||
|
||||
@@ -1474,6 +1474,14 @@ def allow_inspect_abort():
|
||||
return api.request.version.minor >= versions.MINOR_41_INSPECTION_ABORT
|
||||
|
||||
|
||||
def allow_deploy_abort():
|
||||
"""Check if deployment abort is allowed.
|
||||
|
||||
Version 1.110 of the API added support for deployment abort in DEPLOYWAIT
|
||||
"""
|
||||
return api.request.version.minor >= versions.MINOR_110_DEPLOYWAIT_ABORT
|
||||
|
||||
|
||||
def allow_detail_query():
|
||||
"""Check if passing a detail=True query string is allowed.
|
||||
|
||||
|
||||
@@ -147,6 +147,7 @@ BASE_VERSION = 1
|
||||
# v1.107: Add X-OpenStack-Request-Id header.
|
||||
# v1.108: Add disable_ramdisk support for servicing
|
||||
# v1.109: Add health field to node object.
|
||||
# v1.110: Add support for aborting deployment in DEPLOYWAIT state
|
||||
|
||||
MINOR_0_JUNO = 0
|
||||
MINOR_1_INITIAL_VERSION = 1
|
||||
@@ -258,6 +259,7 @@ MINOR_106_PORTGROUP_SHARD = 106
|
||||
MINOR_107_X_OPENSTACK_REQUEST_ID = 107
|
||||
MINOR_108_SERVICE_DISABLE_RAMDISK = 108
|
||||
MINOR_109_NODE_HEALTH = 109
|
||||
MINOR_110_DEPLOYWAIT_ABORT = 110
|
||||
|
||||
# When adding another version, update:
|
||||
# - MINOR_MAX_VERSION
|
||||
@@ -267,7 +269,7 @@ MINOR_109_NODE_HEALTH = 109
|
||||
# - Add a comment describing the change above the list of consts
|
||||
|
||||
|
||||
MINOR_MAX_VERSION = MINOR_109_NODE_HEALTH
|
||||
MINOR_MAX_VERSION = MINOR_110_DEPLOYWAIT_ABORT
|
||||
|
||||
# String representations of the minor and maximum versions
|
||||
_MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION)
|
||||
|
||||
@@ -944,7 +944,7 @@ RELEASE_MAPPING = {
|
||||
# make it below. To release, we will preserve a version matching
|
||||
# the release as a separate block of text, like above.
|
||||
'master': {
|
||||
'api': '1.109',
|
||||
'api': '1.110',
|
||||
'rpc': '1.62',
|
||||
'networking_rpc': '1.0',
|
||||
'objects': {
|
||||
|
||||
@@ -125,6 +125,9 @@ machine.add_transition(st.DEPLOYHOLD, st.DEPLOYWAIT, 'unhold')
|
||||
# A node in deploy hold may also be aborted
|
||||
machine.add_transition(st.DEPLOYHOLD, st.DEPLOYFAIL, 'abort')
|
||||
|
||||
# A deployment waiting on callback may be aborted
|
||||
machine.add_transition(st.DEPLOYWAIT, st.DEPLOYFAIL, 'abort')
|
||||
|
||||
# A deployment may complete
|
||||
machine.add_transition(st.DEPLOYING, st.ACTIVE, 'done')
|
||||
|
||||
|
||||
@@ -530,9 +530,66 @@ def continue_node_deploy(task):
|
||||
|
||||
next_step_index = utils.update_next_step_index(task, 'deploy')
|
||||
|
||||
# If this isn't the final deploy step in the deployment operation
|
||||
# and it is flagged to abort after the deploy step that just
|
||||
# finished, we abort the deployment operation.
|
||||
if node.deploy_step.get('abort_after'):
|
||||
step_name = node.deploy_step['step']
|
||||
if next_step_index is not None:
|
||||
LOG.debug('The deployment operation for node %(node)s was '
|
||||
'marked to be aborted after step "%(step)s '
|
||||
'completed. Aborting now that it has completed.',
|
||||
{'node': task.node.uuid, 'step': step_name})
|
||||
|
||||
task.process_event('fail')
|
||||
do_node_deploy_abort(task)
|
||||
return
|
||||
|
||||
LOG.debug('The deployment operation for node %(node)s was '
|
||||
'marked to be aborted after step "%(step)s" '
|
||||
'completed. However, since there are no more '
|
||||
'deploy steps after this, the abort is not going '
|
||||
'to be done.', {'node': node.uuid,
|
||||
'step': step_name})
|
||||
|
||||
do_next_deploy_step(task, next_step_index)
|
||||
|
||||
|
||||
def do_node_deploy_abort(task):
|
||||
"""Abort an ongoing deployment operation.
|
||||
|
||||
:param task: a TaskManager instance with an exclusive lock
|
||||
"""
|
||||
node = task.node
|
||||
try:
|
||||
task.driver.deploy.clean_up(task)
|
||||
except Exception as e:
|
||||
log_msg = (_('Failed to clean up deploying for node %(node)s '
|
||||
'after aborting the operation. Error: %(err)s') %
|
||||
{'node': node.uuid, 'err': e})
|
||||
error_msg = _('Failed to clean up deploying after aborting '
|
||||
'the operation')
|
||||
utils.deploying_error_handler(task, log_msg,
|
||||
errmsg=error_msg,
|
||||
traceback=True,
|
||||
clean_up=False)
|
||||
return
|
||||
|
||||
info_message = _('Deploy operation aborted for node %s') % node.uuid
|
||||
last_error = _('By request, the deploy operation was aborted')
|
||||
if node.deploy_step:
|
||||
step_msg = (_(' during or after the completion of step "%s"')
|
||||
% conductor_steps.step_id(node.deploy_step))
|
||||
info_message += step_msg
|
||||
last_error += step_msg
|
||||
|
||||
node.last_error = last_error
|
||||
node.deploy_step = {}
|
||||
utils.wipe_deploy_internal_info(task)
|
||||
node.save()
|
||||
LOG.info(info_message)
|
||||
|
||||
|
||||
def _get_configdrive_obj_name(node):
|
||||
"""Generate the object name for the config drive."""
|
||||
return 'configdrive-%s' % node.uuid
|
||||
|
||||
@@ -1438,6 +1438,7 @@ class ConductorManager(base_manager.BaseConductorManager):
|
||||
states.RESCUEWAIT,
|
||||
states.INSPECTWAIT,
|
||||
states.CLEANHOLD,
|
||||
states.DEPLOYWAIT,
|
||||
states.DEPLOYHOLD,
|
||||
states.SERVICEWAIT,
|
||||
states.SERVICEHOLD,
|
||||
@@ -1556,6 +1557,42 @@ class ConductorManager(base_manager.BaseConductorManager):
|
||||
if node.provision_state == states.INSPECTWAIT:
|
||||
return inspection.abort_inspection(task)
|
||||
|
||||
if node.provision_state == states.DEPLOYWAIT:
|
||||
# Check if the deploy step is abortable; if so abort it.
|
||||
# Otherwise, indicate in that deploy step, that deployment
|
||||
# should be aborted after that step is done.
|
||||
if (node.deploy_step and not
|
||||
node.deploy_step.get('abortable')):
|
||||
LOG.info('The current deploy step "%(deploy_step)s" for '
|
||||
'node %(node)s is not abortable. Adding a '
|
||||
'flag to abort the deployment after the deploy '
|
||||
'step is completed.',
|
||||
{'deploy_step': node.deploy_step['step'],
|
||||
'node': node.uuid})
|
||||
deploy_step = node.deploy_step
|
||||
if not deploy_step.get('abort_after'):
|
||||
deploy_step['abort_after'] = True
|
||||
node.deploy_step = deploy_step
|
||||
node.save()
|
||||
return
|
||||
|
||||
LOG.debug('Aborting the deployment operation during deploy step '
|
||||
'"%(step)s" for node %(node)s in provision state '
|
||||
'"%(prov)s".',
|
||||
{'node': node.uuid,
|
||||
'prov': node.provision_state,
|
||||
'step': node.deploy_step.get('step')
|
||||
if node.deploy_step else None})
|
||||
# Immediately break agent API interaction
|
||||
utils.remove_agent_url(task.node)
|
||||
# Abort the deployment, leaving the node in DEPLOYFAIL
|
||||
task.process_event(
|
||||
'abort',
|
||||
callback=self._spawn_worker,
|
||||
call_args=(deployments.do_node_deploy_abort, task),
|
||||
err_handler=utils.provisioning_error_handler)
|
||||
return
|
||||
|
||||
if node.provision_state == states.DEPLOYHOLD:
|
||||
# Immediately break agent API interaction
|
||||
# and align with do_node_tear_down
|
||||
|
||||
@@ -261,6 +261,7 @@ class BaseInterface(object, metaclass=abc.ABCMeta):
|
||||
# Create a DeployStep to represent this method
|
||||
step = {'step': method.__name__,
|
||||
'priority': method._deploy_step_priority,
|
||||
'abortable': method._deploy_step_abortable,
|
||||
'argsinfo': method._deploy_step_argsinfo,
|
||||
'interface': instance.interface_type}
|
||||
instance.deploy_steps.append(step)
|
||||
@@ -2123,7 +2124,7 @@ def clean_step(priority, abortable=False, argsinfo=None,
|
||||
return decorator
|
||||
|
||||
|
||||
def deploy_step(priority, argsinfo=None):
|
||||
def deploy_step(priority, abortable=True, argsinfo=None):
|
||||
"""Decorator for deployment steps.
|
||||
|
||||
Only steps with priorities greater than 0 are used.
|
||||
@@ -2152,8 +2153,14 @@ def deploy_step(priority, argsinfo=None):
|
||||
def example_deploying(self, task):
|
||||
# do some deploying
|
||||
|
||||
@base.deploy_step(priority=50, abortable=False)
|
||||
def non_abortable_deploy(self, task):
|
||||
# do some critical deploying that cannot be aborted
|
||||
|
||||
:param priority: an integer (>=0) priority; used for determining the order
|
||||
in which the step is run in the deployment process.
|
||||
:param abortable: Boolean value. Whether the deploy step is abortable
|
||||
or not; defaults to True.
|
||||
:param argsinfo: a dictionary of keyword arguments where key is the name of
|
||||
the argument and value is a dictionary as follows::
|
||||
|
||||
@@ -2173,6 +2180,13 @@ def deploy_step(priority, argsinfo=None):
|
||||
_('"priority" must be an integer value >= 0, instead of "%s"')
|
||||
% priority)
|
||||
|
||||
if isinstance(abortable, bool):
|
||||
func._deploy_step_abortable = abortable
|
||||
else:
|
||||
raise exception.InvalidParameterValue(
|
||||
_('"abortable" must be a Boolean value instead of "%s"')
|
||||
% abortable)
|
||||
|
||||
_validate_argsinfo(argsinfo)
|
||||
func._deploy_step_argsinfo = argsinfo
|
||||
return func
|
||||
|
||||
@@ -156,7 +156,8 @@ class IloBIOS(base.BIOSInterface):
|
||||
raise exception.InstanceDeployFailure(reason=errmsg)
|
||||
|
||||
@METRICS.timer('IloBIOS.apply_configuration')
|
||||
@base.deploy_step(priority=0, argsinfo=_APPLY_CONFIGURATION_ARGSINFO)
|
||||
@base.deploy_step(priority=0, abortable=False,
|
||||
argsinfo=_APPLY_CONFIGURATION_ARGSINFO)
|
||||
@base.clean_step(priority=0, abortable=False,
|
||||
argsinfo=_APPLY_CONFIGURATION_ARGSINFO)
|
||||
@base.cache_bios_settings
|
||||
@@ -181,7 +182,7 @@ class IloBIOS(base.BIOSInterface):
|
||||
task, 'apply_configuration')
|
||||
|
||||
@METRICS.timer('IloBIOS.factory_reset')
|
||||
@base.deploy_step(priority=0)
|
||||
@base.deploy_step(priority=0, abortable=False)
|
||||
@base.clean_step(priority=0, abortable=False)
|
||||
@base.cache_bios_settings
|
||||
def factory_reset(self, task):
|
||||
|
||||
@@ -651,7 +651,8 @@ class IloManagement(base.ManagementInterface):
|
||||
"is completed.", {'node': node.uuid})
|
||||
|
||||
@METRICS.timer('IloManagement.update_firmware')
|
||||
@base.deploy_step(priority=0, argsinfo=_FIRMWARE_UPDATE_ARGSINFO)
|
||||
@base.deploy_step(priority=0, abortable=False,
|
||||
argsinfo=_FIRMWARE_UPDATE_ARGSINFO)
|
||||
@base.clean_step(priority=0, abortable=False,
|
||||
argsinfo=_FIRMWARE_UPDATE_ARGSINFO)
|
||||
@firmware_processor.verify_firmware_update_args
|
||||
|
||||
@@ -229,7 +229,8 @@ class RedfishFirmware(base.FirmwareInterface):
|
||||
return nic_list
|
||||
|
||||
@METRICS.timer('RedfishFirmware.update')
|
||||
@base.deploy_step(priority=0, argsinfo=_FW_SETTINGS_ARGSINFO)
|
||||
@base.deploy_step(priority=0, abortable=False,
|
||||
argsinfo=_FW_SETTINGS_ARGSINFO)
|
||||
@base.clean_step(priority=0, abortable=False,
|
||||
argsinfo=_FW_SETTINGS_ARGSINFO,
|
||||
requires_ramdisk=True)
|
||||
|
||||
@@ -7363,6 +7363,25 @@ ORHMKeXMO8fcK0By7CiMKwHSXCoEQgfQhWwpMdSsO8LgHCjh87DQc= """
|
||||
headers={api_base.Version.string: "1.41"})
|
||||
self.assertEqual(http_client.ACCEPTED, ret.status_code)
|
||||
|
||||
def test_deploy_abort_raises_before_1_110(self):
|
||||
self.node.provision_state = states.DEPLOYWAIT
|
||||
self.node.save()
|
||||
ret = self.put_json('/nodes/%s/states/provision' % self.node.uuid,
|
||||
{'target': states.VERBS['abort']},
|
||||
headers={api_base.Version.string: "1.109"},
|
||||
expect_errors=True)
|
||||
self.assertEqual(http_client.NOT_ACCEPTABLE, ret.status_code)
|
||||
|
||||
@mock.patch.object(rpcapi.ConductorAPI, 'do_provisioning_action',
|
||||
autospec=True)
|
||||
def test_deploy_abort_accepted_after_1_110(self, mock_provision):
|
||||
self.node.provision_state = states.DEPLOYWAIT
|
||||
self.node.save()
|
||||
ret = self.put_json('/nodes/%s/states/provision' % self.node.uuid,
|
||||
{'target': states.VERBS['abort']},
|
||||
headers={api_base.Version.string: "1.110"})
|
||||
self.assertEqual(http_client.ACCEPTED, ret.status_code)
|
||||
|
||||
@mock.patch.object(rpcapi.ConductorAPI, 'set_indicator_state',
|
||||
autospec=True)
|
||||
def test_set_indicator_state(self, mock_sis):
|
||||
|
||||
@@ -1351,3 +1351,65 @@ class StoreConfigDriveTestCase(db_base.DbTestCase):
|
||||
container_name, expected_obj_name, 1800)
|
||||
self.node.refresh()
|
||||
self.assertEqual(expected_instance_info, self.node.instance_info)
|
||||
|
||||
|
||||
@mock.patch.object(fake.FakeDeploy, 'clean_up', autospec=True)
|
||||
class DoNodeDeployAbortTestCase(db_base.DbTestCase):
|
||||
|
||||
def setUp(self):
|
||||
super(DoNodeDeployAbortTestCase, self).setUp()
|
||||
self.node = obj_utils.create_test_node(
|
||||
self.context, driver='fake-hardware',
|
||||
provision_state=states.DEPLOYFAIL,
|
||||
target_provision_state=states.ACTIVE)
|
||||
|
||||
def _test_do_node_deploy_abort(self, deploy_step, clean_up_mock):
|
||||
self.node.provision_state = states.DEPLOYWAIT
|
||||
self.node.deploy_step = deploy_step
|
||||
self.node.driver_internal_info = {
|
||||
'agent_url': 'some url',
|
||||
'agent_secret_token': 'token',
|
||||
'deploy_step_index': 2,
|
||||
'deployment_reboot': True,
|
||||
'deployment_polling': True,
|
||||
'skip_current_deploy_step': True
|
||||
}
|
||||
self.node.save()
|
||||
|
||||
with task_manager.acquire(self.context, self.node.uuid) as task:
|
||||
deployments.do_node_deploy_abort(task)
|
||||
self.assertIsNotNone(task.node.last_error)
|
||||
clean_up_mock.assert_called_once_with(task.driver.deploy, task)
|
||||
if deploy_step:
|
||||
self.assertIn(deploy_step['step'], task.node.last_error)
|
||||
# assert node's deploy_step and metadata was cleaned up
|
||||
self.assertEqual({}, task.node.deploy_step)
|
||||
self.assertNotIn('deploy_step_index',
|
||||
task.node.driver_internal_info)
|
||||
self.assertNotIn('deployment_reboot',
|
||||
task.node.driver_internal_info)
|
||||
self.assertNotIn('deployment_polling',
|
||||
task.node.driver_internal_info)
|
||||
self.assertNotIn('skip_current_deploy_step',
|
||||
task.node.driver_internal_info)
|
||||
|
||||
def test_do_node_deploy_abort_early(self, clean_up_mock):
|
||||
self._test_do_node_deploy_abort(None, clean_up_mock)
|
||||
|
||||
def test_do_node_deploy_abort_with_step(self, clean_up_mock):
|
||||
self._test_do_node_deploy_abort(
|
||||
{'step': 'foo', 'interface': 'deploy', 'abortable': True},
|
||||
clean_up_mock)
|
||||
|
||||
def test_do_node_deploy_abort_clean_up_fail(self, clean_up_mock):
|
||||
clean_up_mock.side_effect = Exception('Surprise')
|
||||
self.node.provision_state = states.DEPLOYWAIT
|
||||
self.node.deploy_step = {'step': 'foo', 'abortable': True}
|
||||
self.node.save()
|
||||
|
||||
with task_manager.acquire(self.context, self.node.uuid) as task:
|
||||
deployments.do_node_deploy_abort(task)
|
||||
clean_up_mock.assert_called_once_with(task.driver.deploy, task)
|
||||
self.assertIsNotNone(task.node.last_error)
|
||||
# Node should be in DEPLOYFAIL after clean_up failure
|
||||
self.assertEqual(states.DEPLOYFAIL, task.node.provision_state)
|
||||
|
||||
@@ -2252,6 +2252,56 @@ class ContinueNodeDeployTestCase(mgr_utils.ServiceSetUpMixin,
|
||||
mock.ANY)
|
||||
self.assertFalse(mock_event.called)
|
||||
|
||||
def _continue_node_deploy_abort(self):
|
||||
last_deploy_step = self.deploy_steps[0]
|
||||
last_deploy_step['abortable'] = False
|
||||
last_deploy_step['abort_after'] = True
|
||||
driver_info = {'deploy_steps': self.deploy_steps,
|
||||
'deploy_step_index': 0,
|
||||
'steps_validated': True}
|
||||
tgt_prov_state = states.ACTIVE
|
||||
node = obj_utils.create_test_node(
|
||||
self.context, driver='fake-hardware',
|
||||
provision_state=states.DEPLOYWAIT,
|
||||
target_provision_state=tgt_prov_state, last_error=None,
|
||||
driver_internal_info=driver_info, deploy_step=self.deploy_steps[0])
|
||||
|
||||
self._start_service()
|
||||
self.service.continue_node_deploy(self.context, node.uuid)
|
||||
node.refresh()
|
||||
self.assertEqual(states.DEPLOYFAIL, node.provision_state)
|
||||
self.assertEqual(tgt_prov_state, node.target_provision_state)
|
||||
self.assertIsNotNone(node.last_error)
|
||||
# assert the deploy step name is in the last error message
|
||||
self.assertIn(self.deploy_steps[0]['step'], node.last_error)
|
||||
|
||||
def test_continue_node_deploy_abort(self):
|
||||
self._continue_node_deploy_abort()
|
||||
|
||||
def _continue_node_deploy_abort_last_deploy_step(self):
|
||||
last_deploy_step = self.deploy_steps[0]
|
||||
last_deploy_step['abortable'] = False
|
||||
last_deploy_step['abort_after'] = True
|
||||
driver_info = {'deploy_steps': [self.deploy_steps[0]],
|
||||
'deploy_step_index': 0,
|
||||
'steps_validated': True}
|
||||
tgt_prov_state = states.ACTIVE
|
||||
node = obj_utils.create_test_node(
|
||||
self.context, driver='fake-hardware',
|
||||
provision_state=states.DEPLOYWAIT,
|
||||
target_provision_state=tgt_prov_state, last_error=None,
|
||||
driver_internal_info=driver_info, deploy_step=self.deploy_steps[0])
|
||||
|
||||
self._start_service()
|
||||
self.service.continue_node_deploy(self.context, node.uuid)
|
||||
node.refresh()
|
||||
self.assertEqual(states.ACTIVE, node.provision_state)
|
||||
self.assertIsNone(node.target_provision_state)
|
||||
self.assertIsNone(node.last_error)
|
||||
|
||||
def test_continue_node_deploy_abort_last_deploy_step(self):
|
||||
self._continue_node_deploy_abort_last_deploy_step()
|
||||
|
||||
|
||||
@mgr_utils.mock_record_keepalive
|
||||
class CheckTimeoutsTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase):
|
||||
@@ -2906,6 +2956,47 @@ class DoProvisioningActionTestCase(mgr_utils.ServiceSetUpMixin,
|
||||
states.DEPLOYHOLD)
|
||||
self.assertNotIn('agent_url', node.driver_internal_info)
|
||||
|
||||
@mock.patch('ironic.conductor.manager.ConductorManager._spawn_worker',
|
||||
autospec=True)
|
||||
def test_do_provision_action_abort_from_deploywait(self, mock_spawn):
|
||||
node = obj_utils.create_test_node(
|
||||
self.context, driver='fake-hardware',
|
||||
provision_state=states.DEPLOYWAIT,
|
||||
driver_internal_info={
|
||||
'agent_url': 'https://foo.bar/'
|
||||
})
|
||||
|
||||
self._start_service()
|
||||
self.service.do_provisioning_action(self.context, node.uuid, 'abort')
|
||||
node.refresh()
|
||||
mock_spawn.assert_called_with(
|
||||
self.service,
|
||||
deployments.do_node_deploy_abort,
|
||||
mock.ANY)
|
||||
self.assertNotIn('agent_url', node.driver_internal_info)
|
||||
|
||||
@mock.patch('ironic.conductor.manager.ConductorManager._spawn_worker',
|
||||
autospec=True)
|
||||
def test_do_provision_action_abort_from_deploywait_step_not_abortable(
|
||||
self, mock_spawn):
|
||||
node = obj_utils.create_test_node(
|
||||
self.context, driver='fake-hardware',
|
||||
provision_state=states.DEPLOYWAIT,
|
||||
target_provision_state=states.ACTIVE,
|
||||
deploy_step={'step': 'foo', 'abortable': False})
|
||||
|
||||
self._start_service(start_allocations=False)
|
||||
self.service.do_provisioning_action(self.context, node.uuid, 'abort')
|
||||
node.refresh()
|
||||
# Assert the current deploy step was marked to be aborted later
|
||||
self.assertIn('abort_after', node.deploy_step)
|
||||
self.assertTrue(node.deploy_step['abort_after'])
|
||||
# Make sure things stays as it was before
|
||||
self.assertEqual(states.DEPLOYWAIT, node.provision_state)
|
||||
self.assertEqual(states.ACTIVE, node.target_provision_state)
|
||||
# Make sure spawn worker was not called
|
||||
mock_spawn.assert_not_called()
|
||||
|
||||
@mock.patch('ironic.conductor.manager.ConductorManager._spawn_worker',
|
||||
autospec=True)
|
||||
def test_do_provision_action_abort_from_servicefail(self, mock_spawn):
|
||||
|
||||
@@ -1650,16 +1650,16 @@ class StepMethodsTestCase(db_base.DbTestCase):
|
||||
steps = self.deploy.get_deploy_steps(task)
|
||||
# 2 in-band steps + 3 out-of-band
|
||||
expected = [
|
||||
{'step': 'deploy', 'priority': 100, 'argsinfo': None,
|
||||
'interface': 'deploy'},
|
||||
{'step': 'deploy', 'priority': 100, 'abortable': True,
|
||||
'argsinfo': None, 'interface': 'deploy'},
|
||||
{'step': 'prepare_instance_boot', 'priority': 60,
|
||||
'abortable': True, 'argsinfo': None, 'interface': 'deploy'},
|
||||
{'step': 'tear_down_agent', 'priority': 40, 'abortable': True,
|
||||
'argsinfo': None, 'interface': 'deploy'},
|
||||
{'step': 'tear_down_agent', 'priority': 40, 'argsinfo': None,
|
||||
'interface': 'deploy'},
|
||||
{'step': 'switch_to_tenant_network', 'priority': 30,
|
||||
'abortable': True, 'argsinfo': None, 'interface': 'deploy'},
|
||||
{'step': 'boot_instance', 'priority': 20, 'abortable': True,
|
||||
'argsinfo': None, 'interface': 'deploy'},
|
||||
{'step': 'boot_instance', 'priority': 20, 'argsinfo': None,
|
||||
'interface': 'deploy'},
|
||||
] + self.clean_steps['deploy']
|
||||
self.assertCountEqual(expected, steps)
|
||||
|
||||
@@ -1669,16 +1669,16 @@ class StepMethodsTestCase(db_base.DbTestCase):
|
||||
steps = self.deploy.get_deploy_steps(task)
|
||||
# three base out-of-band steps
|
||||
expected = [
|
||||
{'step': 'deploy', 'priority': 100, 'argsinfo': None,
|
||||
'interface': 'deploy'},
|
||||
{'step': 'deploy', 'priority': 100, 'abortable': True,
|
||||
'argsinfo': None, 'interface': 'deploy'},
|
||||
{'step': 'prepare_instance_boot', 'priority': 60,
|
||||
'abortable': True, 'argsinfo': None, 'interface': 'deploy'},
|
||||
{'step': 'tear_down_agent', 'priority': 40, 'abortable': True,
|
||||
'argsinfo': None, 'interface': 'deploy'},
|
||||
{'step': 'tear_down_agent', 'priority': 40, 'argsinfo': None,
|
||||
'interface': 'deploy'},
|
||||
{'step': 'switch_to_tenant_network', 'priority': 30,
|
||||
'abortable': True, 'argsinfo': None, 'interface': 'deploy'},
|
||||
{'step': 'boot_instance', 'priority': 20, 'abortable': True,
|
||||
'argsinfo': None, 'interface': 'deploy'},
|
||||
{'step': 'boot_instance', 'priority': 20, 'argsinfo': None,
|
||||
'interface': 'deploy'},
|
||||
]
|
||||
self.assertCountEqual(expected, steps)
|
||||
|
||||
|
||||
@@ -268,8 +268,8 @@ class RamdiskDeployTestCase(db_base.DbTestCase):
|
||||
|
||||
def test_get_deploy_steps(self):
|
||||
# Only the default deploy step exists in the ramdisk deploy
|
||||
expected = [{'argsinfo': None, 'interface': 'deploy', 'priority': 100,
|
||||
'step': 'deploy'}]
|
||||
expected = [{'argsinfo': None, 'abortable': True,
|
||||
'interface': 'deploy', 'priority': 100, 'step': 'deploy'}]
|
||||
with task_manager.acquire(self.context, self.node.uuid) as task:
|
||||
steps = task.driver.deploy.get_deploy_steps(task)
|
||||
self.assertEqual(expected, steps)
|
||||
|
||||
@@ -353,6 +353,7 @@ class DeployStepDecoratorTestCase(base.TestCase):
|
||||
method_mock = mock.MagicMock()
|
||||
del method_mock._is_deploy_step
|
||||
del method_mock._deploy_step_priority
|
||||
del method_mock._deploy_step_abortable
|
||||
del method_mock._deploy_step_argsinfo
|
||||
self.method = method_mock
|
||||
|
||||
@@ -371,6 +372,32 @@ class DeployStepDecoratorTestCase(base.TestCase):
|
||||
self.assertTrue(self.method._is_deploy_step)
|
||||
self.assertEqual(0, self.method._deploy_step_priority)
|
||||
self.assertEqual(argsinfo, self.method._deploy_step_argsinfo)
|
||||
# Defaults to True
|
||||
self.assertTrue(self.method._deploy_step_abortable)
|
||||
|
||||
def test_deploy_step_abortable_default(self):
|
||||
d = driver_base.deploy_step(priority=10)
|
||||
d(self.method)
|
||||
self.assertTrue(self.method._is_deploy_step)
|
||||
self.assertEqual(10, self.method._deploy_step_priority)
|
||||
# Defaults to True
|
||||
self.assertTrue(self.method._deploy_step_abortable)
|
||||
|
||||
def test_deploy_step_abortable_false(self):
|
||||
d = driver_base.deploy_step(priority=10, abortable=False)
|
||||
d(self.method)
|
||||
self.assertTrue(self.method._is_deploy_step)
|
||||
self.assertEqual(10, self.method._deploy_step_priority)
|
||||
self.assertFalse(self.method._deploy_step_abortable)
|
||||
|
||||
def test_deploy_step_bad_abortable(self):
|
||||
d = driver_base.deploy_step(priority=0, abortable='blue')
|
||||
self.assertRaisesRegex(exception.InvalidParameterValue, 'abortable',
|
||||
d, self.method)
|
||||
self.assertTrue(self.method._is_deploy_step)
|
||||
self.assertEqual(0, self.method._deploy_step_priority)
|
||||
self.assertFalse(hasattr(self.method, '_deploy_step_abortable'))
|
||||
self.assertFalse(hasattr(self.method, '_deploy_step_argsinfo'))
|
||||
|
||||
def test_deploy_step_bad_priority(self):
|
||||
d = driver_base.deploy_step(priority='hi')
|
||||
@@ -398,6 +425,7 @@ class DeployAndCleanStepDecoratorTestCase(base.TestCase):
|
||||
method_mock = mock.MagicMock()
|
||||
del method_mock._is_deploy_step
|
||||
del method_mock._deploy_step_priority
|
||||
del method_mock._deploy_step_abortable
|
||||
del method_mock._deploy_step_argsinfo
|
||||
del method_mock._is_clean_step
|
||||
del method_mock._clean_step_priority
|
||||
@@ -411,9 +439,11 @@ class DeployAndCleanStepDecoratorTestCase(base.TestCase):
|
||||
dd(dc(self.method))
|
||||
self.assertTrue(self.method._is_deploy_step)
|
||||
self.assertEqual(10, self.method._deploy_step_priority)
|
||||
self.assertTrue(self.method._deploy_step_abortable) # defaults to True
|
||||
self.assertIsNone(self.method._deploy_step_argsinfo)
|
||||
self.assertTrue(self.method._is_clean_step)
|
||||
self.assertEqual(11, self.method._clean_step_priority)
|
||||
# defaults to False
|
||||
self.assertFalse(self.method._clean_step_abortable)
|
||||
self.assertIsNone(self.method._clean_step_argsinfo)
|
||||
|
||||
@@ -427,9 +457,11 @@ class DeployAndCleanStepDecoratorTestCase(base.TestCase):
|
||||
dd(dc(self.method))
|
||||
self.assertTrue(self.method._is_deploy_step)
|
||||
self.assertEqual(0, self.method._deploy_step_priority)
|
||||
self.assertTrue(self.method._deploy_step_abortable) # defaults to True
|
||||
self.assertEqual(dargsinfo, self.method._deploy_step_argsinfo)
|
||||
self.assertTrue(self.method._is_clean_step)
|
||||
self.assertEqual(0, self.method._clean_step_priority)
|
||||
# defaults to False
|
||||
self.assertFalse(self.method._clean_step_abortable)
|
||||
self.assertEqual(cargsinfo, self.method._clean_step_argsinfo)
|
||||
|
||||
@@ -444,9 +476,11 @@ class DeployAndCleanStepDecoratorTestCase(base.TestCase):
|
||||
dc(dd(self.method))
|
||||
self.assertTrue(self.method._is_deploy_step)
|
||||
self.assertEqual(0, self.method._deploy_step_priority)
|
||||
self.assertTrue(self.method._deploy_step_abortable) # defaults to True
|
||||
self.assertEqual(dargsinfo, self.method._deploy_step_argsinfo)
|
||||
self.assertTrue(self.method._is_clean_step)
|
||||
self.assertEqual(0, self.method._clean_step_priority)
|
||||
# defaults to False
|
||||
self.assertFalse(self.method._clean_step_abortable)
|
||||
self.assertEqual(cargsinfo, self.method._clean_step_argsinfo)
|
||||
|
||||
@@ -571,6 +605,40 @@ class DeployStepTestCase(base.TestCase):
|
||||
obj3.execute_deploy_step(task_mock, deploy_step)
|
||||
method_args_mock.assert_called_once_with(task_mock, **args)
|
||||
|
||||
def test_deploy_steps_include_abortable(self):
|
||||
# Verify that deploy steps include the abortable field
|
||||
class BaseTestClass(driver_base.BaseInterface):
|
||||
def get_properties(self):
|
||||
return {}
|
||||
|
||||
def validate(self, task):
|
||||
pass
|
||||
|
||||
class TestClass(BaseTestClass):
|
||||
interface_type = 'test'
|
||||
|
||||
@driver_base.deploy_step(priority=10)
|
||||
def deploy_abortable_default(self, task):
|
||||
pass
|
||||
|
||||
@driver_base.deploy_step(priority=20, abortable=False)
|
||||
def deploy_not_abortable(self, task):
|
||||
pass
|
||||
|
||||
obj = TestClass()
|
||||
task_mock = mock.MagicMock(spec_set=[])
|
||||
steps = obj.get_deploy_steps(task_mock)
|
||||
|
||||
# Find the steps
|
||||
abortable_step = [s for s in steps
|
||||
if s['step'] == 'deploy_abortable_default'][0]
|
||||
non_abortable_step = [s for s in steps
|
||||
if s['step'] == 'deploy_not_abortable'][0]
|
||||
|
||||
# Verify the abortable field
|
||||
self.assertTrue(abortable_step['abortable']) # defaults to True
|
||||
self.assertFalse(non_abortable_step['abortable'])
|
||||
|
||||
|
||||
class MyRAIDInterface(driver_base.RAIDInterface):
|
||||
|
||||
|
||||
28
releasenotes/notes/deploywait-abort-e8f9a2b4c7d3e1f5.yaml
Normal file
28
releasenotes/notes/deploywait-abort-e8f9a2b4c7d3e1f5.yaml
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
features:
|
||||
- |
|
||||
Adds support for aborting deployment while in the ``wait call-back``
|
||||
(DEPLOYWAIT) provision state. This provides consistency with other wait
|
||||
states (CLEANWAIT, INSPECTWAIT, RESCUEWAIT, SERVICEWAIT) that already
|
||||
support the abort verb. Operators can now issue the abort command on a
|
||||
node in DEPLOYWAIT state, which will transition it to DEPLOYFAIL.
|
||||
|
||||
Unlike aborting from DEPLOYHOLD which triggers a full teardown and
|
||||
cleaning, aborting from DEPLOYWAIT performs a deployment cleanup and
|
||||
leaves the node in DEPLOYFAIL state without triggering automated cleaning.
|
||||
This allows operators to investigate the issue or retry deployment without
|
||||
waiting for a full cleaning cycle.
|
||||
|
||||
This feature is available starting with API version 1.110. When a node
|
||||
is in DEPLOYWAIT and has a non-abortable deploy step running, the abort
|
||||
request will flag the step to be aborted after completion, similar to
|
||||
the behavior for cleaning and servicing.
|
||||
- |
|
||||
The ``@deploy_step`` decorator now supports an ``abortable`` parameter,
|
||||
similar to ``@clean_step`` and ``@service_step`` decorators. This allows
|
||||
deploy steps to be marked as non-abortable for critical operations that
|
||||
cannot be safely interrupted. The default value is ``True`` (abortable)
|
||||
for backward compatibility. Steps that are both clean and deploy steps
|
||||
with ``abortable=False`` in the clean step decorator have been updated
|
||||
to also have ``abortable=False`` in their deploy step decorator for
|
||||
consistency.
|
||||
Reference in New Issue
Block a user