diff --git a/api-ref/source/actions.inc b/api-ref/source/actions.inc index bb1df1b5b..d0f2e56d4 100644 --- a/api-ref/source/actions.inc +++ b/api-ref/source/actions.inc @@ -142,3 +142,46 @@ Response Example .. literalinclude:: samples/action-get-response.json :language: javascript + +Update action +============= + +.. rest_method:: PATCH /v1/actions/{action_id} + + min_version: 1.12 + +Update status of an action. + +This API is only available since API microversion 1.12. + +Request Parameters +------------------ + +.. rest_parameters:: parameters.yaml + + - OpenStack-API-Version: microversion + - action_id: action_id_url + - action: action + - status: action_status_update + +Request Example +--------------- + +.. literalinclude:: samples/action-get-request.json + :language: javascript + +Response Codes +-------------- + +.. rest_status_code:: success status.yaml + + - 202 + +.. rest_status_code:: error status.yaml + + - 400 + - 401 + - 403 + - 404 + - 409 + - 503 diff --git a/api-ref/source/parameters.yaml b/api-ref/source/parameters.yaml index 989399d6a..344ddd092 100644 --- a/api-ref/source/parameters.yaml +++ b/api-ref/source/parameters.yaml @@ -357,6 +357,14 @@ action_status: description: | A string representation of the current status of the action. +action_status_update: + type: object + in: body + required: True + description: | + A string representation of the action status to update CANCELLED is + the only valid status at this time. + action_target: type: string in: body diff --git a/api-ref/source/samples/action-get-request.json b/api-ref/source/samples/action-get-request.json new file mode 100644 index 000000000..c3d511d00 --- /dev/null +++ b/api-ref/source/samples/action-get-request.json @@ -0,0 +1,5 @@ +{ + "action": { + "status": "CANCELLED", + } +} diff --git a/senlin/api/middleware/fault.py b/senlin/api/middleware/fault.py index c2f3d7891..e3ba8c6a7 100644 --- a/senlin/api/middleware/fault.py +++ b/senlin/api/middleware/fault.py @@ -45,6 +45,7 @@ class FaultWrapper(wsgi.Middleware): 'ActionConflict': webob.exc.HTTPConflict, 'ActionCooldown': webob.exc.HTTPConflict, 'ActionInProgress': webob.exc.HTTPConflict, + 'ActionImmutable': webob.exc.HTTPConflict, 'BadRequest': webob.exc.HTTPBadRequest, 'FeatureNotSupported': webob.exc.HTTPConflict, 'Forbidden': webob.exc.HTTPForbidden, diff --git a/senlin/api/openstack/history.rst b/senlin/api/openstack/history.rst index eebef466f..337b963ea 100755 --- a/senlin/api/openstack/history.rst +++ b/senlin/api/openstack/history.rst @@ -117,3 +117,8 @@ it can be used by both users and developers. response code 409 when a scaling action conflicts with one already being processed or a cooldown for a scaling action is encountered. +1.12 +---- +- Added ``action_update`` API. This API enables users to update the status of + an action (only CANCELLED is supported). An action that spawns dependent + actions will attempt to cancel all dependent actions. diff --git a/senlin/api/openstack/v1/actions.py b/senlin/api/openstack/v1/actions.py index 228ee4b44..ebbf5229d 100644 --- a/senlin/api/openstack/v1/actions.py +++ b/senlin/api/openstack/v1/actions.py @@ -99,3 +99,17 @@ class ActionController(wsgi.Controller): action = self.rpc_client.call(req.context, 'action_get', obj) return {'action': action} + + @wsgi.Controller.api_version('1.12') + @util.policy_enforce + def update(self, req, action_id, body): + data = body.get('action') + if data is None: + raise exc.HTTPBadRequest(_("Malformed request data, missing " + "'action' key in request body.")) + data['identity'] = action_id + + obj = util.parse_request('ActionUpdateRequest', req, data) + self.rpc_client.call(req.context, 'action_update', obj) + + raise exc.HTTPAccepted diff --git a/senlin/api/openstack/v1/router.py b/senlin/api/openstack/v1/router.py index 096145e92..218ce8c5e 100644 --- a/senlin/api/openstack/v1/router.py +++ b/senlin/api/openstack/v1/router.py @@ -255,6 +255,10 @@ class API(wsgi.Router): "/actions/{action_id}", action="get", conditions={'method': 'GET'}) + sub_mapper.connect("action_update", + "/actions/{action_id}", + action="update", + conditions={'method': 'PATCH'}) # Receivers res = wsgi.Resource(receivers.ReceiverController(conf)) diff --git a/senlin/api/openstack/v1/version.py b/senlin/api/openstack/v1/version.py index 204778282..8540262af 100755 --- a/senlin/api/openstack/v1/version.py +++ b/senlin/api/openstack/v1/version.py @@ -24,7 +24,7 @@ class VersionController(object): # This includes any semantic changes which may not affect the input or # output formats or even originate in the API code layer. _MIN_API_VERSION = "1.0" - _MAX_API_VERSION = "1.11" + _MAX_API_VERSION = "1.12" DEFAULT_API_VERSION = _MIN_API_VERSION diff --git a/senlin/common/exception.py b/senlin/common/exception.py index 063568003..435112277 100644 --- a/senlin/common/exception.py +++ b/senlin/common/exception.py @@ -200,6 +200,11 @@ class ActionCooldown(SenlinException): "progress") +class ActionImmutable(SenlinException): + msg_fmt = _("Action (%(id)s) is in status (%(actual)s) while expected " + "status must be one of (%(expected)s).") + + class NodeNotOrphan(SenlinException): msg_fmt = _("%(message)s") diff --git a/senlin/common/policies/actions.py b/senlin/common/policies/actions.py index fb36e69a5..6de922b7c 100644 --- a/senlin/common/policies/actions.py +++ b/senlin/common/policies/actions.py @@ -39,6 +39,17 @@ rules = [ 'method': 'GET' } ] + ), + policy.DocumentedRuleDefault( + name="actions:update", + check_str=base.UNPROTECTED, + description="Update action", + operations=[ + { + 'path': '/v1/actions/{action_id}', + 'method': 'PATCH' + } + ] ) ] diff --git a/senlin/db/sqlalchemy/api.py b/senlin/db/sqlalchemy/api.py index f23713a76..aab8f3e47 100755 --- a/senlin/db/sqlalchemy/api.py +++ b/senlin/db/sqlalchemy/api.py @@ -1272,7 +1272,7 @@ def _mark_cancelled(session, action_id, timestamp, reason=None): 'owner': None, 'status': consts.ACTION_CANCELLED, 'status_reason': (six.text_type(reason) if reason else - 'Action execution failed'), + 'Action execution cancelled'), 'end_time': timestamp, } query.update(values, synchronize_session=False) diff --git a/senlin/engine/actions/base.py b/senlin/engine/actions/base.py index 598c707f9..86d113173 100755 --- a/senlin/engine/actions/base.py +++ b/senlin/engine/actions/base.py @@ -271,8 +271,8 @@ class Action(object): @staticmethod def _check_action_lock(target, action): - if action == consts.CLUSTER_DELETE: - # CLUSTER_DELETE actions do not care about cluster locks + if action == consts.CLUSTER_DELETE or action == consts.NODE_DELETE: + # DELETE actions do not care about locks return elif (action in list(consts.CLUSTER_ACTION_NAMES) and cl.ClusterLock.is_locked(target)): @@ -286,14 +286,11 @@ class Action(object): @staticmethod def _check_conflicting_actions(ctx, target, action): conflict_actions = ao.Action.get_all_active_by_target(ctx, target) - if conflict_actions and action == consts.CLUSTER_DELETE: - delete_ids = [a['id'] for a in conflict_actions - if a['action'] == consts.CLUSTER_DELETE] - if delete_ids: - raise exception.ActionConflict( - type=action, target=target, actions=",".join( - delete_ids)) - elif conflict_actions: + # Ignore conflicting actions on deletes. + if not conflict_actions or action in (consts.CLUSTER_DELETE, + consts.NODE_DELETE): + return + else: action_ids = [a['id'] for a in conflict_actions] raise exception.ActionConflict( type=action, target=target, actions=",".join(action_ids)) @@ -318,21 +315,59 @@ class Action(object): return if cmd == self.SIG_CANCEL: - expected = (self.INIT, self.WAITING, self.READY, self.RUNNING) + expected = (self.INIT, self.WAITING, self.READY, self.RUNNING, + self.WAITING_LIFECYCLE_COMPLETION) elif cmd == self.SIG_SUSPEND: expected = (self.RUNNING) else: # SIG_RESUME expected = (self.SUSPENDED) if self.status not in expected: - LOG.error("Action (%(id)s) is in status (%(actual)s) while " - "expected status must be one of (%(expected)s).", - dict(id=self.id[:8], expected=expected, - actual=self.status)) + LOG.info("Action (%(id)s) is in status (%(actual)s) while " + "expected status must be one of (%(expected)s).", + dict(id=self.id[:8], expected=expected, + actual=self.status)) return ao.Action.signal(self.context, self.id, cmd) + def signal_cancel(self): + """Signal the action and any depended actions to cancel. + + If the action or any depended actions are in status + 'WAITING_LIFECYCLE_COMPLETION' or 'INIT' update the status to cancelled + directly. + + :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) + + ao.Action.signal(self.context, self.id, self.SIG_CANCEL) + + if self.status in (self.WAITING_LIFECYCLE_COMPLETION, self.INIT): + self.set_status(self.RES_CANCEL, 'Action execution cancelled') + + depended = dobj.Dependency.get_depended(self.context, self.id) + if not depended: + return + + for child in depended: + # Try to cancel all dependant actions + action = self.load(self.context, action_id=child) + if not action.is_cancelled(): + ao.Action.signal(self.context, child, self.SIG_CANCEL) + # If the action is in WAITING_LIFECYCLE_COMPLETION or INIT update + # the status to CANCELLED immediately. + if action.status in (action.WAITING_LIFECYCLE_COMPLETION, + action.INIT): + action.set_status(action.RES_CANCEL, + 'Action execution cancelled') + def execute(self, **kwargs): """Execute the action. @@ -409,6 +444,8 @@ class Action(object): def is_timeout(self, timeout=None): if timeout is None: timeout = self.timeout + if self.start_time is None: + return False time_elapse = wallclock() - self.start_time return time_elapse > timeout @@ -593,6 +630,13 @@ def ActionProc(ctx, action_id): LOG.error('Action "%s" could not be found.', action_id) return False + if action.is_cancelled(): + reason = '%(action)s [%(id)s] cancelled' % { + 'action': action.action, 'id': action.id[:8]} + action.set_status(action.RES_CANCEL, reason) + LOG.info(reason) + return True + EVENT.info(action, consts.PHASE_START, action_id[:8]) reason = 'Action completed' diff --git a/senlin/engine/actions/cluster_action.py b/senlin/engine/actions/cluster_action.py index a33fd4843..810218f56 100755 --- a/senlin/engine/actions/cluster_action.py +++ b/senlin/engine/actions/cluster_action.py @@ -68,23 +68,33 @@ class ClusterAction(base.Action): status = self.get_status() while status != self.READY: if status == self.FAILED: - reason = ('%(action)s [%(id)s] failed') % { - 'action': self.action, 'id': self.id[:8]} + reason = ('%(action)s [%(id)s] failed' % { + 'action': self.action, 'id': self.id[:8]}) LOG.debug(reason) return self.RES_ERROR, reason if self.is_cancelled(): # During this period, if cancel request comes, cancel this - # operation immediately, then release the cluster lock - reason = ('%(action)s [%(id)s] cancelled') % { - 'action': self.action, 'id': self.id[:8]} + # operation immediately after signaling children to cancel, + # then release the cluster lock + reason = ('%(action)s [%(id)s] cancelled' % { + 'action': self.action, 'id': self.id[:8]}) LOG.debug(reason) return self.RES_CANCEL, reason + # When a child action is cancelled the parent action will update + # its status to cancelled as well this allows it to exit. + if status == self.CANCELLED: + if self.check_children_complete(): + reason = ('%(action)s [%(id)s] cancelled' % { + 'action': self.action, 'id': self.id[:8]}) + LOG.debug(reason) + return self.RES_CANCEL, reason + if self.is_timeout(): # Action timeout, return - reason = ('%(action)s [%(id)s] timeout') % { - 'action': self.action, 'id': self.id[:8]} + reason = ('%(action)s [%(id)s] timeout' % { + 'action': self.action, 'id': self.id[:8]}) LOG.debug(reason) return self.RES_TIMEOUT, reason @@ -100,9 +110,23 @@ class ClusterAction(base.Action): # Continue waiting (with reschedule) scheduler.reschedule(self.id, 3) status = self.get_status() + dispatcher.start_action() return self.RES_OK, 'All dependents ended with success' + def check_children_complete(self): + depended = dobj.Dependency.get_depended(self.context, self.id) + if not depended: + return True + + for child in depended: + # Try to cancel all dependant actions + action = base.Action.load(self.context, action_id=child) + if action.get_status() not in (action.CANCELLED, action.SUCCEEDED, + action.FAILED): + return False + return True + def _create_nodes(self, count): """Utility method for node creation. diff --git a/senlin/engine/service.py b/senlin/engine/service.py index ba1a313c1..766d34be4 100755 --- a/senlin/engine/service.py +++ b/senlin/engine/service.py @@ -1696,7 +1696,7 @@ class EngineService(service.Service): @request_context def node_update(self, ctx, req): - """Update a node with new propertye values. + """Update a node with new property values. :param ctx: An instance of the request context. :param req: An instance of the NodeUpdateRequest object. @@ -2302,6 +2302,26 @@ class EngineService(service.Service): LOG.info("Action '%s' is deleted.", req.identity) + @request_context + def action_update(self, ctx, req): + """Update the specified action object. + + :param ctx: An instance of the request context. + :param req: An instance of the ActionUpdateRequest object. + :return: None if update was successful, or an exception of type + `BadRequest`. + """ + # Only allow cancellation of actions at this time. + 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() + else: + msg = ("Unknown status %(status)s for action %(action)s" % + {"status": req.status, "action": req.identity}) + raise exception.BadRequest(msg=msg) + @request_context def receiver_list(self, ctx, req): """List receivers matching the specified criteria. diff --git a/senlin/objects/requests/actions.py b/senlin/objects/requests/actions.py index d7e39242d..4e838750a 100644 --- a/senlin/objects/requests/actions.py +++ b/senlin/objects/requests/actions.py @@ -17,6 +17,7 @@ from senlin.objects import fields @base.SenlinObjectRegistry.register class ActionCreateRequestBody(base.SenlinObject): + fields = { 'name': fields.NameField(), 'cluster_id': fields.StringField(), @@ -67,3 +68,12 @@ class ActionDeleteRequest(base.SenlinObject): fields = { 'identity': fields.StringField() } + + +@base.SenlinObjectRegistry.register +class ActionUpdateRequest(base.SenlinObject): + + fields = { + 'identity': fields.StringField(), + 'status': fields.StringField() + } diff --git a/senlin/tests/unit/api/openstack/v1/test_actions.py b/senlin/tests/unit/api/openstack/v1/test_actions.py index fdfafd764..22360a922 100644 --- a/senlin/tests/unit/api/openstack/v1/test_actions.py +++ b/senlin/tests/unit/api/openstack/v1/test_actions.py @@ -14,6 +14,8 @@ import mock import six from webob import exc +from oslo_serialization import jsonutils + from senlin.api.common import util from senlin.api.middleware import fault from senlin.api.openstack.v1 import actions @@ -309,3 +311,51 @@ class ActionControllerTest(shared.ControllerTest, base.SenlinTestCase): self.assertEqual(403, resp.status_int) self.assertIn('403 Forbidden', six.text_type(resp)) + + @mock.patch.object(util, 'parse_request') + @mock.patch.object(rpc_client.EngineClient, 'call') + def test_action_update_cancel(self, mock_call, mock_parse, mock_enforce): + self._mock_enforce_setup(mock_enforce, 'update', True) + aid = 'xxxx-yyyy-zzzz' + body = { + 'action': { + 'status': 'CANCELLED' + } + } + + req = self._patch('/actions/%(action_id)s' % {'action_id': aid}, + jsonutils.dumps(body), version='1.12') + obj = mock.Mock() + mock_parse.return_value = obj + + 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' + }) + mock_call.assert_called_once_with(req.context, 'action_update', obj) + + @mock.patch.object(util, 'parse_request') + @mock.patch.object(rpc_client.EngineClient, 'call') + def test_action_update_invalid(self, mock_call, mock_parse, mock_enforce): + self._mock_enforce_setup(mock_enforce, 'update', True) + aid = 'xxxx-yyyy-zzzz' + body = {'status': 'FOO'} + + req = self._patch('/actions/%(action_id)s' % {'action_id': aid}, + jsonutils.dumps(body), version='1.12') + + ex = self.assertRaises(exc.HTTPBadRequest, + self.controller.update, req, + action_id=aid, body=body) + + self.assertEqual("Malformed request data, missing 'action' key " + "in request body.", six.text_type(ex)) + + self.assertFalse(mock_parse.called) + self.assertFalse(mock_call.called) diff --git a/senlin/tests/unit/api/openstack/v1/test_router.py b/senlin/tests/unit/api/openstack/v1/test_router.py index f43d181d1..0ef83c12f 100644 --- a/senlin/tests/unit/api/openstack/v1/test_router.py +++ b/senlin/tests/unit/api/openstack/v1/test_router.py @@ -375,6 +375,16 @@ class RoutesTest(base.SenlinTestCase): 'action_id': 'bbbb' }) + self.assertRoute( + self.m, + '/actions/bbbb', + 'PATCH', + 'update', + 'ActionController', + { + 'action_id': 'bbbb' + }) + def test_receiver_collection(self): self.assertRoute( self.m, diff --git a/senlin/tests/unit/engine/actions/test_action_base.py b/senlin/tests/unit/engine/actions/test_action_base.py index 90e0f2bdf..f39f513e6 100755 --- a/senlin/tests/unit/engine/actions/test_action_base.py +++ b/senlin/tests/unit/engine/actions/test_action_base.py @@ -44,6 +44,8 @@ OWNER_ID = 'c7114713-ee68-409d-ba5d-0560a72a386c' ACTION_ID = '4c2cead2-fd74-418a-9d12-bd2d9bd7a812' USER_ID = '3c4d64baadcd437d8dd49054899e73dd' PROJECT_ID = 'cf7a6ae28dde4f46aa8fe55d318a608f' +CHILD_IDS = ['8500ae8f-e632-4e8b-8206-552873cc2c3a', + '67c0eba9-514f-4659-9deb-99868873dfd6'] class DummyAction(ab.Action): @@ -358,30 +360,6 @@ class ActionBaseTest(base.SenlinTestCase): mock_store.assert_not_called() mock_active.assert_called_once_with(mock.ANY, OBJID) - @mock.patch.object(ab.Action, 'store') - @mock.patch.object(ao.Action, 'get_all_active_by_target') - @mock.patch.object(cl.ClusterLock, 'is_locked') - def test_action_create_delete_conflict(self, mock_lock, mock_active, - mock_store): - mock_store.return_value = 'FAKE_ID' - uuid1 = 'ce982cd5-26da-4e2c-84e5-be8f720b7478' - uuid2 = 'ce982cd5-26da-4e2c-84e5-be8f720b7479' - mock_active.return_value = [ - ao.Action(id=uuid1, action='CLUSTER_DELETE'), - ao.Action(id=uuid2, action='NODE_DELETE') - ] - mock_lock.return_value = True - - error_message = ( - 'The CLUSTER_DELETE action for target {} conflicts with the ' - 'following action\(s\): {}').format(OBJID, uuid1) - with self.assertRaisesRegexp(exception.ActionConflict, - error_message): - ab.Action.create(self.ctx, OBJID, 'CLUSTER_DELETE', name='test') - - mock_store.assert_not_called() - mock_active.assert_called_once_with(mock.ANY, OBJID) - @mock.patch.object(ab.Action, 'store') @mock.patch.object(ao.Action, 'get_all_active_by_target') @mock.patch.object(cl.ClusterLock, 'is_locked') @@ -532,6 +510,102 @@ class ActionBaseTest(base.SenlinTestCase): self.assertEqual(0, mock_call.call_count) mock_call.reset_mock() + @mock.patch.object(ao.Action, 'signal') + @mock.patch.object(dobj.Dependency, 'get_depended') + def test_signal_cancel(self, mock_dobj, mock_signal): + 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.signal_cancel() + + action.load.assert_not_called() + action.set_status.assert_not_called() + mock_dobj.assert_called_once_with(action.context, action.id) + mock_signal.assert_called_once_with(action.context, action.id, + action.SIG_CANCEL) + + @mock.patch.object(ao.Action, 'signal') + @mock.patch.object(dobj.Dependency, 'get_depended') + def test_signal_cancel_children(self, mock_dobj, mock_signal): + 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.READY + child.set_status = child_status_mock + children.append(child) + mock_dobj.return_value = CHILD_IDS + action.load = mock.Mock() + action.load.side_effect = children + + action.status = action.RUNNING + action.signal_cancel() + + mock_dobj.assert_called_once_with(action.context, action.id) + child_status_mock.assert_not_called() + self.assertEqual(3, mock_signal.call_count) + self.assertEqual(2, action.load.call_count) + + @mock.patch.object(ao.Action, 'signal') + @mock.patch.object(dobj.Dependency, 'get_depended') + def test_signal_cancel_children_lifecycle(self, mock_dobj, mock_signal): + 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.load = mock.Mock() + action.load.side_effect = children + + action.status = action.RUNNING + action.signal_cancel() + + mock_dobj.assert_called_once_with(action.context, action.id) + self.assertEqual(2, child_status_mock.call_count) + self.assertEqual(3, mock_signal.call_count) + self.assertEqual(2, action.load.call_count) + + @mock.patch.object(ao.Action, 'signal') + @mock.patch.object(dobj.Dependency, 'get_depended') + def test_signal_cancel_lifecycle(self, mock_dobj, mock_signal): + 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.WAITING_LIFECYCLE_COMPLETION + action.signal_cancel() + + action.load.assert_not_called() + action.set_status.assert_called_once_with(action.RES_CANCEL, + 'Action execution cancelled') + mock_dobj.assert_called_once_with(action.context, action.id) + mock_signal.assert_called_once_with(action.context, action.id, + action.SIG_CANCEL) + + @mock.patch.object(ao.Action, 'signal') + @mock.patch.object(dobj.Dependency, 'get_depended') + def test_signal_cancel_immutable(self, mock_dobj, mock_signal): + 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.signal_cancel) + + action.load.assert_not_called() + action.set_status.assert_not_called() + mock_signal.aseert_not_called() + def test_execute_default(self): action = ab.Action.__new__(DummyAction, OBJID, 'BOOM', self.ctx) self.assertRaises(NotImplementedError, @@ -1019,6 +1093,8 @@ class ActionProcTest(base.SenlinTestCase): def test_action_proc_successful(self, mock_mark, mock_load, mock_event_info): action = ab.Action(OBJID, 'OBJECT_ACTION', self.ctx) + action.is_cancelled = mock.Mock() + action.is_cancelled.return_value = False mock_obj = mock.Mock() action.entity = mock_obj self.patchobject(action, 'execute', @@ -1039,6 +1115,8 @@ class ActionProcTest(base.SenlinTestCase): @mock.patch.object(ao.Action, 'mark_failed') def test_action_proc_failed_error(self, mock_mark, mock_load, mock_info): action = ab.Action(OBJID, 'CLUSTER_ACTION', self.ctx, id=ACTION_ID) + action.is_cancelled = mock.Mock() + action.is_cancelled.return_value = False action.entity = mock.Mock(id=CLUSTER_ID, name='fake-cluster') self.patchobject(action, 'execute', side_effect=Exception('Boom!')) @@ -1052,3 +1130,26 @@ class ActionProcTest(base.SenlinTestCase): project_safe=False) mock_info.assert_called_once_with(action, 'start', 'ACTION') mock_status.assert_called_once_with(action.RES_ERROR, 'Boom!') + + @mock.patch.object(EVENT, 'info') + @mock.patch.object(ab.Action, 'load') + @mock.patch.object(ao.Action, 'mark_failed') + def test_action_proc_is_cancelled(self, mock_mark, mock_load, mock_info): + action = ab.Action(OBJID, 'CLUSTER_ACTION', self.ctx, id=ACTION_ID) + action.is_cancelled = mock.Mock() + action.is_cancelled.return_value = True + action.entity = mock.Mock(id=CLUSTER_ID, name='fake-cluster') + + mock_status = self.patchobject(action, 'set_status') + mock_load.return_value = action + + res = ab.ActionProc(self.ctx, 'ACTION') + self.assertIs(True, res) + + mock_load.assert_called_once_with(self.ctx, action_id='ACTION', + project_safe=False) + + mock_info.assert_not_called() + mock_status.assert_called_once_with( + action.RES_CANCEL, + 'CLUSTER_ACTION [%s] cancelled' % ACTION_ID[:8]) diff --git a/senlin/tests/unit/engine/service/test_actions.py b/senlin/tests/unit/engine/service/test_actions.py index d01fa9a8d..c5b230941 100644 --- a/senlin/tests/unit/engine/service/test_actions.py +++ b/senlin/tests/unit/engine/service/test_actions.py @@ -205,3 +205,37 @@ class ActionTest(base.SenlinTestCase): self.ctx, req.obj_to_primitive()) self.assertEqual(exc.ResourceNotFound, ex.exc_info[0]) + + @mock.patch.object(ab.Action, 'load') + def test_action_update(self, mock_load): + x_obj = mock.Mock() + x_obj.id = 'FAKE_ID' + x_obj.signal_cancel = mock.Mock() + x_obj.SIG_CANCEL = 'CANCEL' + mock_load.return_value = x_obj + + req = orao.ActionUpdateRequest(identity='ACTION_ID', + status='CANCELLED') + + result = self.eng.action_update(self.ctx, req.obj_to_primitive()) + self.assertIsNone(result) + + mock_load.assert_called_with(self.ctx, 'ACTION_ID', project_safe=False) + x_obj.signal_cancel.assert_called_once_with() + + @mock.patch.object(ab.Action, 'load') + def test_action_update_unknown_action(self, mock_load): + x_obj = mock.Mock() + x_obj.id = 'FAKE_ID' + x_obj.signal_cancel = mock.Mock() + mock_load.return_value = x_obj + + req = orao.ActionUpdateRequest(identity='ACTION_ID', + status='FOO') + ex = self.assertRaises(rpc.ExpectedException, self.eng.action_update, + self.ctx, req.obj_to_primitive()) + + self.assertEqual(exc.BadRequest, ex.exc_info[0]) + + mock_load.assert_not_called() + x_obj.signal_cancel.assert_not_called() diff --git a/senlin/tests/unit/objects/requests/test_actions.py b/senlin/tests/unit/objects/requests/test_actions.py index 005b43721..5b90d463e 100644 --- a/senlin/tests/unit/objects/requests/test_actions.py +++ b/senlin/tests/unit/objects/requests/test_actions.py @@ -140,3 +140,16 @@ class TestActionDelete(test_base.SenlinTestCase): def test_action_get_request(self): sot = actions.ActionDeleteRequest(**self.body) self.assertEqual('test-action', sot.identity) + + +class TestActionUpdate(test_base.SenlinTestCase): + + body = { + 'identity': 'test-action', + 'status': 'CANCELLED' + } + + def test_action_update_request(self): + sot = actions.ActionUpdateRequest(**self.body) + self.assertEqual('test-action', sot.identity) + self.assertEqual('CANCELLED', sot.status)