Action plan state transition - payload validation
This patchset fixes the lack of field validation that are provided by an API user. Via a PATCH on /action_plans, the only field that can be modified is now the 'state'. This field can only perform to the following state transitions: - RECOMMENDED --> TRIGGERED - RECOMMENDED --> CANCELLED - ONGOING --> CANCELLED - TRIGGERED --> CANCELLED The DELETED state can only be set using a DELETE request. Closes-Bug: #1531106 Change-Id: I6669cbe63407f0bbb792fb2e2ce6b1e8a7365238
This commit is contained in:
parent
2db5ae31c7
commit
83fdbf7366
|
@ -77,21 +77,45 @@ import wsme
|
|||
from wsme import types as wtypes
|
||||
import wsmeext.pecan as wsme_pecan
|
||||
|
||||
from watcher._i18n import _
|
||||
from watcher.api.controllers import base
|
||||
from watcher.api.controllers import link
|
||||
from watcher.api.controllers.v1 import collection
|
||||
from watcher.api.controllers.v1 import types
|
||||
from watcher.api.controllers.v1 import utils as api_utils
|
||||
from watcher.applier.rpcapi import ApplierAPI
|
||||
from watcher.applier import rpcapi
|
||||
from watcher.common import exception
|
||||
from watcher import objects
|
||||
from watcher.objects import action_plan as ap_objects
|
||||
|
||||
|
||||
class ActionPlanPatchType(types.JsonPatchType):
|
||||
|
||||
@staticmethod
|
||||
def _validate_state(patch):
|
||||
serialized_patch = {'path': patch.path, 'op': patch.op}
|
||||
if patch.value is not wsme.Unset:
|
||||
serialized_patch['value'] = patch.value
|
||||
# todo: use state machines to handle state transitions
|
||||
state_value = patch.value
|
||||
if state_value and not hasattr(ap_objects.State, state_value):
|
||||
msg = _("Invalid state: %(state)s")
|
||||
raise exception.PatchError(
|
||||
patch=serialized_patch, reason=msg % dict(state=state_value))
|
||||
|
||||
@staticmethod
|
||||
def validate(patch):
|
||||
if patch.path == "/state":
|
||||
ActionPlanPatchType._validate_state(patch)
|
||||
return types.JsonPatchType.validate(patch)
|
||||
|
||||
@staticmethod
|
||||
def internal_attrs():
|
||||
return types.JsonPatchType.internal_attrs()
|
||||
|
||||
@staticmethod
|
||||
def mandatory_attrs():
|
||||
return []
|
||||
return ["audit_id", "state", "first_action_id"]
|
||||
|
||||
|
||||
class ActionPlan(base.APIBase):
|
||||
|
@ -374,6 +398,34 @@ class ActionPlansController(rest.RestController):
|
|||
raise exception.PatchError(patch=patch, reason=e)
|
||||
|
||||
launch_action_plan = False
|
||||
|
||||
# transitions that are allowed via PATCH
|
||||
allowed_patch_transitions = [
|
||||
(ap_objects.State.RECOMMENDED,
|
||||
ap_objects.State.TRIGGERED),
|
||||
(ap_objects.State.RECOMMENDED,
|
||||
ap_objects.State.CANCELLED),
|
||||
(ap_objects.State.ONGOING,
|
||||
ap_objects.State.CANCELLED),
|
||||
(ap_objects.State.TRIGGERED,
|
||||
ap_objects.State.CANCELLED),
|
||||
]
|
||||
|
||||
# todo: improve this in blueprint watcher-api-validation
|
||||
if hasattr(action_plan, 'state'):
|
||||
transition = (action_plan_to_update.state, action_plan.state)
|
||||
if transition not in allowed_patch_transitions:
|
||||
error_message = _("State transition not allowed: "
|
||||
"(%(initial_state)s -> %(new_state)s)")
|
||||
raise exception.PatchError(
|
||||
patch=patch,
|
||||
reason=error_message % dict(
|
||||
initial_state=action_plan_to_update.state,
|
||||
new_state=action_plan.state))
|
||||
|
||||
if action_plan.state == ap_objects.State.TRIGGERED:
|
||||
launch_action_plan = True
|
||||
|
||||
# Update only the fields that have changed
|
||||
for field in objects.ActionPlan.fields:
|
||||
try:
|
||||
|
@ -393,7 +445,7 @@ class ActionPlansController(rest.RestController):
|
|||
action_plan_to_update.save()
|
||||
|
||||
if launch_action_plan:
|
||||
applier_client = ApplierAPI()
|
||||
applier_client = rpcapi.ApplierAPI()
|
||||
applier_client.launch_action_plan(pecan.request.context,
|
||||
action_plan.uuid)
|
||||
|
||||
|
|
|
@ -19,6 +19,16 @@ msgstr ""
|
|||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.1.1\n"
|
||||
|
||||
#: watcher/api/controllers/v1/action_plan.py:102
|
||||
#, python-format
|
||||
msgid "Invalid state: %(state)s"
|
||||
msgstr "État invalide : %(state)s"
|
||||
|
||||
#: watcher/api/controllers/v1/action_plan.py:418
|
||||
#, python-format
|
||||
msgid "State transition not allowed: (%(initial_state)s -> %(new_state)s)"
|
||||
msgstr "Transition d'état non autorisée : (%(initial_state)s -> %(new_state)s)"
|
||||
|
||||
#: watcher/api/controllers/v1/types.py:148
|
||||
#, python-format
|
||||
msgid "%s is not JSON serializable"
|
||||
|
@ -363,7 +373,7 @@ msgstr ""
|
|||
msgid "'obj' argument type is not valid"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/decision_engine/planner/default.py:76
|
||||
#: watcher/decision_engine/planner/default.py:75
|
||||
msgid "The action plan is empty"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
@ -7,9 +7,9 @@
|
|||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: python-watcher 0.22.1.dev28\n"
|
||||
"Project-Id-Version: python-watcher 0.22.1.dev49\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2016-01-19 17:54+0100\n"
|
||||
"POT-Creation-Date: 2016-01-22 10:43+0100\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
|
@ -18,6 +18,20 @@ msgstr ""
|
|||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.1.1\n"
|
||||
|
||||
#: watcher/api/controllers/v1/action_plan.py:102
|
||||
#, python-format
|
||||
msgid "Invalid state: %(state)s"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/api/controllers/v1/action_plan.py:416
|
||||
#, python-format
|
||||
msgid "State transition not allowed: (%(initial_state)s -> %(new_state)s)"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/api/controllers/v1/audit.py:359
|
||||
msgid "The audit template UUID or name specified is invalid"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/api/controllers/v1/types.py:148
|
||||
#, python-format
|
||||
msgid "%s is not JSON serializable"
|
||||
|
@ -65,7 +79,7 @@ msgstr ""
|
|||
msgid "ErrorDocumentMiddleware received an invalid status %s"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/api/middleware/parsable_error.py:80
|
||||
#: watcher/api/middleware/parsable_error.py:79
|
||||
#, python-format
|
||||
msgid "Error parsing HTTP response: %s"
|
||||
msgstr ""
|
||||
|
@ -74,17 +88,17 @@ msgstr ""
|
|||
msgid "The target state is not defined"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/applier/workflow_engine/default.py:69
|
||||
#: watcher/applier/workflow_engine/default.py:126
|
||||
#, python-format
|
||||
msgid "The WorkFlow Engine has failed to execute the action %s"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/applier/workflow_engine/default.py:77
|
||||
#: watcher/applier/workflow_engine/default.py:144
|
||||
#, python-format
|
||||
msgid "Revert action %s"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/applier/workflow_engine/default.py:83
|
||||
#: watcher/applier/workflow_engine/default.py:150
|
||||
msgid "Oops! We need disaster recover plan"
|
||||
msgstr ""
|
||||
|
||||
|
@ -104,184 +118,176 @@ msgstr ""
|
|||
msgid "serving on http://%(host)s:%(port)s"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:56
|
||||
#: watcher/common/exception.py:51
|
||||
msgid "An unknown exception occurred"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:77
|
||||
#: watcher/common/exception.py:71
|
||||
msgid "Exception in string format operation"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:107
|
||||
#: watcher/common/exception.py:101
|
||||
msgid "Not authorized"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:112
|
||||
#: watcher/common/exception.py:106
|
||||
msgid "Operation not permitted"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:116
|
||||
#: watcher/common/exception.py:110
|
||||
msgid "Unacceptable parameters"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:121
|
||||
#: watcher/common/exception.py:115
|
||||
#, python-format
|
||||
msgid "The %(name)s %(id)s could not be found"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:125
|
||||
#: watcher/common/exception.py:119
|
||||
msgid "Conflict"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:130
|
||||
#: watcher/common/exception.py:124
|
||||
#, python-format
|
||||
msgid "The %(name)s resource %(id)s could not be found"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:135
|
||||
#: watcher/common/exception.py:129
|
||||
#, python-format
|
||||
msgid "Expected an uuid or int but received %(identity)s"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:139
|
||||
#: watcher/common/exception.py:133
|
||||
#, python-format
|
||||
msgid "Goal %(goal)s is not defined in Watcher configuration file"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:145
|
||||
#: watcher/common/exception.py:139
|
||||
#, python-format
|
||||
msgid "%(err)s"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:149
|
||||
#: watcher/common/exception.py:143
|
||||
#, python-format
|
||||
msgid "Expected a uuid but received %(uuid)s"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:153
|
||||
#: watcher/common/exception.py:147
|
||||
#, python-format
|
||||
msgid "Expected a logical name but received %(name)s"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:157
|
||||
#: watcher/common/exception.py:151
|
||||
#, python-format
|
||||
msgid "Expected a logical name or uuid but received %(name)s"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:161
|
||||
#: watcher/common/exception.py:155
|
||||
#, python-format
|
||||
msgid "AuditTemplate %(audit_template)s could not be found"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:165
|
||||
#: watcher/common/exception.py:159
|
||||
#, python-format
|
||||
msgid "An audit_template with UUID %(uuid)s or name %(name)s already exists"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:170
|
||||
#: watcher/common/exception.py:164
|
||||
#, python-format
|
||||
msgid "AuditTemplate %(audit_template)s is referenced by one or multiple audit"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:175
|
||||
#: watcher/common/exception.py:169
|
||||
#, python-format
|
||||
msgid "Audit %(audit)s could not be found"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:179
|
||||
#: watcher/common/exception.py:173
|
||||
#, python-format
|
||||
msgid "An audit with UUID %(uuid)s already exists"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:183
|
||||
#: watcher/common/exception.py:177
|
||||
#, python-format
|
||||
msgid "Audit %(audit)s is referenced by one or multiple action plans"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:188
|
||||
#: watcher/common/exception.py:182
|
||||
msgid "ActionPlan %(action plan)s could not be found"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:192
|
||||
#: watcher/common/exception.py:186
|
||||
#, python-format
|
||||
msgid "An action plan with UUID %(uuid)s already exists"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:196
|
||||
#: watcher/common/exception.py:190
|
||||
#, python-format
|
||||
msgid "Action Plan %(action_plan)s is referenced by one or multiple actions"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:201
|
||||
#: watcher/common/exception.py:195
|
||||
#, python-format
|
||||
msgid "Action %(action)s could not be found"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:205
|
||||
#: watcher/common/exception.py:199
|
||||
#, python-format
|
||||
msgid "An action with UUID %(uuid)s already exists"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:209
|
||||
#: watcher/common/exception.py:203
|
||||
#, python-format
|
||||
msgid "Action plan %(action_plan)s is referenced by one or multiple goals"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:214
|
||||
#: watcher/common/exception.py:208
|
||||
msgid "Filtering actions on both audit and action-plan is prohibited"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:223
|
||||
#: watcher/common/exception.py:217
|
||||
#, python-format
|
||||
msgid "Couldn't apply patch '%(patch)s'. Reason: %(reason)s"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:233
|
||||
msgid "Description must be an instance of str"
|
||||
#: watcher/common/exception.py:224
|
||||
msgid "Illegal argument"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:243
|
||||
msgid "An exception occurred without a description"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:251
|
||||
msgid "Description cannot be empty"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:260
|
||||
#: watcher/common/exception.py:228
|
||||
msgid "No such metric"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:269
|
||||
#: watcher/common/exception.py:232
|
||||
msgid "No rows were returned"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:277
|
||||
#: watcher/common/exception.py:236
|
||||
msgid "'Keystone API endpoint is missing''"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:281
|
||||
#: watcher/common/exception.py:240
|
||||
msgid "The list of hypervisor(s) in the cluster is empty"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:285
|
||||
#: watcher/common/exception.py:244
|
||||
msgid "The metrics resource collector is not defined"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:289
|
||||
#: watcher/common/exception.py:248
|
||||
msgid "the cluster state is not defined"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:295
|
||||
#: watcher/common/exception.py:254
|
||||
#, python-format
|
||||
msgid "The instance '%(name)s' is not found"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:299
|
||||
#: watcher/common/exception.py:258
|
||||
msgid "The hypervisor is not found"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/common/exception.py:303
|
||||
#: watcher/common/exception.py:262
|
||||
#, python-format
|
||||
msgid "Error loading plugin '%(name)s'"
|
||||
msgstr ""
|
||||
|
@ -320,7 +326,7 @@ msgstr ""
|
|||
#: watcher/common/utils.py:53
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Failed to remove trailing character. Returning original object. Supplied "
|
||||
"Failed to remove trailing character. Returning original object.Supplied "
|
||||
"object is not a string: %s,"
|
||||
msgstr ""
|
||||
|
||||
|
@ -376,11 +382,11 @@ msgstr ""
|
|||
msgid "No values returned by %(resource_id)s for %(metric_name)s"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/decision_engine/strategy/strategies/basic_consolidation.py:349
|
||||
#: watcher/decision_engine/strategy/strategies/basic_consolidation.py:424
|
||||
msgid "Initializing Sercon Consolidation"
|
||||
msgstr ""
|
||||
|
||||
#: watcher/decision_engine/strategy/strategies/basic_consolidation.py:406
|
||||
#: watcher/decision_engine/strategy/strategies/basic_consolidation.py:468
|
||||
msgid "The workloads of the compute nodes of the cluster is zero"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
@ -11,9 +11,11 @@
|
|||
# limitations under the License.
|
||||
|
||||
import datetime
|
||||
import itertools
|
||||
import mock
|
||||
|
||||
|
||||
from oslo_config import cfg
|
||||
from oslo_utils import timeutils
|
||||
from wsme import types as wtypes
|
||||
|
||||
from watcher.api.controllers.v1 import action_plan as api_action_plan
|
||||
|
@ -29,7 +31,7 @@ from watcher.tests.objects import utils as obj_utils
|
|||
|
||||
class TestActionPlanObject(base.TestCase):
|
||||
|
||||
def test_actionPlan_init(self):
|
||||
def test_action_plan_init(self):
|
||||
act_plan_dict = api_utils.action_plan_post_data()
|
||||
del act_plan_dict['state']
|
||||
del act_plan_dict['audit_id']
|
||||
|
@ -304,7 +306,7 @@ class TestDelete(api_base.FunctionalTest):
|
|||
self.assertEqual('application/json', response.content_type)
|
||||
self.assertTrue(response.json['error_message'])
|
||||
|
||||
def test_delete_ction_plan_not_found(self):
|
||||
def test_delete_action_plan_not_found(self):
|
||||
uuid = utils.generate_uuid()
|
||||
response = self.delete('/action_plans/%s' % uuid, expect_errors=True)
|
||||
self.assertEqual(404, response.status_int)
|
||||
|
@ -317,7 +319,7 @@ class TestPatch(api_base.FunctionalTest):
|
|||
def setUp(self):
|
||||
super(TestPatch, self).setUp()
|
||||
self.action_plan = obj_utils.create_action_plan_without_audit(
|
||||
self.context)
|
||||
self.context, state=objects.action_plan.State.RECOMMENDED)
|
||||
p = mock.patch.object(db_api.BaseConnection, 'update_action_plan')
|
||||
self.mock_action_plan_update = p.start()
|
||||
self.mock_action_plan_update.side_effect = \
|
||||
|
@ -329,52 +331,36 @@ class TestPatch(api_base.FunctionalTest):
|
|||
return action_plan
|
||||
|
||||
@mock.patch('oslo_utils.timeutils.utcnow')
|
||||
def test_replace_ok(self, mock_utcnow):
|
||||
def test_replace_denied(self, mock_utcnow):
|
||||
test_time = datetime.datetime(2000, 1, 1, 0, 0)
|
||||
mock_utcnow.return_value = test_time
|
||||
|
||||
new_state = 'CANCELLED'
|
||||
new_state = 'DELETED'
|
||||
response = self.get_json(
|
||||
'/action_plans/%s' % self.action_plan.uuid)
|
||||
self.assertNotEqual(new_state, response['state'])
|
||||
|
||||
response = self.patch_json(
|
||||
'/action_plans/%s' % self.action_plan.uuid,
|
||||
[{'path': '/state', 'value': new_state,
|
||||
'op': 'replace'}])
|
||||
[{'path': '/state', 'value': new_state, 'op': 'replace'}],
|
||||
expect_errors=True)
|
||||
|
||||
self.assertEqual('application/json', response.content_type)
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual(400, response.status_int)
|
||||
self.assertTrue(response.json['error_message'])
|
||||
|
||||
response = self.get_json(
|
||||
'/action_plans/%s' % self.action_plan.uuid)
|
||||
self.assertEqual(new_state, response['state'])
|
||||
return_updated_at = timeutils.parse_isotime(
|
||||
response['updated_at']).replace(tzinfo=None)
|
||||
self.assertEqual(test_time, return_updated_at)
|
||||
|
||||
def test_replace_non_existent_action_plan(self):
|
||||
def test_replace_non_existent_action_plan_denied(self):
|
||||
response = self.patch_json(
|
||||
'/action_plans/%s' % utils.generate_uuid(),
|
||||
[{'path': '/state', 'value': 'CANCELLED',
|
||||
'op': 'replace'}],
|
||||
[{'path': '/state',
|
||||
'value': objects.action_plan.State.TRIGGERED,
|
||||
'op': 'replace'}],
|
||||
expect_errors=True)
|
||||
self.assertEqual(404, response.status_int)
|
||||
self.assertEqual('application/json', response.content_type)
|
||||
self.assertTrue(response.json['error_message'])
|
||||
|
||||
def test_add_ok(self):
|
||||
new_state = 'CANCELLED'
|
||||
response = self.patch_json(
|
||||
'/action_plans/%s' % self.action_plan.uuid,
|
||||
[{'path': '/state', 'value': new_state, 'op': 'add'}])
|
||||
self.assertEqual('application/json', response.content_type)
|
||||
self.assertEqual(200, response.status_int)
|
||||
|
||||
response = self.get_json(
|
||||
'/action_plans/%s' % self.action_plan.uuid)
|
||||
self.assertEqual(new_state, response['state'])
|
||||
|
||||
def test_add_non_existent_property(self):
|
||||
def test_add_non_existent_property_denied(self):
|
||||
response = self.patch_json(
|
||||
'/action_plans/%s' % self.action_plan.uuid,
|
||||
[{'path': '/foo', 'value': 'bar', 'op': 'add'}],
|
||||
|
@ -383,22 +369,22 @@ class TestPatch(api_base.FunctionalTest):
|
|||
self.assertEqual(400, response.status_int)
|
||||
self.assertTrue(response.json['error_message'])
|
||||
|
||||
def test_remove_ok(self):
|
||||
def test_remove_denied(self):
|
||||
# We should not be able to remove the state of an action plan
|
||||
response = self.get_json(
|
||||
'/action_plans/%s' % self.action_plan.uuid)
|
||||
self.assertIsNotNone(response['state'])
|
||||
|
||||
response = self.patch_json(
|
||||
'/action_plans/%s' % self.action_plan.uuid,
|
||||
[{'path': '/state', 'op': 'remove'}])
|
||||
[{'path': '/state', 'op': 'remove'}],
|
||||
expect_errors=True)
|
||||
|
||||
self.assertEqual('application/json', response.content_type)
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual(400, response.status_int)
|
||||
self.assertTrue(response.json['error_message'])
|
||||
|
||||
response = self.get_json(
|
||||
'/action_plans/%s' % self.action_plan.uuid)
|
||||
self.assertIsNone(response['state'])
|
||||
|
||||
def test_remove_uuid(self):
|
||||
def test_remove_uuid_denied(self):
|
||||
response = self.patch_json(
|
||||
'/action_plans/%s' % self.action_plan.uuid,
|
||||
[{'path': '/uuid', 'op': 'remove'}],
|
||||
|
@ -407,7 +393,7 @@ class TestPatch(api_base.FunctionalTest):
|
|||
self.assertEqual('application/json', response.content_type)
|
||||
self.assertTrue(response.json['error_message'])
|
||||
|
||||
def test_remove_non_existent_property(self):
|
||||
def test_remove_non_existent_property_denied(self):
|
||||
response = self.patch_json(
|
||||
'/action_plans/%s' % self.action_plan.uuid,
|
||||
[{'path': '/non-existent', 'op': 'remove'}],
|
||||
|
@ -416,19 +402,129 @@ class TestPatch(api_base.FunctionalTest):
|
|||
self.assertEqual('application/json', response.content_type)
|
||||
self.assertTrue(response.json['error_message'])
|
||||
|
||||
def test_replace_ok_state_starting(self):
|
||||
with mock.patch.object(aapi.ApplierAPI,
|
||||
'launch_action_plan') as applier_mock:
|
||||
new_state = objects.action_plan.State.TRIGGERED
|
||||
response = self.get_json(
|
||||
'/action_plans/%s' % self.action_plan.uuid)
|
||||
self.assertNotEqual(new_state, response['state'])
|
||||
@mock.patch.object(aapi.ApplierAPI, 'launch_action_plan')
|
||||
def test_replace_state_triggered_ok(self, applier_mock):
|
||||
new_state = objects.action_plan.State.TRIGGERED
|
||||
response = self.get_json(
|
||||
'/action_plans/%s' % self.action_plan.uuid)
|
||||
self.assertNotEqual(new_state, response['state'])
|
||||
response = self.patch_json(
|
||||
'/action_plans/%s' % self.action_plan.uuid,
|
||||
[{'path': '/state', 'value': new_state,
|
||||
'op': 'replace'}])
|
||||
self.assertEqual('application/json', response.content_type)
|
||||
self.assertEqual(200, response.status_code)
|
||||
applier_mock.assert_called_once_with(mock.ANY,
|
||||
self.action_plan.uuid)
|
||||
|
||||
response = self.patch_json(
|
||||
'/action_plans/%s' % self.action_plan.uuid,
|
||||
[{'path': '/state', 'value': new_state,
|
||||
'op': 'replace'}])
|
||||
self.assertEqual('application/json', response.content_type)
|
||||
self.assertEqual(200, response.status_code)
|
||||
applier_mock.assert_called_once_with(mock.ANY,
|
||||
self.action_plan.uuid)
|
||||
|
||||
ALLOWED_TRANSITIONS = [
|
||||
{"original_state": objects.action_plan.State.RECOMMENDED,
|
||||
"new_state": objects.action_plan.State.TRIGGERED},
|
||||
{"original_state": objects.action_plan.State.RECOMMENDED,
|
||||
"new_state": objects.action_plan.State.CANCELLED},
|
||||
{"original_state": objects.action_plan.State.ONGOING,
|
||||
"new_state": objects.action_plan.State.CANCELLED},
|
||||
{"original_state": objects.action_plan.State.TRIGGERED,
|
||||
"new_state": objects.action_plan.State.CANCELLED},
|
||||
]
|
||||
|
||||
|
||||
class TestPatchStateTransitionDenied(api_base.FunctionalTest):
|
||||
|
||||
STATES = [
|
||||
ap_state for ap_state in objects.action_plan.State.__dict__
|
||||
if not ap_state.startswith("_")
|
||||
]
|
||||
|
||||
scenarios = [
|
||||
(
|
||||
"%s -> %s" % (original_state, new_state),
|
||||
{"original_state": original_state,
|
||||
"new_state": new_state},
|
||||
)
|
||||
for original_state, new_state
|
||||
in list(itertools.product(STATES, STATES))
|
||||
# from DELETED to ...
|
||||
# NOTE: Any state transition from DELETED (To RECOMMENDED, TRIGGERED,
|
||||
# ONGOING, CANCELLED, SUCCEEDED and FAILED) will cause a 404 Not Found
|
||||
# because we cannot retrieve them with a GET (soft_deleted state).
|
||||
# This is the reason why they are not listed here but they have a
|
||||
# special test to cover it
|
||||
if original_state != objects.action_plan.State.DELETED
|
||||
and original_state != new_state
|
||||
and {"original_state": original_state,
|
||||
"new_state": new_state} not in ALLOWED_TRANSITIONS
|
||||
]
|
||||
|
||||
@mock.patch.object(
|
||||
db_api.BaseConnection, 'update_action_plan',
|
||||
mock.Mock(side_effect=lambda ap: ap.save() or ap))
|
||||
def test_replace_state_triggered_denied(self):
|
||||
action_plan = obj_utils.create_action_plan_without_audit(
|
||||
self.context, state=self.original_state)
|
||||
|
||||
initial_ap = self.get_json('/action_plans/%s' % action_plan.uuid)
|
||||
response = self.patch_json(
|
||||
'/action_plans/%s' % action_plan.uuid,
|
||||
[{'path': '/state', 'value': self.new_state,
|
||||
'op': 'replace'}],
|
||||
expect_errors=True)
|
||||
updated_ap = self.get_json('/action_plans/%s' % action_plan.uuid)
|
||||
|
||||
self.assertNotEqual(self.new_state, initial_ap['state'])
|
||||
self.assertEqual(self.original_state, updated_ap['state'])
|
||||
self.assertEqual(400, response.status_code)
|
||||
self.assertEqual('application/json', response.content_type)
|
||||
self.assertTrue(response.json['error_message'])
|
||||
|
||||
|
||||
class TestPatchStateDeletedNotFound(api_base.FunctionalTest):
|
||||
|
||||
@mock.patch.object(
|
||||
db_api.BaseConnection, 'update_action_plan',
|
||||
mock.Mock(side_effect=lambda ap: ap.save() or ap))
|
||||
def test_replace_state_triggered_not_found(self):
|
||||
action_plan = obj_utils.create_action_plan_without_audit(
|
||||
self.context, state=objects.action_plan.State.DELETED)
|
||||
|
||||
response = self.get_json(
|
||||
'/action_plans/%s' % action_plan.uuid,
|
||||
expect_errors=True
|
||||
)
|
||||
self.assertEqual(404, response.status_code)
|
||||
self.assertEqual('application/json', response.content_type)
|
||||
self.assertTrue(response.json['error_message'])
|
||||
|
||||
|
||||
class TestPatchStateTransitionOk(api_base.FunctionalTest):
|
||||
|
||||
scenarios = [
|
||||
(
|
||||
"%s -> %s" % (transition["original_state"],
|
||||
transition["new_state"]),
|
||||
transition
|
||||
)
|
||||
for transition in ALLOWED_TRANSITIONS
|
||||
]
|
||||
|
||||
@mock.patch.object(
|
||||
db_api.BaseConnection, 'update_action_plan',
|
||||
mock.Mock(side_effect=lambda ap: ap.save() or ap))
|
||||
@mock.patch.object(aapi.ApplierAPI, 'launch_action_plan', mock.Mock())
|
||||
def test_replace_state_triggered_ok(self):
|
||||
action_plan = obj_utils.create_action_plan_without_audit(
|
||||
self.context, state=self.original_state)
|
||||
|
||||
initial_ap = self.get_json('/action_plans/%s' % action_plan.uuid)
|
||||
|
||||
response = self.patch_json(
|
||||
'/action_plans/%s' % action_plan.uuid,
|
||||
[{'path': '/state', 'value': self.new_state,
|
||||
'op': 'replace'}])
|
||||
updated_ap = self.get_json('/action_plans/%s' % action_plan.uuid)
|
||||
|
||||
self.assertNotEqual(self.new_state, initial_ap['state'])
|
||||
self.assertEqual(self.new_state, updated_ap['state'])
|
||||
self.assertEqual('application/json', response.content_type)
|
||||
self.assertEqual(200, response.status_code)
|
||||
|
|
|
@ -16,26 +16,20 @@
|
|||
from __future__ import unicode_literals
|
||||
|
||||
from mock import patch
|
||||
from stevedore.extension import Extension
|
||||
from stevedore.extension import ExtensionManager
|
||||
|
||||
from watcher.decision_engine.strategy.loading.default import \
|
||||
DefaultStrategyLoader
|
||||
from watcher.decision_engine.strategy.strategies.base import BaseStrategy
|
||||
from watcher.decision_engine.strategy.strategies.dummy_strategy import \
|
||||
DummyStrategy
|
||||
from watcher.tests.base import TestCase
|
||||
from stevedore import extension
|
||||
from watcher.common import exception
|
||||
from watcher.decision_engine.strategy.loading import default as default_loading
|
||||
from watcher.decision_engine.strategy.strategies import dummy_strategy
|
||||
from watcher.tests import base
|
||||
|
||||
|
||||
class TestDefaultStrategyLoader(TestCase):
|
||||
class TestDefaultStrategyLoader(base.TestCase):
|
||||
|
||||
strategy_loader = DefaultStrategyLoader()
|
||||
strategy_loader = default_loading.DefaultStrategyLoader()
|
||||
|
||||
def test_load_strategy_with_empty_model(self):
|
||||
selected_strategy = self.strategy_loader.load(None)
|
||||
self.assertIsNotNone(selected_strategy,
|
||||
'The default strategy not be must none')
|
||||
self.assertIsInstance(selected_strategy, BaseStrategy)
|
||||
self.assertRaises(
|
||||
exception.LoadingError, self.strategy_loader.load, None)
|
||||
|
||||
def test_load_strategy_is_basic(self):
|
||||
exptected_strategy = 'basic'
|
||||
|
@ -45,31 +39,32 @@ class TestDefaultStrategyLoader(TestCase):
|
|||
exptected_strategy,
|
||||
'The default strategy should be basic')
|
||||
|
||||
@patch(
|
||||
"watcher.decision_engine.strategy.loading.default.ExtensionManager")
|
||||
@patch("watcher.common.loader.default.ExtensionManager")
|
||||
def test_strategy_loader(self, m_extension_manager):
|
||||
dummy_strategy_name = "dummy"
|
||||
# Set up the fake Stevedore extensions
|
||||
m_extension_manager.return_value = ExtensionManager.make_test_instance(
|
||||
extensions=[Extension(
|
||||
name=dummy_strategy_name,
|
||||
entry_point="%s:%s" % (DummyStrategy.__module__,
|
||||
DummyStrategy.__name__),
|
||||
plugin=DummyStrategy,
|
||||
obj=None,
|
||||
)],
|
||||
namespace="watcher_strategies",
|
||||
)
|
||||
strategy_loader = DefaultStrategyLoader()
|
||||
m_extension_manager.return_value = extension.\
|
||||
ExtensionManager.make_test_instance(
|
||||
extensions=[extension.Extension(
|
||||
name=dummy_strategy_name,
|
||||
entry_point="%s:%s" % (
|
||||
dummy_strategy.DummyStrategy.__module__,
|
||||
dummy_strategy.DummyStrategy.__name__),
|
||||
plugin=dummy_strategy.DummyStrategy,
|
||||
obj=None,
|
||||
)],
|
||||
namespace="watcher_strategies",
|
||||
)
|
||||
strategy_loader = default_loading.DefaultStrategyLoader()
|
||||
loaded_strategy = strategy_loader.load("dummy")
|
||||
|
||||
self.assertEqual("dummy", loaded_strategy.name)
|
||||
self.assertEqual("Dummy Strategy", loaded_strategy.description)
|
||||
|
||||
def test_load_dummy_strategy(self):
|
||||
strategy_loader = DefaultStrategyLoader()
|
||||
strategy_loader = default_loading.DefaultStrategyLoader()
|
||||
loaded_strategy = strategy_loader.load("dummy")
|
||||
self.assertIsInstance(loaded_strategy, DummyStrategy)
|
||||
self.assertIsInstance(loaded_strategy, dummy_strategy.DummyStrategy)
|
||||
|
||||
def test_endpoints(self):
|
||||
for endpoint in self.strategy_loader.list_available():
|
||||
|
|
Loading…
Reference in New Issue