Merge "Allow aborting deployments in "wait call-back" state"

This commit is contained in:
Zuul
2026-03-02 17:47:34 +00:00
committed by Gerrit Code Review
19 changed files with 430 additions and 24 deletions

View File

@@ -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)
----------------------

View File

@@ -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']:

View File

@@ -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.

View File

@@ -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)

View File

@@ -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': {

View File

@@ -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')

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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):

View File

@@ -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

View File

@@ -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)

View File

@@ -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):

View File

@@ -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)

View File

@@ -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):

View File

@@ -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)

View File

@@ -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)

View File

@@ -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):

View 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.