diff --git a/watcher/api/controllers/v1/action_plan.py b/watcher/api/controllers/v1/action_plan.py index 8ece4105c..8237be6c9 100644 --- a/watcher/api/controllers/v1/action_plan.py +++ b/watcher/api/controllers/v1/action_plan.py @@ -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) diff --git a/watcher/locale/fr/LC_MESSAGES/watcher.po b/watcher/locale/fr/LC_MESSAGES/watcher.po index 5000eabcf..5cd95efee 100644 --- a/watcher/locale/fr/LC_MESSAGES/watcher.po +++ b/watcher/locale/fr/LC_MESSAGES/watcher.po @@ -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 "" diff --git a/watcher/locale/watcher.pot b/watcher/locale/watcher.pot index 80394f0ce..e609152a6 100644 --- a/watcher/locale/watcher.pot +++ b/watcher/locale/watcher.pot @@ -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 \n" "Language-Team: LANGUAGE \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 "" diff --git a/watcher/tests/api/v1/test_actions_plans.py b/watcher/tests/api/v1/test_actions_plans.py index 24183d6d2..28c20af46 100644 --- a/watcher/tests/api/v1/test_actions_plans.py +++ b/watcher/tests/api/v1/test_actions_plans.py @@ -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) diff --git a/watcher/tests/applier/action_plan/__init__.py b/watcher/tests/applier/action_plan/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/watcher/tests/decision_engine/strategy/loading/__init__.py b/watcher/tests/decision_engine/strategy/loading/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/watcher/tests/decision_engine/strategy/loading/test_default_strategy_loader.py b/watcher/tests/decision_engine/strategy/loading/test_default_strategy_loader.py index 20170cc55..b510e0e08 100644 --- a/watcher/tests/decision_engine/strategy/loading/test_default_strategy_loader.py +++ b/watcher/tests/decision_engine/strategy/loading/test_default_strategy_loader.py @@ -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():