Merge "Add action_update api to cancel actions"

This commit is contained in:
Zuul 2018-12-11 00:04:10 +00:00 committed by Gerrit Code Review
commit 94c448276f
20 changed files with 451 additions and 49 deletions

View File

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

View File

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

View File

@ -0,0 +1,5 @@
{
"action": {
"status": "CANCELLED",
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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