diff --git a/watcher/api/controllers/v1/__init__.py b/watcher/api/controllers/v1/__init__.py index 8f752ec81..37cb77454 100644 --- a/watcher/api/controllers/v1/__init__.py +++ b/watcher/api/controllers/v1/__init__.py @@ -34,6 +34,7 @@ from watcher.api.controllers.v1 import action_plan from watcher.api.controllers.v1 import audit from watcher.api.controllers.v1 import audit_template from watcher.api.controllers.v1 import goal +from watcher.api.controllers.v1 import strategy class APIBase(wtypes.Base): @@ -157,6 +158,7 @@ class Controller(rest.RestController): actions = action.ActionsController() action_plans = action_plan.ActionPlansController() goals = goal.GoalsController() + strategies = strategy.StrategiesController() @wsme_pecan.wsexpose(V1) def get(self): diff --git a/watcher/api/controllers/v1/strategy.py b/watcher/api/controllers/v1/strategy.py new file mode 100644 index 000000000..4dcc6e59d --- /dev/null +++ b/watcher/api/controllers/v1/strategy.py @@ -0,0 +1,253 @@ +# -*- encoding: utf-8 -*- +# Copyright (c) 2016 b<>com +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from oslo_config import cfg + +import pecan +from pecan import rest +import wsme +from wsme import types as wtypes +import wsmeext.pecan as wsme_pecan + +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.common import exception +from watcher.common import utils as common_utils +from watcher import objects + +CONF = cfg.CONF + + +class Strategy(base.APIBase): + """API representation of a strategy. + + This class enforces type checking and value constraints, and converts + between the internal object model and the API representation of a strategy. + """ + _goal_uuid = None + + def _get_goal(self, value): + if value == wtypes.Unset: + return None + goal = None + try: + if (common_utils.is_uuid_like(value) or + common_utils.is_int_like(value)): + goal = objects.Goal.get(pecan.request.context, value) + else: + goal = objects.Goal.get_by_name(pecan.request.context, value) + except exception.GoalNotFound: + pass + if goal: + self.goal_id = goal.id + return goal + + def _get_goal_uuid(self): + return self._goal_uuid + + def _set_goal_uuid(self, value): + if value and self._goal_uuid != value: + self._goal_uuid = None + goal = self._get_goal(value) + if goal: + self._goal_uuid = goal.uuid + + uuid = types.uuid + """Unique UUID for this strategy""" + + name = wtypes.text + """Name of the strategy""" + + display_name = wtypes.text + """Localized name of the strategy""" + + links = wsme.wsattr([link.Link], readonly=True) + """A list containing a self link and associated goal links""" + + goal_uuid = wsme.wsproperty(wtypes.text, _get_goal_uuid, _set_goal_uuid, + mandatory=True) + """The UUID of the goal this audit refers to""" + + def __init__(self, **kwargs): + super(Strategy, self).__init__() + + self.fields = [] + self.fields.append('uuid') + self.fields.append('name') + self.fields.append('display_name') + self.fields.append('goal_uuid') + setattr(self, 'uuid', kwargs.get('uuid', wtypes.Unset)) + setattr(self, 'name', kwargs.get('name', wtypes.Unset)) + setattr(self, 'display_name', kwargs.get('display_name', wtypes.Unset)) + setattr(self, 'goal_uuid', kwargs.get('goal_id', wtypes.Unset)) + + @staticmethod + def _convert_with_links(strategy, url, expand=True): + if not expand: + strategy.unset_fields_except( + ['uuid', 'name', 'display_name', 'goal_uuid']) + + strategy.links = [ + link.Link.make_link('self', url, 'strategies', strategy.uuid), + link.Link.make_link('bookmark', url, 'strategies', strategy.uuid, + bookmark=True)] + return strategy + + @classmethod + def convert_with_links(cls, strategy, expand=True): + strategy = Strategy(**strategy.as_dict()) + return cls._convert_with_links( + strategy, pecan.request.host_url, expand) + + @classmethod + def sample(cls, expand=True): + sample = cls(uuid='27e3153e-d5bf-4b7e-b517-fb518e17f34c', + name='DUMMY', + display_name='Dummy strategy') + return cls._convert_with_links(sample, 'http://localhost:9322', expand) + + +class StrategyCollection(collection.Collection): + """API representation of a collection of strategies.""" + + strategies = [Strategy] + """A list containing strategies objects""" + + def __init__(self, **kwargs): + super(StrategyCollection, self).__init__() + self._type = 'strategies' + + @staticmethod + def convert_with_links(strategies, limit, url=None, expand=False, + **kwargs): + strategy_collection = StrategyCollection() + strategy_collection.strategies = [ + Strategy.convert_with_links(g, expand) for g in strategies] + + if 'sort_key' in kwargs: + reverse = False + if kwargs['sort_key'] == 'strategy': + if 'sort_dir' in kwargs: + reverse = True if kwargs['sort_dir'] == 'desc' else False + strategy_collection.strategies = sorted( + strategy_collection.strategies, + key=lambda strategy: strategy.uuid, + reverse=reverse) + + strategy_collection.next = strategy_collection.get_next( + limit, url=url, **kwargs) + return strategy_collection + + @classmethod + def sample(cls): + sample = cls() + sample.strategies = [Strategy.sample(expand=False)] + return sample + + +class StrategiesController(rest.RestController): + """REST controller for Strategies.""" + def __init__(self): + super(StrategiesController, self).__init__() + + from_strategies = False + """A flag to indicate if the requests to this controller are coming + from the top-level resource Strategies.""" + + _custom_actions = { + 'detail': ['GET'], + } + + def _get_strategies_collection(self, filters, marker, limit, sort_key, + sort_dir, expand=False, resource_url=None): + limit = api_utils.validate_limit(limit) + api_utils.validate_sort_dir(sort_dir) + + sort_db_key = (sort_key if sort_key in objects.Strategy.fields.keys() + else None) + + marker_obj = None + if marker: + marker_obj = objects.Strategy.get_by_uuid( + pecan.request.context, marker) + + strategies = objects.Strategy.list( + pecan.request.context, limit, marker_obj, filters=filters, + sort_key=sort_db_key, sort_dir=sort_dir) + + return StrategyCollection.convert_with_links( + strategies, limit, url=resource_url, expand=expand, + sort_key=sort_key, sort_dir=sort_dir) + + @wsme_pecan.wsexpose(StrategyCollection, wtypes.text, wtypes.text, + int, wtypes.text, wtypes.text) + def get_all(self, goal_uuid=None, marker=None, limit=None, + sort_key='id', sort_dir='asc'): + """Retrieve a list of strategies. + + :param goal_uuid: goal UUID to filter by. + :param marker: pagination marker for large data sets. + :param limit: maximum number of resources to return in a single result. + :param sort_key: column to sort results by. Default: id. + :param sort_dir: direction to sort. "asc" or "desc". Default: asc. + """ + filters = api_utils.as_filters_dict(goal_uuid=goal_uuid) + return self._get_strategies_collection( + filters, marker, limit, sort_key, sort_dir) + + @wsme_pecan.wsexpose(StrategyCollection, wtypes.text, wtypes.text, int, + wtypes.text, wtypes.text) + def detail(self, goal_uuid=None, marker=None, limit=None, + sort_key='id', sort_dir='asc'): + """Retrieve a list of strategies with detail. + + :param goal_uuid: goal UUID to filter by. + :param marker: pagination marker for large data sets. + :param limit: maximum number of resources to return in a single result. + :param sort_key: column to sort results by. Default: id. + :param sort_dir: direction to sort. "asc" or "desc". Default: asc. + """ + # NOTE(lucasagomes): /detail should only work agaist collections + parent = pecan.request.path.split('/')[:-1][-1] + if parent != "strategies": + raise exception.HTTPNotFound + expand = True + resource_url = '/'.join(['strategies', 'detail']) + + filters = api_utils.as_filters_dict(goal_uuid=goal_uuid) + return self._get_strategies_collection( + filters, marker, limit, sort_key, sort_dir, expand, resource_url) + + @wsme_pecan.wsexpose(Strategy, wtypes.text) + def get_one(self, strategy): + """Retrieve information about the given strategy. + + :param strategy: UUID or name of the strategy. + """ + if self.from_strategies: + raise exception.OperationNotPermitted + + if common_utils.is_uuid_like(strategy): + get_strategy_func = objects.Strategy.get_by_uuid + else: + get_strategy_func = objects.Strategy.get_by_name + + rpc_strategy = get_strategy_func(pecan.request.context, strategy) + + return Strategy.convert_with_links(rpc_strategy) diff --git a/watcher/db/sqlalchemy/api.py b/watcher/db/sqlalchemy/api.py index 8f3fbcf69..3eca71bc1 100644 --- a/watcher/db/sqlalchemy/api.py +++ b/watcher/db/sqlalchemy/api.py @@ -187,10 +187,19 @@ class Connection(api.BaseConnection): def __add_join_filter(self, query, model, join_model, fieldname, value): query = query.join(join_model) - return self.__add_simple_filter(query, model, fieldname, value) + return self.__add_simple_filter(query, join_model, fieldname, value) def _add_filters(self, query, model, filters=None, plain_fields=None, join_fieldmap=None): + """Generic way to add filters to a Watcher model + + :param query: a :py:class:`sqlalchemy.orm.query.Query` instance + :param model: the model class the filters should relate to + :param filters: dict with the following structure {"fieldname": value} + :param plain_fields: a :py:class:`sqlalchemy.orm.query.Query` instance + :param join_fieldmap: a :py:class:`sqlalchemy.orm.query.Query` instance + + """ filters = filters or {} plain_fields = plain_fields or () join_fieldmap = join_fieldmap or {} @@ -200,9 +209,9 @@ class Connection(api.BaseConnection): query = self.__add_simple_filter( query, model, fieldname, value) elif fieldname in join_fieldmap: - join_model = join_fieldmap[fieldname] + join_field, join_model = join_fieldmap[fieldname] query = self.__add_join_filter( - query, model, join_model, fieldname, value) + query, model, join_model, join_field, value) query = self.__add_soft_delete_mixin_filters(query, filters, model) query = self.__add_timestamp_mixin_filters(query, filters, model) @@ -272,7 +281,7 @@ class Connection(api.BaseConnection): def _add_strategies_filters(self, query, filters): plain_fields = ['uuid', 'name', 'display_name', 'goal_id'] - join_fieldmap = {'goal_uuid': models.Goal} + join_fieldmap = {'goal_uuid': ("uuid", models.Goal)} return self._add_filters( query=query, model=models.Strategy, filters=filters, diff --git a/watcher/decision_engine/strategy/context/default.py b/watcher/decision_engine/strategy/context/default.py index 3b0af6fe0..90cee30b1 100644 --- a/watcher/decision_engine/strategy/context/default.py +++ b/watcher/decision_engine/strategy/context/default.py @@ -26,6 +26,7 @@ LOG = log.getLogger(__name__) class DefaultStrategyContext(base.BaseStrategyContext): + def __init__(self): super(DefaultStrategyContext, self).__init__() LOG.debug("Initializing Strategy Context") diff --git a/watcher/tests/api/v1/test_strategies.py b/watcher/tests/api/v1/test_strategies.py new file mode 100644 index 000000000..b1c1a1358 --- /dev/null +++ b/watcher/tests/api/v1/test_strategies.py @@ -0,0 +1,159 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from oslo_config import cfg +from six.moves.urllib import parse as urlparse + +from watcher.common import utils +from watcher.tests.api import base as api_base +from watcher.tests.objects import utils as obj_utils + + +class TestListStrategy(api_base.FunctionalTest): + + def _assert_strategy_fields(self, strategy): + strategy_fields = ['uuid', 'name', 'display_name', 'goal_uuid'] + for field in strategy_fields: + self.assertIn(field, strategy) + + def test_one(self): + strategy = obj_utils.create_test_strategy(self.context) + response = self.get_json('/strategies') + self.assertEqual(strategy.uuid, response['strategies'][0]["uuid"]) + self._assert_strategy_fields(response['strategies'][0]) + + def test_get_one_by_uuid(self): + strategy = obj_utils.create_test_strategy(self.context) + response = self.get_json('/strategies/%s' % strategy.uuid) + self.assertEqual(strategy.uuid, response["uuid"]) + self.assertEqual(strategy.name, response["name"]) + self._assert_strategy_fields(response) + + def test_get_one_by_name(self): + strategy = obj_utils.create_test_strategy(self.context) + response = self.get_json(urlparse.quote( + '/strategies/%s' % strategy['name'])) + self.assertEqual(strategy.uuid, response['uuid']) + self._assert_strategy_fields(response) + + def test_get_one_soft_deleted(self): + strategy = obj_utils.create_test_strategy(self.context) + strategy.soft_delete() + response = self.get_json( + '/strategies/%s' % strategy['uuid'], + headers={'X-Show-Deleted': 'True'}) + self.assertEqual(strategy.uuid, response['uuid']) + self._assert_strategy_fields(response) + + response = self.get_json( + '/strategies/%s' % strategy['uuid'], + expect_errors=True) + self.assertEqual(404, response.status_int) + + def test_detail(self): + obj_utils.create_test_goal(self.context) + strategy = obj_utils.create_test_strategy(self.context) + response = self.get_json('/strategies/detail') + self.assertEqual(strategy.uuid, response['strategies'][0]["uuid"]) + self._assert_strategy_fields(response['strategies'][0]) + for strategy in response['strategies']: + self.assertTrue( + all(val is not None for key, val in strategy.items() + if key in ['uuid', 'name', 'display_name', 'goal_uuid'])) + + def test_detail_against_single(self): + strategy = obj_utils.create_test_strategy(self.context) + response = self.get_json('/strategies/%s/detail' % strategy.uuid, + expect_errors=True) + self.assertEqual(404, response.status_int) + + def test_many(self): + obj_utils.create_test_goal(self.context) + strategy_list = [] + for idx in range(1, 6): + strategy = obj_utils.create_test_strategy( + self.context, id=idx, + uuid=utils.generate_uuid(), + name='STRATEGY_{0}'.format(idx)) + strategy_list.append(strategy.uuid) + response = self.get_json('/strategies') + self.assertEqual(5, len(response['strategies'])) + for strategy in response['strategies']: + self.assertTrue( + all(val is not None for key, val in strategy.items() + if key in ['uuid', 'name', 'display_name', 'goal_uuid'])) + + def test_many_without_soft_deleted(self): + strategy_list = [] + for id_ in [1, 2, 3]: + strategy = obj_utils.create_test_strategy( + self.context, id=id_, uuid=utils.generate_uuid(), + name='STRATEGY_{0}'.format(id_)) + strategy_list.append(strategy.uuid) + for id_ in [4, 5]: + strategy = obj_utils.create_test_strategy( + self.context, id=id_, uuid=utils.generate_uuid(), + name='STRATEGY_{0}'.format(id_)) + strategy.soft_delete() + response = self.get_json('/strategies') + self.assertEqual(3, len(response['strategies'])) + uuids = [s['uuid'] for s in response['strategies']] + self.assertEqual(sorted(strategy_list), sorted(uuids)) + + def test_strategies_collection_links(self): + for idx in range(1, 6): + obj_utils.create_test_strategy( + self.context, id=idx, + uuid=utils.generate_uuid(), + name='STRATEGY_{0}'.format(idx)) + response = self.get_json('/strategies/?limit=2') + self.assertEqual(2, len(response['strategies'])) + + def test_strategies_collection_links_default_limit(self): + for idx in range(1, 6): + obj_utils.create_test_strategy( + self.context, id=idx, + uuid=utils.generate_uuid(), + name='STRATEGY_{0}'.format(idx)) + cfg.CONF.set_override('max_limit', 3, 'api', enforce_type=True) + response = self.get_json('/strategies') + self.assertEqual(3, len(response['strategies'])) + + def test_filter_by_goal_uuid(self): + goal1 = obj_utils.create_test_goal( + self.context, + id=1, + uuid=utils.generate_uuid(), + name='My_Goal 1') + goal2 = obj_utils.create_test_goal( + self.context, + id=2, + uuid=utils.generate_uuid(), + name='My Goal 2') + + for id_ in range(1, 3): + obj_utils.create_test_strategy( + self.context, id=id_, + uuid=utils.generate_uuid(), + goal_id=goal1['id']) + for id_ in range(3, 5): + obj_utils.create_test_strategy( + self.context, id=id_, + uuid=utils.generate_uuid(), + goal_id=goal2['id']) + + response = self.get_json('/strategies/?goal_uuid=%s' % goal1['uuid']) + + strategies = response['strategies'] + self.assertEqual(2, len(strategies)) + for strategy in strategies: + self.assertEqual(goal1['uuid'], strategy['goal_uuid']) diff --git a/watcher/tests/objects/utils.py b/watcher/tests/objects/utils.py index b37c91f98..176d4e749 100644 --- a/watcher/tests/objects/utils.py +++ b/watcher/tests/objects/utils.py @@ -162,3 +162,30 @@ def create_test_goal(context, **kw): goal = get_test_goal(context, **kw) goal.create() return goal + + +def get_test_strategy(context, **kw): + """Return a Strategy object with appropriate attributes. + + NOTE: The object leaves the attributes marked as changed, such + that a create() could be used to commit it to the DB. + """ + db_strategy = db_utils.get_test_strategy(**kw) + # Let DB generate ID if it isn't specified explicitly + if 'id' not in kw: + del db_strategy['id'] + strategy = objects.Strategy(context) + for key in db_strategy: + setattr(strategy, key, db_strategy[key]) + return strategy + + +def create_test_strategy(context, **kw): + """Create and return a test strategy object. + + Create a strategy in the DB and return a Strategy object with appropriate + attributes. + """ + strategy = get_test_strategy(context, **kw) + strategy.create() + return strategy diff --git a/watcher_tempest_plugin/services/infra_optim/v1/json/client.py b/watcher_tempest_plugin/services/infra_optim/v1/json/client.py index 52cd659e2..8d8420e8e 100644 --- a/watcher_tempest_plugin/services/infra_optim/v1/json/client.py +++ b/watcher_tempest_plugin/services/infra_optim/v1/json/client.py @@ -259,3 +259,24 @@ class InfraOptimClientJSON(base.BaseInfraOptimClient): :return: Serialized action as a dictionary """ return self._show_request('/actions', action_uuid) + + # ### STRATEGIES ### # + + @base.handle_errors + def list_strategies(self, **kwargs): + """List all existing strategies""" + return self._list_request('/strategies', **kwargs) + + @base.handle_errors + def list_strategies_detail(self, **kwargs): + """Lists details of all existing strategies""" + return self._list_request('/strategies/detail', **kwargs) + + @base.handle_errors + def show_strategy(self, strategy): + """Gets a specific strategy + + :param strategy_id: Name of the strategy + :return: Serialized strategy as a dictionary + """ + return self._show_request('/strategies', strategy) diff --git a/watcher_tempest_plugin/tests/api/admin/test_strategy.py b/watcher_tempest_plugin/tests/api/admin/test_strategy.py new file mode 100644 index 000000000..1b3cd0e4a --- /dev/null +++ b/watcher_tempest_plugin/tests/api/admin/test_strategy.py @@ -0,0 +1,69 @@ +# -*- encoding: utf-8 -*- +# Copyright (c) 2016 b<>com +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import unicode_literals + +from tempest import test + +from watcher_tempest_plugin.tests.api.admin import base + + +class TestShowListStrategy(base.BaseInfraOptimTest): + """Tests for strategies""" + + DUMMY_STRATEGY = "dummy" + + @classmethod + def resource_setup(cls): + super(TestShowListStrategy, cls).resource_setup() + + def assert_expected(self, expected, actual, + keys=('created_at', 'updated_at', 'deleted_at')): + super(TestShowListStrategy, self).assert_expected( + expected, actual, keys) + + @test.attr(type='smoke') + def test_show_strategy(self): + _, strategy = self.client.show_strategy(self.DUMMY_STRATEGY) + + self.assertEqual(self.DUMMY_STRATEGY, strategy['name']) + self.assertIn("display_name", strategy.keys()) + + @test.attr(type='smoke') + def test_show_strategy_with_links(self): + _, strategy = self.client.show_strategy(self.DUMMY_STRATEGY) + self.assertIn('links', strategy.keys()) + self.assertEqual(2, len(strategy['links'])) + self.assertIn(strategy['uuid'], + strategy['links'][0]['href']) + + @test.attr(type="smoke") + def test_list_strategies(self): + _, body = self.client.list_strategies() + self.assertIn('strategies', body) + strategies = body['strategies'] + self.assertIn(self.DUMMY_STRATEGY, + [i['name'] for i in body['strategies']]) + + for strategy in strategies: + self.assertTrue( + all(val is not None for key, val in strategy.items() + if key in ['uuid', 'name', 'display_name', 'goal_uuid'])) + + # Verify self links. + for strategy in body['strategies']: + self.validate_self_link('strategies', strategy['uuid'], + strategy['links'][0]['href'])