diff --git a/ceilometer/api/controllers/v2.py b/ceilometer/api/controllers/v2.py index 93db1f040..9af4b2b11 100644 --- a/ceilometer/api/controllers/v2.py +++ b/ceilometer/api/controllers/v2.py @@ -2052,9 +2052,29 @@ class QuerySamplesController(rest.RestController): query.limit)] +class QueryAlarmHistoryController(rest.RestController): + """Provides complex query possibilites for alarm history + """ + @wsme_pecan.wsexpose([AlarmChange], body=ComplexQuery) + def post(self, body): + """Define query for retrieving AlarmChange data. + + :param body: Query rules for the alarm history to be returned. + """ + query = ValidatedComplexQuery(body) + query.validate(visibility_field="on_behalf_of") + conn = pecan.request.storage_conn + return [AlarmChange.from_db_model(s) + for s in conn.query_alarm_history(query.filter_expr, + query.orderby, + query.limit)] + + class QueryAlarmsController(rest.RestController): """Provides complex query possibilities for alarms """ + history = QueryAlarmHistoryController() + @wsme_pecan.wsexpose([Alarm], body=ComplexQuery) def post(self, body): """Define query for retrieving Alarm data. diff --git a/ceilometer/storage/base.py b/ceilometer/storage/base.py index f4eb2a6d9..d4a3b2cf2 100644 --- a/ceilometer/storage/base.py +++ b/ceilometer/storage/base.py @@ -343,3 +343,15 @@ class Connection(object): raise NotImplementedError(_('Complex query for alarms \ is not implemented.')) + + @staticmethod + def query_alarm_history(filter_expr=None, orderby=None, limit=None): + """Return an iterable of model.AlarmChange objects. + + :param filter_expr: Filter expression for query. + :param orderby: List of field name and direction pairs for order by. + :param limit: Maximum number of results to return. + """ + + raise NotImplementedError(_('Complex query for alarms \ + history is not implemented.')) diff --git a/ceilometer/storage/impl_mongodb.py b/ceilometer/storage/impl_mongodb.py index da84ca86e..62fff58b4 100644 --- a/ceilometer/storage/impl_mongodb.py +++ b/ceilometer/storage/impl_mongodb.py @@ -791,7 +791,8 @@ class Connection(base.Connection): filter_expr) retrieve = {models.Meter: self._retrieve_samples, - models.Alarm: self._retrieve_alarms} + models.Alarm: self._retrieve_alarms, + models.AlarmChange: self._retrieve_alarm_changes} return retrieve[model](query_filter, orderby_filter, limit) def query_samples(self, filter_expr=None, orderby=None, limit=None): @@ -1014,6 +1015,21 @@ class Connection(base.Connection): """ self.db.alarm.remove({'alarm_id': alarm_id}) + def _retrieve_alarm_changes(self, query_filter, orderby, limit): + if limit is not None: + alarms_history = self.db.alarm_history.find(query_filter, + limit=limit, + sort=orderby) + else: + alarms_history = self.db.alarm_history.find( + query_filter, sort=orderby) + + for alarm_history in alarms_history: + ah = {} + ah.update(alarm_history) + del ah['_id'] + yield models.AlarmChange(**ah) + def get_alarm_changes(self, alarm_id, on_behalf_of, user=None, project=None, type=None, start_timestamp=None, start_timestamp_op=None, @@ -1057,12 +1073,10 @@ class Connection(base.Connection): if ts_range: q['timestamp'] = ts_range - sort = [("timestamp", pymongo.DESCENDING)] - for alarm_change in self.db.alarm_history.find(q, sort=sort): - ac = {} - ac.update(alarm_change) - del ac['_id'] - yield models.AlarmChange(**ac) + return self._retrieve_alarm_changes(q, + [("timestamp", + pymongo.DESCENDING)], + None) def record_alarm_change(self, alarm_change): """Record alarm change event. @@ -1073,3 +1087,11 @@ class Connection(base.Connection): """Return an iterable of model.Alarm objects. """ return self._retrieve_data(filter_expr, orderby, limit, models.Alarm) + + def query_alarm_history(self, filter_expr=None, orderby=None, limit=None): + """Return an iterable of model.AlarmChange objects. + """ + return self._retrieve_data(filter_expr, + orderby, + limit, + models.AlarmChange) diff --git a/ceilometer/storage/impl_sqlalchemy.py b/ceilometer/storage/impl_sqlalchemy.py index 1deab2cc4..91a4baa64 100644 --- a/ceilometer/storage/impl_sqlalchemy.py +++ b/ceilometer/storage/impl_sqlalchemy.py @@ -602,7 +602,8 @@ class Connection(base.Connection): table) retrieve = {models.Meter: self._retrieve_samples, - models.Alarm: self._retrieve_alarms} + models.Alarm: self._retrieve_alarms, + models.AlarmChange: self._retrieve_alarm_history} return retrieve[table](query) def query_samples(self, filter_expr=None, orderby=None, limit=None): @@ -840,6 +841,17 @@ class Connection(base.Connection): """ return self._retrieve_data(filter_expr, orderby, limit, models.Alarm) + def _retrieve_alarm_history(self, query): + return (self._row_to_alarm_change_model(x) for x in query.all()) + + def query_alarm_history(self, filter_expr=None, orderby=None, limit=None): + """Return an iterable of model.AlarmChange objects. + """ + return self._retrieve_data(filter_expr, + orderby, + limit, + models.AlarmChange) + def get_alarm_changes(self, alarm_id, on_behalf_of, user=None, project=None, type=None, start_timestamp=None, start_timestamp_op=None, @@ -896,7 +908,7 @@ class Connection(base.Connection): models.AlarmChange.timestamp < end_timestamp) query = query.order_by(desc(models.AlarmChange.timestamp)) - return (self._row_to_alarm_change_model(x) for x in query.all()) + return self._retrieve_alarm_history(query) def record_alarm_change(self, alarm_change): """Record alarm change event. diff --git a/ceilometer/tests/api/v2/test_complex_query_scenarios.py b/ceilometer/tests/api/v2/test_complex_query_scenarios.py index 3dfb9e24c..c910585b3 100644 --- a/ceilometer/tests/api/v2/test_complex_query_scenarios.py +++ b/ceilometer/tests/api/v2/test_complex_query_scenarios.py @@ -340,3 +340,108 @@ class TestQueryAlarmsController(tests_api.FunctionalTest, self.assertEqual(400, data.status_int) self.assertIn("Limit should be positive", data.body) + + +class TestQueryAlarmsHistoryController( + tests_api.FunctionalTest, tests_db.MixinTestsWithBackendScenarios): + + def setUp(self): + super(TestQueryAlarmsHistoryController, self).setUp() + self.url = '/query/alarms/history' + for id in [1, 2]: + for type in ["creation", "state transition"]: + for date in [datetime.datetime(2013, 1, 1), + datetime.datetime(2013, 2, 2)]: + event_id = "-".join([str(id), type, date.isoformat()]) + alarm_change = {"event_id": event_id, + "alarm_id": "alarm-id%d" % id, + "type": type, + "detail": "", + "user_id": "user-id%d" % id, + "project_id": "project-id%d" % id, + "on_behalf_of": "project-id%d" % id, + "timestamp": date} + + self.conn.record_alarm_change(alarm_change) + + def test_query_all(self): + data = self.post_json(self.url, + params={}) + + self.assertEqual(8, len(data.json)) + + def test_filter_with_isotime(self): + date_time = datetime.datetime(2013, 1, 1) + isotime = date_time.isoformat() + + data = self.post_json(self.url, + params={"filter": + '{">": {"timestamp":"' + + isotime + '"}}'}) + + self.assertEqual(4, len(data.json)) + for history in data.json: + result_time = timeutils.parse_isotime(history['timestamp']) + result_time = result_time.replace(tzinfo=None) + self.assertTrue(result_time > date_time) + + def test_non_admin_tenant_sees_only_its_own_project(self): + data = self.post_json(self.url, + params={}, + headers=non_admin_header) + for history in data.json: + self.assertEqual("project-id1", history['on_behalf_of']) + + def test_non_admin_tenant_cannot_query_others_project(self): + data = self.post_json(self.url, + params={"filter": + '{"=": {"on_behalf_of":' + + ' "project-id2"}}'}, + expect_errors=True, + headers=non_admin_header) + + self.assertEqual(401, data.status_int) + self.assertIn("Not Authorized to access project project-id2", + data.body) + + def test_non_admin_tenant_can_explicitly_filter_for_own_project(self): + data = self.post_json(self.url, + params={"filter": + '{"=": {"on_behalf_of":' + + ' "project-id1"}}'}, + headers=non_admin_header) + + for history in data.json: + self.assertEqual("project-id1", history['on_behalf_of']) + + def test_admin_tenant_sees_every_project(self): + data = self.post_json(self.url, + params={}, + headers=admin_header) + + self.assertEqual(8, len(data.json)) + for history in data.json: + self.assertIn(history['on_behalf_of'], + (["project-id1", "project-id2"])) + + def test_query_with_filter_orderby_and_limit(self): + data = self.post_json(self.url, + params={"filter": '{"=": {"type": "creation"}}', + "orderby": '[{"timestamp": "DESC"}]', + "limit": 3}) + + self.assertEqual(3, len(data.json)) + self.assertEqual(["2013-02-02T00:00:00", + "2013-02-02T00:00:00", + "2013-01-01T00:00:00"], + [h["timestamp"] for h in data.json]) + for history in data.json: + self.assertEqual("creation", history["type"]) + + def test_limit_should_be_positive(self): + data = self.post_json(self.url, + params={"limit": 0}, + expect_errors=True) + + self.assertEqual(400, data.status_int) + self.assertIn("Limit should be positive", data.body) diff --git a/ceilometer/tests/storage/test_storage_scenarios.py b/ceilometer/tests/storage/test_storage_scenarios.py index c07ac6ff2..ec3492128 100644 --- a/ceilometer/tests/storage/test_storage_scenarios.py +++ b/ceilometer/tests/storage/test_storage_scenarios.py @@ -2331,6 +2331,131 @@ class ComplexAlarmQueryTest(AlarmTestBase, self.assertTrue(a.enabled) +class ComplexAlarmHistoryQueryTest(AlarmTestBase, + tests_db.MixinTestsWithBackendScenarios): + def setUp(self): + super(DBTestBase, self).setUp() + self.filter_expr = {"and": + [{"or": + [{"=": {"type": "rule change"}}, + {"=": {"type": "state transition"}}]}, + {"=": {"alarm_id": "0r4ng3"}}]} + self.add_some_alarms() + self.prepare_alarm_history() + + def prepare_alarm_history(self): + alarms = list(self.conn.get_alarms()) + for alarm in alarms: + i = alarms.index(alarm) + alarm_change = dict(event_id= + "16fd2706-8baf-433b-82eb-8c7fada847c%s" % i, + alarm_id=alarm.alarm_id, + type=models.AlarmChange.CREATION, + detail="detail %s" % alarm.name, + user_id=alarm.user_id, + project_id=alarm.project_id, + on_behalf_of=alarm.project_id, + timestamp=datetime.datetime(2012, 9, 24, + 7 + i, + 30 + i)) + self.conn.record_alarm_change(alarm_change=alarm_change) + + alarm_change2 = dict(event_id= + "16fd2706-8baf-433b-82eb-8c7fada847d%s" % i, + alarm_id=alarm.alarm_id, + type=models.AlarmChange.RULE_CHANGE, + detail="detail %s" % i, + user_id=alarm.user_id, + project_id=alarm.project_id, + on_behalf_of=alarm.project_id, + timestamp=datetime.datetime(2012, 9, 25, + 10 + i, + 30 + i)) + self.conn.record_alarm_change(alarm_change=alarm_change2) + + alarm_change3 = dict(event_id= + "16fd2706-8baf-433b-82eb-8c7fada847e%s" + % i, + alarm_id=alarm.alarm_id, + type=models.AlarmChange.STATE_TRANSITION, + detail="detail %s" % (i + 1), + user_id=alarm.user_id, + project_id=alarm.project_id, + on_behalf_of=alarm.project_id, + timestamp=datetime.datetime(2012, 9, 26, + 10 + i, + 30 + i)) + + if alarm.name == "red-alert": + alarm_change3['on_behalf_of'] = 'and-da-girls' + + self.conn.record_alarm_change(alarm_change=alarm_change3) + + if alarm.name in ["red-alert", "yellow-alert"]: + alarm_change4 = dict(event_id= + "16fd2706-8baf-433b-82eb-8c7fada847f%s" + % i, + alarm_id=alarm.alarm_id, + type=models.AlarmChange.DELETION, + detail="detail %s" % (i + 2), + user_id=alarm.user_id, + project_id=alarm.project_id, + on_behalf_of=alarm.project_id, + timestamp=datetime.datetime(2012, 9, 27, + 10 + i, + 30 + i)) + self.conn.record_alarm_change(alarm_change=alarm_change4) + + def test_alarm_history_with_no_filter(self): + history = list(self.conn.query_alarm_history()) + self.assertEqual(11, len(history)) + + def test_alarm_history_with_no_filter_and_limit(self): + history = list(self.conn.query_alarm_history(limit=3)) + self.assertEqual(3, len(history)) + + def test_alarm_history_with_filter(self): + history = list( + self.conn.query_alarm_history(filter_expr=self.filter_expr)) + self.assertEqual(2, len(history)) + + def test_alarm_history_with_filter_and_orderby(self): + history = list( + self.conn.query_alarm_history(filter_expr=self.filter_expr, + orderby=[{"timestamp": + "asc"}])) + self.assertEqual([models.AlarmChange.RULE_CHANGE, + models.AlarmChange.STATE_TRANSITION], + [h.type for h in history]) + + def test_alarm_history_with_filter_and_orderby_and_limit(self): + history = list( + self.conn.query_alarm_history(filter_expr=self.filter_expr, + orderby=[{"timestamp": + "asc"}], + limit=1)) + self.assertEqual(models.AlarmChange.RULE_CHANGE, history[0].type) + + def test_alarm_history_with_on_behalf_of_filter(self): + filter_expr = {"=": {"on_behalf_of": "and-da-girls"}} + history = list(self.conn.query_alarm_history(filter_expr=filter_expr)) + self.assertEqual(1, len(history)) + self.assertEqual("16fd2706-8baf-433b-82eb-8c7fada847e0", + history[0].event_id) + + def test_alarm_history_with_alarm_id_as_filter(self): + filter_expr = {"=": {"alarm_id": "r3d"}} + history = list(self.conn.query_alarm_history(filter_expr=filter_expr, + orderby=[{"timestamp": + "asc"}])) + self.assertEqual(4, len(history)) + self.assertEqual([models.AlarmChange.CREATION, + models.AlarmChange.RULE_CHANGE, + models.AlarmChange.STATE_TRANSITION, + models.AlarmChange.DELETION], + [h.type for h in history]) + + class EventTestBase(tests_db.TestBase, tests_db.MixinTestsWithBackendScenarios): """Separate test base class because we don't want to diff --git a/doc/source/webapi/v2.rst b/doc/source/webapi/v2.rst index 06e9bfeb8..88cfaa2d0 100644 --- a/doc/source/webapi/v2.rst +++ b/doc/source/webapi/v2.rst @@ -94,9 +94,9 @@ field of *Sample*). Complex Query +++++++++++++ The filter expressions of the Complex Query feature operate on the fields -of *Sample* and *Alarm*. The following comparison operators are supported: *=*, -*!=*, *<*, *<=*, *>* and *>=*; and the following logical operators can be -used: *and* and *or*. +of *Sample*, *Alarm* and *AlarmChange*. The following comparison operators are +supported: *=*, *!=*, *<*, *<=*, *>* and *>=*; and the following logical operators +can be used: *and* and *or*. Complex Query supports defining the list of orderby expressions in the form of [{"field_name": "asc"}, {"field_name2": "desc"}, ...]. @@ -111,6 +111,9 @@ The *filter*, *orderby* and *limit* are all optional fields in a query. .. rest-controller:: ceilometer.api.controllers.v2:QueryAlarmsController :webprefix: /v2/query/alarms +.. rest-controller:: ceilometer.api.controllers.v2:QueryAlarmHistoryController + :webprefix: /v2/query/alarms/history + .. autotype:: ceilometer.api.controllers.v2.ComplexQuery :members: