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:
Vincent Françoise
2016-01-21 14:33:14 +01:00
committed by David TARDIVEL
parent 2db5ae31c7
commit a6c1bc1926
7 changed files with 305 additions and 146 deletions

View File

@@ -77,21 +77,45 @@ import wsme
from wsme import types as wtypes from wsme import types as wtypes
import wsmeext.pecan as wsme_pecan import wsmeext.pecan as wsme_pecan
from watcher._i18n import _
from watcher.api.controllers import base from watcher.api.controllers import base
from watcher.api.controllers import link from watcher.api.controllers import link
from watcher.api.controllers.v1 import collection from watcher.api.controllers.v1 import collection
from watcher.api.controllers.v1 import types from watcher.api.controllers.v1 import types
from watcher.api.controllers.v1 import utils as api_utils 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.common import exception
from watcher import objects from watcher import objects
from watcher.objects import action_plan as ap_objects
class ActionPlanPatchType(types.JsonPatchType): 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 @staticmethod
def mandatory_attrs(): def mandatory_attrs():
return [] return ["audit_id", "state", "first_action_id"]
class ActionPlan(base.APIBase): class ActionPlan(base.APIBase):
@@ -374,6 +398,34 @@ class ActionPlansController(rest.RestController):
raise exception.PatchError(patch=patch, reason=e) raise exception.PatchError(patch=patch, reason=e)
launch_action_plan = False 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 # Update only the fields that have changed
for field in objects.ActionPlan.fields: for field in objects.ActionPlan.fields:
try: try:
@@ -393,7 +445,7 @@ class ActionPlansController(rest.RestController):
action_plan_to_update.save() action_plan_to_update.save()
if launch_action_plan: if launch_action_plan:
applier_client = ApplierAPI() applier_client = rpcapi.ApplierAPI()
applier_client.launch_action_plan(pecan.request.context, applier_client.launch_action_plan(pecan.request.context,
action_plan.uuid) action_plan.uuid)

View File

@@ -19,6 +19,16 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.1.1\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 #: watcher/api/controllers/v1/types.py:148
#, python-format #, python-format
msgid "%s is not JSON serializable" msgid "%s is not JSON serializable"
@@ -363,7 +373,7 @@ msgstr ""
msgid "'obj' argument type is not valid" msgid "'obj' argument type is not valid"
msgstr "" msgstr ""
#: watcher/decision_engine/planner/default.py:76 #: watcher/decision_engine/planner/default.py:75
msgid "The action plan is empty" msgid "The action plan is empty"
msgstr "" msgstr ""

View File

@@ -7,9 +7,9 @@
#, fuzzy #, fuzzy
msgid "" msgid ""
msgstr "" 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" "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" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -18,6 +18,20 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.1.1\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 #: watcher/api/controllers/v1/types.py:148
#, python-format #, python-format
msgid "%s is not JSON serializable" msgid "%s is not JSON serializable"
@@ -65,7 +79,7 @@ msgstr ""
msgid "ErrorDocumentMiddleware received an invalid status %s" msgid "ErrorDocumentMiddleware received an invalid status %s"
msgstr "" msgstr ""
#: watcher/api/middleware/parsable_error.py:80 #: watcher/api/middleware/parsable_error.py:79
#, python-format #, python-format
msgid "Error parsing HTTP response: %s" msgid "Error parsing HTTP response: %s"
msgstr "" msgstr ""
@@ -74,17 +88,17 @@ msgstr ""
msgid "The target state is not defined" msgid "The target state is not defined"
msgstr "" msgstr ""
#: watcher/applier/workflow_engine/default.py:69 #: watcher/applier/workflow_engine/default.py:126
#, python-format #, python-format
msgid "The WorkFlow Engine has failed to execute the action %s" msgid "The WorkFlow Engine has failed to execute the action %s"
msgstr "" msgstr ""
#: watcher/applier/workflow_engine/default.py:77 #: watcher/applier/workflow_engine/default.py:144
#, python-format #, python-format
msgid "Revert action %s" msgid "Revert action %s"
msgstr "" msgstr ""
#: watcher/applier/workflow_engine/default.py:83 #: watcher/applier/workflow_engine/default.py:150
msgid "Oops! We need disaster recover plan" msgid "Oops! We need disaster recover plan"
msgstr "" msgstr ""
@@ -104,184 +118,176 @@ msgstr ""
msgid "serving on http://%(host)s:%(port)s" msgid "serving on http://%(host)s:%(port)s"
msgstr "" msgstr ""
#: watcher/common/exception.py:56 #: watcher/common/exception.py:51
msgid "An unknown exception occurred" msgid "An unknown exception occurred"
msgstr "" msgstr ""
#: watcher/common/exception.py:77 #: watcher/common/exception.py:71
msgid "Exception in string format operation" msgid "Exception in string format operation"
msgstr "" msgstr ""
#: watcher/common/exception.py:107 #: watcher/common/exception.py:101
msgid "Not authorized" msgid "Not authorized"
msgstr "" msgstr ""
#: watcher/common/exception.py:112 #: watcher/common/exception.py:106
msgid "Operation not permitted" msgid "Operation not permitted"
msgstr "" msgstr ""
#: watcher/common/exception.py:116 #: watcher/common/exception.py:110
msgid "Unacceptable parameters" msgid "Unacceptable parameters"
msgstr "" msgstr ""
#: watcher/common/exception.py:121 #: watcher/common/exception.py:115
#, python-format #, python-format
msgid "The %(name)s %(id)s could not be found" msgid "The %(name)s %(id)s could not be found"
msgstr "" msgstr ""
#: watcher/common/exception.py:125 #: watcher/common/exception.py:119
msgid "Conflict" msgid "Conflict"
msgstr "" msgstr ""
#: watcher/common/exception.py:130 #: watcher/common/exception.py:124
#, python-format #, python-format
msgid "The %(name)s resource %(id)s could not be found" msgid "The %(name)s resource %(id)s could not be found"
msgstr "" msgstr ""
#: watcher/common/exception.py:135 #: watcher/common/exception.py:129
#, python-format #, python-format
msgid "Expected an uuid or int but received %(identity)s" msgid "Expected an uuid or int but received %(identity)s"
msgstr "" msgstr ""
#: watcher/common/exception.py:139 #: watcher/common/exception.py:133
#, python-format #, python-format
msgid "Goal %(goal)s is not defined in Watcher configuration file" msgid "Goal %(goal)s is not defined in Watcher configuration file"
msgstr "" msgstr ""
#: watcher/common/exception.py:145 #: watcher/common/exception.py:139
#, python-format #, python-format
msgid "%(err)s" msgid "%(err)s"
msgstr "" msgstr ""
#: watcher/common/exception.py:149 #: watcher/common/exception.py:143
#, python-format #, python-format
msgid "Expected a uuid but received %(uuid)s" msgid "Expected a uuid but received %(uuid)s"
msgstr "" msgstr ""
#: watcher/common/exception.py:153 #: watcher/common/exception.py:147
#, python-format #, python-format
msgid "Expected a logical name but received %(name)s" msgid "Expected a logical name but received %(name)s"
msgstr "" msgstr ""
#: watcher/common/exception.py:157 #: watcher/common/exception.py:151
#, python-format #, python-format
msgid "Expected a logical name or uuid but received %(name)s" msgid "Expected a logical name or uuid but received %(name)s"
msgstr "" msgstr ""
#: watcher/common/exception.py:161 #: watcher/common/exception.py:155
#, python-format #, python-format
msgid "AuditTemplate %(audit_template)s could not be found" msgid "AuditTemplate %(audit_template)s could not be found"
msgstr "" msgstr ""
#: watcher/common/exception.py:165 #: watcher/common/exception.py:159
#, python-format #, python-format
msgid "An audit_template with UUID %(uuid)s or name %(name)s already exists" msgid "An audit_template with UUID %(uuid)s or name %(name)s already exists"
msgstr "" msgstr ""
#: watcher/common/exception.py:170 #: watcher/common/exception.py:164
#, python-format #, python-format
msgid "AuditTemplate %(audit_template)s is referenced by one or multiple audit" msgid "AuditTemplate %(audit_template)s is referenced by one or multiple audit"
msgstr "" msgstr ""
#: watcher/common/exception.py:175 #: watcher/common/exception.py:169
#, python-format #, python-format
msgid "Audit %(audit)s could not be found" msgid "Audit %(audit)s could not be found"
msgstr "" msgstr ""
#: watcher/common/exception.py:179 #: watcher/common/exception.py:173
#, python-format #, python-format
msgid "An audit with UUID %(uuid)s already exists" msgid "An audit with UUID %(uuid)s already exists"
msgstr "" msgstr ""
#: watcher/common/exception.py:183 #: watcher/common/exception.py:177
#, python-format #, python-format
msgid "Audit %(audit)s is referenced by one or multiple action plans" msgid "Audit %(audit)s is referenced by one or multiple action plans"
msgstr "" msgstr ""
#: watcher/common/exception.py:188 #: watcher/common/exception.py:182
msgid "ActionPlan %(action plan)s could not be found" msgid "ActionPlan %(action plan)s could not be found"
msgstr "" msgstr ""
#: watcher/common/exception.py:192 #: watcher/common/exception.py:186
#, python-format #, python-format
msgid "An action plan with UUID %(uuid)s already exists" msgid "An action plan with UUID %(uuid)s already exists"
msgstr "" msgstr ""
#: watcher/common/exception.py:196 #: watcher/common/exception.py:190
#, python-format #, python-format
msgid "Action Plan %(action_plan)s is referenced by one or multiple actions" msgid "Action Plan %(action_plan)s is referenced by one or multiple actions"
msgstr "" msgstr ""
#: watcher/common/exception.py:201 #: watcher/common/exception.py:195
#, python-format #, python-format
msgid "Action %(action)s could not be found" msgid "Action %(action)s could not be found"
msgstr "" msgstr ""
#: watcher/common/exception.py:205 #: watcher/common/exception.py:199
#, python-format #, python-format
msgid "An action with UUID %(uuid)s already exists" msgid "An action with UUID %(uuid)s already exists"
msgstr "" msgstr ""
#: watcher/common/exception.py:209 #: watcher/common/exception.py:203
#, python-format #, python-format
msgid "Action plan %(action_plan)s is referenced by one or multiple goals" msgid "Action plan %(action_plan)s is referenced by one or multiple goals"
msgstr "" msgstr ""
#: watcher/common/exception.py:214 #: watcher/common/exception.py:208
msgid "Filtering actions on both audit and action-plan is prohibited" msgid "Filtering actions on both audit and action-plan is prohibited"
msgstr "" msgstr ""
#: watcher/common/exception.py:223 #: watcher/common/exception.py:217
#, python-format #, python-format
msgid "Couldn't apply patch '%(patch)s'. Reason: %(reason)s" msgid "Couldn't apply patch '%(patch)s'. Reason: %(reason)s"
msgstr "" msgstr ""
#: watcher/common/exception.py:233 #: watcher/common/exception.py:224
msgid "Description must be an instance of str" msgid "Illegal argument"
msgstr "" msgstr ""
#: watcher/common/exception.py:243 #: watcher/common/exception.py:228
msgid "An exception occurred without a description"
msgstr ""
#: watcher/common/exception.py:251
msgid "Description cannot be empty"
msgstr ""
#: watcher/common/exception.py:260
msgid "No such metric" msgid "No such metric"
msgstr "" msgstr ""
#: watcher/common/exception.py:269 #: watcher/common/exception.py:232
msgid "No rows were returned" msgid "No rows were returned"
msgstr "" msgstr ""
#: watcher/common/exception.py:277 #: watcher/common/exception.py:236
msgid "'Keystone API endpoint is missing''" msgid "'Keystone API endpoint is missing''"
msgstr "" msgstr ""
#: watcher/common/exception.py:281 #: watcher/common/exception.py:240
msgid "The list of hypervisor(s) in the cluster is empty" msgid "The list of hypervisor(s) in the cluster is empty"
msgstr "" msgstr ""
#: watcher/common/exception.py:285 #: watcher/common/exception.py:244
msgid "The metrics resource collector is not defined" msgid "The metrics resource collector is not defined"
msgstr "" msgstr ""
#: watcher/common/exception.py:289 #: watcher/common/exception.py:248
msgid "the cluster state is not defined" msgid "the cluster state is not defined"
msgstr "" msgstr ""
#: watcher/common/exception.py:295 #: watcher/common/exception.py:254
#, python-format #, python-format
msgid "The instance '%(name)s' is not found" msgid "The instance '%(name)s' is not found"
msgstr "" msgstr ""
#: watcher/common/exception.py:299 #: watcher/common/exception.py:258
msgid "The hypervisor is not found" msgid "The hypervisor is not found"
msgstr "" msgstr ""
#: watcher/common/exception.py:303 #: watcher/common/exception.py:262
#, python-format #, python-format
msgid "Error loading plugin '%(name)s'" msgid "Error loading plugin '%(name)s'"
msgstr "" msgstr ""
@@ -320,7 +326,7 @@ msgstr ""
#: watcher/common/utils.py:53 #: watcher/common/utils.py:53
#, python-format #, python-format
msgid "" 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," "object is not a string: %s,"
msgstr "" msgstr ""
@@ -376,11 +382,11 @@ msgstr ""
msgid "No values returned by %(resource_id)s for %(metric_name)s" msgid "No values returned by %(resource_id)s for %(metric_name)s"
msgstr "" msgstr ""
#: watcher/decision_engine/strategy/strategies/basic_consolidation.py:349 #: watcher/decision_engine/strategy/strategies/basic_consolidation.py:424
msgid "Initializing Sercon Consolidation" msgid "Initializing Sercon Consolidation"
msgstr "" 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" msgid "The workloads of the compute nodes of the cluster is zero"
msgstr "" msgstr ""

View File

@@ -11,9 +11,11 @@
# limitations under the License. # limitations under the License.
import datetime import datetime
import itertools
import mock import mock
from oslo_config import cfg from oslo_config import cfg
from oslo_utils import timeutils
from wsme import types as wtypes from wsme import types as wtypes
from watcher.api.controllers.v1 import action_plan as api_action_plan 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): class TestActionPlanObject(base.TestCase):
def test_actionPlan_init(self): def test_action_plan_init(self):
act_plan_dict = api_utils.action_plan_post_data() act_plan_dict = api_utils.action_plan_post_data()
del act_plan_dict['state'] del act_plan_dict['state']
del act_plan_dict['audit_id'] del act_plan_dict['audit_id']
@@ -304,7 +306,7 @@ class TestDelete(api_base.FunctionalTest):
self.assertEqual('application/json', response.content_type) self.assertEqual('application/json', response.content_type)
self.assertTrue(response.json['error_message']) 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() uuid = utils.generate_uuid()
response = self.delete('/action_plans/%s' % uuid, expect_errors=True) response = self.delete('/action_plans/%s' % uuid, expect_errors=True)
self.assertEqual(404, response.status_int) self.assertEqual(404, response.status_int)
@@ -317,7 +319,7 @@ class TestPatch(api_base.FunctionalTest):
def setUp(self): def setUp(self):
super(TestPatch, self).setUp() super(TestPatch, self).setUp()
self.action_plan = obj_utils.create_action_plan_without_audit( 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') p = mock.patch.object(db_api.BaseConnection, 'update_action_plan')
self.mock_action_plan_update = p.start() self.mock_action_plan_update = p.start()
self.mock_action_plan_update.side_effect = \ self.mock_action_plan_update.side_effect = \
@@ -329,52 +331,36 @@ class TestPatch(api_base.FunctionalTest):
return action_plan return action_plan
@mock.patch('oslo_utils.timeutils.utcnow') @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) test_time = datetime.datetime(2000, 1, 1, 0, 0)
mock_utcnow.return_value = test_time mock_utcnow.return_value = test_time
new_state = 'CANCELLED' new_state = 'DELETED'
response = self.get_json( response = self.get_json(
'/action_plans/%s' % self.action_plan.uuid) '/action_plans/%s' % self.action_plan.uuid)
self.assertNotEqual(new_state, response['state']) self.assertNotEqual(new_state, response['state'])
response = self.patch_json( response = self.patch_json(
'/action_plans/%s' % self.action_plan.uuid, '/action_plans/%s' % self.action_plan.uuid,
[{'path': '/state', 'value': new_state, [{'path': '/state', 'value': new_state, 'op': 'replace'}],
'op': 'replace'}]) expect_errors=True)
self.assertEqual('application/json', response.content_type) 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( def test_replace_non_existent_action_plan_denied(self):
'/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):
response = self.patch_json( response = self.patch_json(
'/action_plans/%s' % utils.generate_uuid(), '/action_plans/%s' % utils.generate_uuid(),
[{'path': '/state', 'value': 'CANCELLED', [{'path': '/state',
'op': 'replace'}], 'value': objects.action_plan.State.TRIGGERED,
'op': 'replace'}],
expect_errors=True) expect_errors=True)
self.assertEqual(404, response.status_int) self.assertEqual(404, response.status_int)
self.assertEqual('application/json', response.content_type) self.assertEqual('application/json', response.content_type)
self.assertTrue(response.json['error_message']) self.assertTrue(response.json['error_message'])
def test_add_ok(self): def test_add_non_existent_property_denied(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):
response = self.patch_json( response = self.patch_json(
'/action_plans/%s' % self.action_plan.uuid, '/action_plans/%s' % self.action_plan.uuid,
[{'path': '/foo', 'value': 'bar', 'op': 'add'}], [{'path': '/foo', 'value': 'bar', 'op': 'add'}],
@@ -383,22 +369,22 @@ class TestPatch(api_base.FunctionalTest):
self.assertEqual(400, response.status_int) self.assertEqual(400, response.status_int)
self.assertTrue(response.json['error_message']) 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( response = self.get_json(
'/action_plans/%s' % self.action_plan.uuid) '/action_plans/%s' % self.action_plan.uuid)
self.assertIsNotNone(response['state']) self.assertIsNotNone(response['state'])
response = self.patch_json( response = self.patch_json(
'/action_plans/%s' % self.action_plan.uuid, '/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('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( def test_remove_uuid_denied(self):
'/action_plans/%s' % self.action_plan.uuid)
self.assertIsNone(response['state'])
def test_remove_uuid(self):
response = self.patch_json( response = self.patch_json(
'/action_plans/%s' % self.action_plan.uuid, '/action_plans/%s' % self.action_plan.uuid,
[{'path': '/uuid', 'op': 'remove'}], [{'path': '/uuid', 'op': 'remove'}],
@@ -407,7 +393,7 @@ class TestPatch(api_base.FunctionalTest):
self.assertEqual('application/json', response.content_type) self.assertEqual('application/json', response.content_type)
self.assertTrue(response.json['error_message']) 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( response = self.patch_json(
'/action_plans/%s' % self.action_plan.uuid, '/action_plans/%s' % self.action_plan.uuid,
[{'path': '/non-existent', 'op': 'remove'}], [{'path': '/non-existent', 'op': 'remove'}],
@@ -416,19 +402,129 @@ class TestPatch(api_base.FunctionalTest):
self.assertEqual('application/json', response.content_type) self.assertEqual('application/json', response.content_type)
self.assertTrue(response.json['error_message']) self.assertTrue(response.json['error_message'])
def test_replace_ok_state_starting(self): @mock.patch.object(aapi.ApplierAPI, 'launch_action_plan')
with mock.patch.object(aapi.ApplierAPI, def test_replace_state_triggered_ok(self, applier_mock):
'launch_action_plan') as applier_mock: new_state = objects.action_plan.State.TRIGGERED
new_state = objects.action_plan.State.TRIGGERED response = self.get_json(
response = self.get_json( '/action_plans/%s' % self.action_plan.uuid)
'/action_plans/%s' % self.action_plan.uuid) self.assertNotEqual(new_state, response['state'])
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, ALLOWED_TRANSITIONS = [
[{'path': '/state', 'value': new_state, {"original_state": objects.action_plan.State.RECOMMENDED,
'op': 'replace'}]) "new_state": objects.action_plan.State.TRIGGERED},
self.assertEqual('application/json', response.content_type) {"original_state": objects.action_plan.State.RECOMMENDED,
self.assertEqual(200, response.status_code) "new_state": objects.action_plan.State.CANCELLED},
applier_mock.assert_called_once_with(mock.ANY, {"original_state": objects.action_plan.State.ONGOING,
self.action_plan.uuid) "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)

View File

@@ -16,26 +16,20 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from mock import patch from mock import patch
from stevedore.extension import Extension from stevedore import extension
from stevedore.extension import ExtensionManager from watcher.common import exception
from watcher.decision_engine.strategy.loading import default as default_loading
from watcher.decision_engine.strategy.loading.default import \ from watcher.decision_engine.strategy.strategies import dummy_strategy
DefaultStrategyLoader from watcher.tests import base
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
class TestDefaultStrategyLoader(TestCase): class TestDefaultStrategyLoader(base.TestCase):
strategy_loader = DefaultStrategyLoader() strategy_loader = default_loading.DefaultStrategyLoader()
def test_load_strategy_with_empty_model(self): def test_load_strategy_with_empty_model(self):
selected_strategy = self.strategy_loader.load(None) self.assertRaises(
self.assertIsNotNone(selected_strategy, exception.LoadingError, self.strategy_loader.load, None)
'The default strategy not be must none')
self.assertIsInstance(selected_strategy, BaseStrategy)
def test_load_strategy_is_basic(self): def test_load_strategy_is_basic(self):
exptected_strategy = 'basic' exptected_strategy = 'basic'
@@ -45,31 +39,32 @@ class TestDefaultStrategyLoader(TestCase):
exptected_strategy, exptected_strategy,
'The default strategy should be basic') 'The default strategy should be basic')
@patch( @patch("watcher.common.loader.default.ExtensionManager")
"watcher.decision_engine.strategy.loading.default.ExtensionManager")
def test_strategy_loader(self, m_extension_manager): def test_strategy_loader(self, m_extension_manager):
dummy_strategy_name = "dummy" dummy_strategy_name = "dummy"
# Set up the fake Stevedore extensions # Set up the fake Stevedore extensions
m_extension_manager.return_value = ExtensionManager.make_test_instance( m_extension_manager.return_value = extension.\
extensions=[Extension( ExtensionManager.make_test_instance(
name=dummy_strategy_name, extensions=[extension.Extension(
entry_point="%s:%s" % (DummyStrategy.__module__, name=dummy_strategy_name,
DummyStrategy.__name__), entry_point="%s:%s" % (
plugin=DummyStrategy, dummy_strategy.DummyStrategy.__module__,
obj=None, dummy_strategy.DummyStrategy.__name__),
)], plugin=dummy_strategy.DummyStrategy,
namespace="watcher_strategies", obj=None,
) )],
strategy_loader = DefaultStrategyLoader() namespace="watcher_strategies",
)
strategy_loader = default_loading.DefaultStrategyLoader()
loaded_strategy = strategy_loader.load("dummy") loaded_strategy = strategy_loader.load("dummy")
self.assertEqual("dummy", loaded_strategy.name) self.assertEqual("dummy", loaded_strategy.name)
self.assertEqual("Dummy Strategy", loaded_strategy.description) self.assertEqual("Dummy Strategy", loaded_strategy.description)
def test_load_dummy_strategy(self): def test_load_dummy_strategy(self):
strategy_loader = DefaultStrategyLoader() strategy_loader = default_loading.DefaultStrategyLoader()
loaded_strategy = strategy_loader.load("dummy") loaded_strategy = strategy_loader.load("dummy")
self.assertIsInstance(loaded_strategy, DummyStrategy) self.assertIsInstance(loaded_strategy, dummy_strategy.DummyStrategy)
def test_endpoints(self): def test_endpoints(self):
for endpoint in self.strategy_loader.list_available(): for endpoint in self.strategy_loader.list_available():