Add force to action_update

This patch adds the force query parameter to action_update.
When force is set to true on an action cancel request the
action status will be updated in the database to cancelled
rather than signaled to cancel. This will also update any
child actions to update their status in the database to
cancelled.

Change-Id: I45ab4a6ad57e3bfe791da1d2e70d3b2ed3ad9a21
This commit is contained in:
Jude Cross 2018-12-11 16:30:49 -08:00
parent 94c448276f
commit 44b5ae89f6
11 changed files with 154 additions and 9 deletions

View File

@ -163,6 +163,7 @@ Request Parameters
- action_id: action_id_url
- action: action
- status: action_status_update
- force: action_update_force_query
Request Example
---------------

View File

@ -131,6 +131,12 @@ action_status_query:
description: |
Filters the results by the ``status`` property of an action object.
action_update_force_query:
type: boolean
in: query
description: |
A boolean indicating if the action update request should be forced.
cluster_identity_query:
type: string
in: query
@ -358,11 +364,11 @@ action_status:
A string representation of the current status of the action.
action_status_update:
type: object
type: string
in: body
required: True
description: |
A string representation of the action status to update CANCELLED is
A string representation of the action status to update. CANCELLED is
the only valid status at this time.
action_target:

View File

@ -107,6 +107,15 @@ class ActionController(wsgi.Controller):
if data is None:
raise exc.HTTPBadRequest(_("Malformed request data, missing "
"'action' key in request body."))
force_update = req.params.get('force')
if force_update is not None:
force = util.parse_bool_param(consts.ACTION_UPDATE_FORCE,
force_update)
else:
force = False
data['force'] = force
data['identity'] = action_id
obj = util.parse_request('ActionUpdateRequest', req, data)

View File

@ -268,6 +268,12 @@ ACTION_STATUSES = (
'SUSPENDED',
)
ACTION_PARAMS = (
ACTION_UPDATE_FORCE,
) = (
'force',
)
EVENT_LEVELS = {
'CRITICAL': logging.CRITICAL,
'ERROR': logging.ERROR,

View File

@ -368,6 +368,38 @@ class Action(object):
action.set_status(action.RES_CANCEL,
'Action execution cancelled')
def force_cancel(self):
"""Force the action and any depended actions to cancel.
If the action or any depended actions are in status 'INIT', 'WAITING',
'READY', 'RUNNING', or 'WAITING_LIFECYCLE_COMPLETION' immediately
update their status to cancelled. This should only be used if an action
is stuck/dead and has no expectation of ever completing.
:raises: `ActionImmutable` if the action is in an unchangeable state
"""
expected = (self.INIT, self.WAITING, self.READY, self.RUNNING,
self.WAITING_LIFECYCLE_COMPLETION)
if self.status not in expected:
raise exception.ActionImmutable(id=self.id[:8], expected=expected,
actual=self.status)
LOG.debug('Forcing action %s to cancel.', self.id)
self.set_status(self.RES_CANCEL, 'Action execution force cancelled')
depended = dobj.Dependency.get_depended(self.context, self.id)
if not depended:
return
for child in depended:
# Force cancel all dependant actions
action = self.load(self.context, action_id=child)
if action.status in (action.INIT, action.WAITING, action.READY,
action.RUNNING,
action.WAITING_LIFECYCLE_COMPLETION):
LOG.debug('Forcing action %s to cancel.', action.id)
action.set_status(action.RES_CANCEL,
'Action execution force cancelled')
def execute(self, **kwargs):
"""Execute the action.

View File

@ -2315,8 +2315,11 @@ class EngineService(service.Service):
if req.status == consts.ACTION_CANCELLED:
action = action_mod.Action.load(ctx, req.identity,
project_safe=False)
LOG.info("Signaling action '%s' to Cancel.", req.identity)
action.signal_cancel()
if req.force:
action.force_cancel()
else:
LOG.info("Signaling action '%s' to Cancel.", req.identity)
action.signal_cancel()
else:
msg = ("Unknown status %(status)s for action %(action)s" %
{"status": req.status, "action": req.identity})

View File

@ -75,5 +75,6 @@ class ActionUpdateRequest(base.SenlinObject):
fields = {
'identity': fields.StringField(),
'status': fields.StringField()
'status': fields.StringField(),
'force': fields.BooleanField(default=False)
}

View File

@ -336,7 +336,41 @@ class ActionControllerTest(shared.ControllerTest, base.SenlinTestCase):
'ActionUpdateRequest', req,
{
'identity': aid,
'status': 'CANCELLED',
'force': False
})
mock_call.assert_called_once_with(req.context, 'action_update', obj)
@mock.patch.object(util, 'parse_bool_param')
@mock.patch.object(util, 'parse_request')
@mock.patch.object(rpc_client.EngineClient, 'call')
def test_action_update_force_cancel(self, mock_call, mock_parse,
mock_parse_bool, mock_enforce):
self._mock_enforce_setup(mock_enforce, 'update', True)
aid = 'xxxx-yyyy-zzzz'
body = {
'action': {
'status': 'CANCELLED'
}
}
params = {'force': 'True'}
req = self._patch(
'/actions/%(action_id)s' % {'action_id': aid},
jsonutils.dumps(body), version='1.12', params=params)
obj = mock.Mock()
mock_parse.return_value = obj
mock_parse_bool.return_value = True
self.assertRaises(exc.HTTPAccepted,
self.controller.update, req,
action_id=aid, body=body)
mock_parse.assert_called_once_with(
'ActionUpdateRequest', req,
{
'identity': aid,
'status': 'CANCELLED',
'force': True
})
mock_call.assert_called_once_with(req.context, 'action_update', obj)

View File

@ -85,10 +85,14 @@ class ControllerTest(object):
return self._simple_request(path, params=params, method='DELETE')
def _data_request(self, path, data, content_type='application/json',
method='POST', version=None):
method='POST', version=None, params=None):
environ = self._environ(path)
environ['REQUEST_METHOD'] = method
if params:
qs = "&".join(["=".join([k, str(params[k])]) for k in params])
environ['QUERY_STRING'] = qs
req = wsgi.Request(environ)
req.context = utils.dummy_context('api_test_user', self.project)
self.context = req.context
@ -104,10 +108,10 @@ class ControllerTest(object):
return self._data_request(path, data, content_type, method='PUT',
version=version)
def _patch(self, path, data, content_type='application/json',
def _patch(self, path, data, params=None, content_type='application/json',
version=None):
return self._data_request(path, data, content_type, method='PATCH',
version=version)
version=version, params=params)
def tearDown(self):
# Common tearDown to assert that policy enforcement happens for all

View File

@ -606,6 +606,55 @@ class ActionBaseTest(base.SenlinTestCase):
action.set_status.assert_not_called()
mock_signal.aseert_not_called()
@mock.patch.object(dobj.Dependency, 'get_depended')
def test_force_cancel(self, mock_dobj):
action = ab.Action(OBJID, 'OBJECT_ACTION', self.ctx, id=ACTION_ID)
action.load = mock.Mock()
action.set_status = mock.Mock()
mock_dobj.return_value = None
action.status = action.RUNNING
action.force_cancel()
action.load.assert_not_called()
action.set_status.assert_called_once_with(
action.RES_CANCEL, 'Action execution force cancelled')
@mock.patch.object(dobj.Dependency, 'get_depended')
def test_force_cancel_children(self, mock_dobj):
action = ab.Action(OBJID, 'OBJECT_ACTION', self.ctx, id=ACTION_ID)
child_status_mock = mock.Mock()
children = []
for child_id in CHILD_IDS:
child = ab.Action(OBJID, 'OBJECT_ACTION', self.ctx, id=child_id)
child.status = child.WAITING_LIFECYCLE_COMPLETION
child.set_status = child_status_mock
children.append(child)
mock_dobj.return_value = CHILD_IDS
action.set_status = mock.Mock()
action.load = mock.Mock()
action.load.side_effect = children
action.status = action.RUNNING
action.force_cancel()
mock_dobj.assert_called_once_with(action.context, action.id)
self.assertEqual(2, child_status_mock.call_count)
self.assertEqual(2, action.load.call_count)
@mock.patch.object(dobj.Dependency, 'get_depended')
def test_force_cancel_immutable(self, mock_dobj):
action = ab.Action(OBJID, 'OBJECT_ACTION', self.ctx, id=ACTION_ID)
action.load = mock.Mock()
action.set_status = mock.Mock()
mock_dobj.return_value = None
action.status = action.FAILED
self.assertRaises(exception.ActionImmutable, action.force_cancel)
action.load.assert_not_called()
action.set_status.assert_not_called()
def test_execute_default(self):
action = ab.Action.__new__(DummyAction, OBJID, 'BOOM', self.ctx)
self.assertRaises(NotImplementedError,

View File

@ -215,7 +215,7 @@ class ActionTest(base.SenlinTestCase):
mock_load.return_value = x_obj
req = orao.ActionUpdateRequest(identity='ACTION_ID',
status='CANCELLED')
status='CANCELLED', force=False)
result = self.eng.action_update(self.ctx, req.obj_to_primitive())
self.assertIsNone(result)