Add action_update api to cancel actions
This patch implements the action_update API to allow the status of an action to be updated. For now we are only accepting 'CANCELLED' as a valid status value. Implements: blueprint action-update Change-Id: I76402401cb67bc9a2aa1df43ec0f28fc446f04bd
This commit is contained in:
parent
b30b2b8496
commit
ffbfa8042e
|
@ -142,3 +142,46 @@ Response Example
|
||||||
|
|
||||||
.. literalinclude:: samples/action-get-response.json
|
.. literalinclude:: samples/action-get-response.json
|
||||||
:language: javascript
|
: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
|
||||||
|
|
|
@ -357,6 +357,14 @@ action_status:
|
||||||
description: |
|
description: |
|
||||||
A string representation of the current status of the action.
|
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:
|
action_target:
|
||||||
type: string
|
type: string
|
||||||
in: body
|
in: body
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"action": {
|
||||||
|
"status": "CANCELLED",
|
||||||
|
}
|
||||||
|
}
|
|
@ -45,6 +45,7 @@ class FaultWrapper(wsgi.Middleware):
|
||||||
'ActionConflict': webob.exc.HTTPConflict,
|
'ActionConflict': webob.exc.HTTPConflict,
|
||||||
'ActionCooldown': webob.exc.HTTPConflict,
|
'ActionCooldown': webob.exc.HTTPConflict,
|
||||||
'ActionInProgress': webob.exc.HTTPConflict,
|
'ActionInProgress': webob.exc.HTTPConflict,
|
||||||
|
'ActionImmutable': webob.exc.HTTPConflict,
|
||||||
'BadRequest': webob.exc.HTTPBadRequest,
|
'BadRequest': webob.exc.HTTPBadRequest,
|
||||||
'FeatureNotSupported': webob.exc.HTTPConflict,
|
'FeatureNotSupported': webob.exc.HTTPConflict,
|
||||||
'Forbidden': webob.exc.HTTPForbidden,
|
'Forbidden': webob.exc.HTTPForbidden,
|
||||||
|
|
|
@ -117,3 +117,8 @@ it can be used by both users and developers.
|
||||||
response code 409 when a scaling action conflicts with one already
|
response code 409 when a scaling action conflicts with one already
|
||||||
being processed or a cooldown for a scaling action is encountered.
|
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.
|
||||||
|
|
|
@ -99,3 +99,17 @@ class ActionController(wsgi.Controller):
|
||||||
action = self.rpc_client.call(req.context, 'action_get', obj)
|
action = self.rpc_client.call(req.context, 'action_get', obj)
|
||||||
|
|
||||||
return {'action': action}
|
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
|
||||||
|
|
|
@ -255,6 +255,10 @@ class API(wsgi.Router):
|
||||||
"/actions/{action_id}",
|
"/actions/{action_id}",
|
||||||
action="get",
|
action="get",
|
||||||
conditions={'method': 'GET'})
|
conditions={'method': 'GET'})
|
||||||
|
sub_mapper.connect("action_update",
|
||||||
|
"/actions/{action_id}",
|
||||||
|
action="update",
|
||||||
|
conditions={'method': 'PATCH'})
|
||||||
|
|
||||||
# Receivers
|
# Receivers
|
||||||
res = wsgi.Resource(receivers.ReceiverController(conf))
|
res = wsgi.Resource(receivers.ReceiverController(conf))
|
||||||
|
|
|
@ -24,7 +24,7 @@ class VersionController(object):
|
||||||
# This includes any semantic changes which may not affect the input or
|
# This includes any semantic changes which may not affect the input or
|
||||||
# output formats or even originate in the API code layer.
|
# output formats or even originate in the API code layer.
|
||||||
_MIN_API_VERSION = "1.0"
|
_MIN_API_VERSION = "1.0"
|
||||||
_MAX_API_VERSION = "1.11"
|
_MAX_API_VERSION = "1.12"
|
||||||
|
|
||||||
DEFAULT_API_VERSION = _MIN_API_VERSION
|
DEFAULT_API_VERSION = _MIN_API_VERSION
|
||||||
|
|
||||||
|
|
|
@ -200,6 +200,11 @@ class ActionCooldown(SenlinException):
|
||||||
"progress")
|
"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):
|
class NodeNotOrphan(SenlinException):
|
||||||
msg_fmt = _("%(message)s")
|
msg_fmt = _("%(message)s")
|
||||||
|
|
||||||
|
|
|
@ -39,6 +39,17 @@ rules = [
|
||||||
'method': 'GET'
|
'method': 'GET'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
),
|
||||||
|
policy.DocumentedRuleDefault(
|
||||||
|
name="actions:update",
|
||||||
|
check_str=base.UNPROTECTED,
|
||||||
|
description="Update action",
|
||||||
|
operations=[
|
||||||
|
{
|
||||||
|
'path': '/v1/actions/{action_id}',
|
||||||
|
'method': 'PATCH'
|
||||||
|
}
|
||||||
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -1272,7 +1272,7 @@ def _mark_cancelled(session, action_id, timestamp, reason=None):
|
||||||
'owner': None,
|
'owner': None,
|
||||||
'status': consts.ACTION_CANCELLED,
|
'status': consts.ACTION_CANCELLED,
|
||||||
'status_reason': (six.text_type(reason) if reason else
|
'status_reason': (six.text_type(reason) if reason else
|
||||||
'Action execution failed'),
|
'Action execution cancelled'),
|
||||||
'end_time': timestamp,
|
'end_time': timestamp,
|
||||||
}
|
}
|
||||||
query.update(values, synchronize_session=False)
|
query.update(values, synchronize_session=False)
|
||||||
|
|
|
@ -271,8 +271,8 @@ class Action(object):
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _check_action_lock(target, action):
|
def _check_action_lock(target, action):
|
||||||
if action == consts.CLUSTER_DELETE:
|
if action == consts.CLUSTER_DELETE or action == consts.NODE_DELETE:
|
||||||
# CLUSTER_DELETE actions do not care about cluster locks
|
# DELETE actions do not care about locks
|
||||||
return
|
return
|
||||||
elif (action in list(consts.CLUSTER_ACTION_NAMES) and
|
elif (action in list(consts.CLUSTER_ACTION_NAMES) and
|
||||||
cl.ClusterLock.is_locked(target)):
|
cl.ClusterLock.is_locked(target)):
|
||||||
|
@ -286,14 +286,11 @@ class Action(object):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _check_conflicting_actions(ctx, target, action):
|
def _check_conflicting_actions(ctx, target, action):
|
||||||
conflict_actions = ao.Action.get_all_active_by_target(ctx, target)
|
conflict_actions = ao.Action.get_all_active_by_target(ctx, target)
|
||||||
if conflict_actions and action == consts.CLUSTER_DELETE:
|
# Ignore conflicting actions on deletes.
|
||||||
delete_ids = [a['id'] for a in conflict_actions
|
if not conflict_actions or action in (consts.CLUSTER_DELETE,
|
||||||
if a['action'] == consts.CLUSTER_DELETE]
|
consts.NODE_DELETE):
|
||||||
if delete_ids:
|
return
|
||||||
raise exception.ActionConflict(
|
else:
|
||||||
type=action, target=target, actions=",".join(
|
|
||||||
delete_ids))
|
|
||||||
elif conflict_actions:
|
|
||||||
action_ids = [a['id'] for a in conflict_actions]
|
action_ids = [a['id'] for a in conflict_actions]
|
||||||
raise exception.ActionConflict(
|
raise exception.ActionConflict(
|
||||||
type=action, target=target, actions=",".join(action_ids))
|
type=action, target=target, actions=",".join(action_ids))
|
||||||
|
@ -318,21 +315,59 @@ class Action(object):
|
||||||
return
|
return
|
||||||
|
|
||||||
if cmd == self.SIG_CANCEL:
|
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:
|
elif cmd == self.SIG_SUSPEND:
|
||||||
expected = (self.RUNNING)
|
expected = (self.RUNNING)
|
||||||
else: # SIG_RESUME
|
else: # SIG_RESUME
|
||||||
expected = (self.SUSPENDED)
|
expected = (self.SUSPENDED)
|
||||||
|
|
||||||
if self.status not in expected:
|
if self.status not in expected:
|
||||||
LOG.error("Action (%(id)s) is in status (%(actual)s) while "
|
LOG.info("Action (%(id)s) is in status (%(actual)s) while "
|
||||||
"expected status must be one of (%(expected)s).",
|
"expected status must be one of (%(expected)s).",
|
||||||
dict(id=self.id[:8], expected=expected,
|
dict(id=self.id[:8], expected=expected,
|
||||||
actual=self.status))
|
actual=self.status))
|
||||||
return
|
return
|
||||||
|
|
||||||
ao.Action.signal(self.context, self.id, cmd)
|
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):
|
def execute(self, **kwargs):
|
||||||
"""Execute the action.
|
"""Execute the action.
|
||||||
|
|
||||||
|
@ -409,6 +444,8 @@ class Action(object):
|
||||||
def is_timeout(self, timeout=None):
|
def is_timeout(self, timeout=None):
|
||||||
if timeout is None:
|
if timeout is None:
|
||||||
timeout = self.timeout
|
timeout = self.timeout
|
||||||
|
if self.start_time is None:
|
||||||
|
return False
|
||||||
time_elapse = wallclock() - self.start_time
|
time_elapse = wallclock() - self.start_time
|
||||||
return time_elapse > timeout
|
return time_elapse > timeout
|
||||||
|
|
||||||
|
@ -593,6 +630,13 @@ def ActionProc(ctx, action_id):
|
||||||
LOG.error('Action "%s" could not be found.', action_id)
|
LOG.error('Action "%s" could not be found.', action_id)
|
||||||
return False
|
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])
|
EVENT.info(action, consts.PHASE_START, action_id[:8])
|
||||||
|
|
||||||
reason = 'Action completed'
|
reason = 'Action completed'
|
||||||
|
|
|
@ -68,23 +68,33 @@ class ClusterAction(base.Action):
|
||||||
status = self.get_status()
|
status = self.get_status()
|
||||||
while status != self.READY:
|
while status != self.READY:
|
||||||
if status == self.FAILED:
|
if status == self.FAILED:
|
||||||
reason = ('%(action)s [%(id)s] failed') % {
|
reason = ('%(action)s [%(id)s] failed' % {
|
||||||
'action': self.action, 'id': self.id[:8]}
|
'action': self.action, 'id': self.id[:8]})
|
||||||
LOG.debug(reason)
|
LOG.debug(reason)
|
||||||
return self.RES_ERROR, reason
|
return self.RES_ERROR, reason
|
||||||
|
|
||||||
if self.is_cancelled():
|
if self.is_cancelled():
|
||||||
# During this period, if cancel request comes, cancel this
|
# During this period, if cancel request comes, cancel this
|
||||||
# operation immediately, then release the cluster lock
|
# operation immediately after signaling children to cancel,
|
||||||
reason = ('%(action)s [%(id)s] cancelled') % {
|
# then release the cluster lock
|
||||||
'action': self.action, 'id': self.id[:8]}
|
reason = ('%(action)s [%(id)s] cancelled' % {
|
||||||
|
'action': self.action, 'id': self.id[:8]})
|
||||||
LOG.debug(reason)
|
LOG.debug(reason)
|
||||||
return self.RES_CANCEL, 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():
|
if self.is_timeout():
|
||||||
# Action timeout, return
|
# Action timeout, return
|
||||||
reason = ('%(action)s [%(id)s] timeout') % {
|
reason = ('%(action)s [%(id)s] timeout' % {
|
||||||
'action': self.action, 'id': self.id[:8]}
|
'action': self.action, 'id': self.id[:8]})
|
||||||
LOG.debug(reason)
|
LOG.debug(reason)
|
||||||
return self.RES_TIMEOUT, reason
|
return self.RES_TIMEOUT, reason
|
||||||
|
|
||||||
|
@ -100,9 +110,23 @@ class ClusterAction(base.Action):
|
||||||
# Continue waiting (with reschedule)
|
# Continue waiting (with reschedule)
|
||||||
scheduler.reschedule(self.id, 3)
|
scheduler.reschedule(self.id, 3)
|
||||||
status = self.get_status()
|
status = self.get_status()
|
||||||
|
dispatcher.start_action()
|
||||||
|
|
||||||
return self.RES_OK, 'All dependents ended with success'
|
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):
|
def _create_nodes(self, count):
|
||||||
"""Utility method for node creation.
|
"""Utility method for node creation.
|
||||||
|
|
||||||
|
|
|
@ -1696,7 +1696,7 @@ class EngineService(service.Service):
|
||||||
|
|
||||||
@request_context
|
@request_context
|
||||||
def node_update(self, ctx, req):
|
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 ctx: An instance of the request context.
|
||||||
:param req: An instance of the NodeUpdateRequest object.
|
:param req: An instance of the NodeUpdateRequest object.
|
||||||
|
@ -2302,6 +2302,26 @@ class EngineService(service.Service):
|
||||||
|
|
||||||
LOG.info("Action '%s' is deleted.", req.identity)
|
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
|
@request_context
|
||||||
def receiver_list(self, ctx, req):
|
def receiver_list(self, ctx, req):
|
||||||
"""List receivers matching the specified criteria.
|
"""List receivers matching the specified criteria.
|
||||||
|
|
|
@ -17,6 +17,7 @@ from senlin.objects import fields
|
||||||
|
|
||||||
@base.SenlinObjectRegistry.register
|
@base.SenlinObjectRegistry.register
|
||||||
class ActionCreateRequestBody(base.SenlinObject):
|
class ActionCreateRequestBody(base.SenlinObject):
|
||||||
|
|
||||||
fields = {
|
fields = {
|
||||||
'name': fields.NameField(),
|
'name': fields.NameField(),
|
||||||
'cluster_id': fields.StringField(),
|
'cluster_id': fields.StringField(),
|
||||||
|
@ -67,3 +68,12 @@ class ActionDeleteRequest(base.SenlinObject):
|
||||||
fields = {
|
fields = {
|
||||||
'identity': fields.StringField()
|
'identity': fields.StringField()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@base.SenlinObjectRegistry.register
|
||||||
|
class ActionUpdateRequest(base.SenlinObject):
|
||||||
|
|
||||||
|
fields = {
|
||||||
|
'identity': fields.StringField(),
|
||||||
|
'status': fields.StringField()
|
||||||
|
}
|
||||||
|
|
|
@ -14,6 +14,8 @@ import mock
|
||||||
import six
|
import six
|
||||||
from webob import exc
|
from webob import exc
|
||||||
|
|
||||||
|
from oslo_serialization import jsonutils
|
||||||
|
|
||||||
from senlin.api.common import util
|
from senlin.api.common import util
|
||||||
from senlin.api.middleware import fault
|
from senlin.api.middleware import fault
|
||||||
from senlin.api.openstack.v1 import actions
|
from senlin.api.openstack.v1 import actions
|
||||||
|
@ -309,3 +311,51 @@ class ActionControllerTest(shared.ControllerTest, base.SenlinTestCase):
|
||||||
|
|
||||||
self.assertEqual(403, resp.status_int)
|
self.assertEqual(403, resp.status_int)
|
||||||
self.assertIn('403 Forbidden', six.text_type(resp))
|
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)
|
||||||
|
|
|
@ -375,6 +375,16 @@ class RoutesTest(base.SenlinTestCase):
|
||||||
'action_id': 'bbbb'
|
'action_id': 'bbbb'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
self.assertRoute(
|
||||||
|
self.m,
|
||||||
|
'/actions/bbbb',
|
||||||
|
'PATCH',
|
||||||
|
'update',
|
||||||
|
'ActionController',
|
||||||
|
{
|
||||||
|
'action_id': 'bbbb'
|
||||||
|
})
|
||||||
|
|
||||||
def test_receiver_collection(self):
|
def test_receiver_collection(self):
|
||||||
self.assertRoute(
|
self.assertRoute(
|
||||||
self.m,
|
self.m,
|
||||||
|
|
|
@ -44,6 +44,8 @@ OWNER_ID = 'c7114713-ee68-409d-ba5d-0560a72a386c'
|
||||||
ACTION_ID = '4c2cead2-fd74-418a-9d12-bd2d9bd7a812'
|
ACTION_ID = '4c2cead2-fd74-418a-9d12-bd2d9bd7a812'
|
||||||
USER_ID = '3c4d64baadcd437d8dd49054899e73dd'
|
USER_ID = '3c4d64baadcd437d8dd49054899e73dd'
|
||||||
PROJECT_ID = 'cf7a6ae28dde4f46aa8fe55d318a608f'
|
PROJECT_ID = 'cf7a6ae28dde4f46aa8fe55d318a608f'
|
||||||
|
CHILD_IDS = ['8500ae8f-e632-4e8b-8206-552873cc2c3a',
|
||||||
|
'67c0eba9-514f-4659-9deb-99868873dfd6']
|
||||||
|
|
||||||
|
|
||||||
class DummyAction(ab.Action):
|
class DummyAction(ab.Action):
|
||||||
|
@ -358,30 +360,6 @@ class ActionBaseTest(base.SenlinTestCase):
|
||||||
mock_store.assert_not_called()
|
mock_store.assert_not_called()
|
||||||
mock_active.assert_called_once_with(mock.ANY, OBJID)
|
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(ab.Action, 'store')
|
||||||
@mock.patch.object(ao.Action, 'get_all_active_by_target')
|
@mock.patch.object(ao.Action, 'get_all_active_by_target')
|
||||||
@mock.patch.object(cl.ClusterLock, 'is_locked')
|
@mock.patch.object(cl.ClusterLock, 'is_locked')
|
||||||
|
@ -532,6 +510,102 @@ class ActionBaseTest(base.SenlinTestCase):
|
||||||
self.assertEqual(0, mock_call.call_count)
|
self.assertEqual(0, mock_call.call_count)
|
||||||
mock_call.reset_mock()
|
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):
|
def test_execute_default(self):
|
||||||
action = ab.Action.__new__(DummyAction, OBJID, 'BOOM', self.ctx)
|
action = ab.Action.__new__(DummyAction, OBJID, 'BOOM', self.ctx)
|
||||||
self.assertRaises(NotImplementedError,
|
self.assertRaises(NotImplementedError,
|
||||||
|
@ -1019,6 +1093,8 @@ class ActionProcTest(base.SenlinTestCase):
|
||||||
def test_action_proc_successful(self, mock_mark, mock_load,
|
def test_action_proc_successful(self, mock_mark, mock_load,
|
||||||
mock_event_info):
|
mock_event_info):
|
||||||
action = ab.Action(OBJID, 'OBJECT_ACTION', self.ctx)
|
action = ab.Action(OBJID, 'OBJECT_ACTION', self.ctx)
|
||||||
|
action.is_cancelled = mock.Mock()
|
||||||
|
action.is_cancelled.return_value = False
|
||||||
mock_obj = mock.Mock()
|
mock_obj = mock.Mock()
|
||||||
action.entity = mock_obj
|
action.entity = mock_obj
|
||||||
self.patchobject(action, 'execute',
|
self.patchobject(action, 'execute',
|
||||||
|
@ -1039,6 +1115,8 @@ class ActionProcTest(base.SenlinTestCase):
|
||||||
@mock.patch.object(ao.Action, 'mark_failed')
|
@mock.patch.object(ao.Action, 'mark_failed')
|
||||||
def test_action_proc_failed_error(self, mock_mark, mock_load, mock_info):
|
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 = 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')
|
action.entity = mock.Mock(id=CLUSTER_ID, name='fake-cluster')
|
||||||
|
|
||||||
self.patchobject(action, 'execute', side_effect=Exception('Boom!'))
|
self.patchobject(action, 'execute', side_effect=Exception('Boom!'))
|
||||||
|
@ -1052,3 +1130,26 @@ class ActionProcTest(base.SenlinTestCase):
|
||||||
project_safe=False)
|
project_safe=False)
|
||||||
mock_info.assert_called_once_with(action, 'start', 'ACTION')
|
mock_info.assert_called_once_with(action, 'start', 'ACTION')
|
||||||
mock_status.assert_called_once_with(action.RES_ERROR, 'Boom!')
|
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])
|
||||||
|
|
|
@ -205,3 +205,37 @@ class ActionTest(base.SenlinTestCase):
|
||||||
self.ctx, req.obj_to_primitive())
|
self.ctx, req.obj_to_primitive())
|
||||||
|
|
||||||
self.assertEqual(exc.ResourceNotFound, ex.exc_info[0])
|
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()
|
||||||
|
|
|
@ -140,3 +140,16 @@ class TestActionDelete(test_base.SenlinTestCase):
|
||||||
def test_action_get_request(self):
|
def test_action_get_request(self):
|
||||||
sot = actions.ActionDeleteRequest(**self.body)
|
sot = actions.ActionDeleteRequest(**self.body)
|
||||||
self.assertEqual('test-action', sot.identity)
|
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)
|
||||||
|
|
Loading…
Reference in New Issue