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 83fdbf7366
7 changed files with 305 additions and 146 deletions

View File

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

View File

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

View File

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

View File

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

View File

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