diff --git a/ceilometer/api/controllers/v2.py b/ceilometer/api/controllers/v2.py index 747daa0b7..86790d222 100644 --- a/ceilometer/api/controllers/v2.py +++ b/ceilometer/api/controllers/v2.py @@ -34,10 +34,13 @@ import ast import datetime import inspect +import json import uuid import pecan from pecan import rest +from oslo.config import cfg + import wsme import wsmeext.pecan as wsme_pecan from wsme import types as wtypes @@ -56,6 +59,16 @@ from ceilometer.api import acl LOG = log.getLogger(__name__) +ALARM_API_OPTS = [ + cfg.BoolOpt('record_history', + default=True, + help='Record alarm change events' + ), +] + +cfg.CONF.register_opts(ALARM_API_OPTS, group='alarm') + + operation_kind = wtypes.Enum(str, 'lt', 'le', 'eq', 'ne', 'ge', 'gt') @@ -895,6 +908,9 @@ class AlarmChange(_Base): """Representation of an event in an alarm's history """ + event_id = wtypes.text + "The UUID of the change event" + alarm_id = wtypes.text "The UUID of the alarm" @@ -957,6 +973,28 @@ class AlarmController(rest.RestController): raise wsme.exc.ClientSideError(error) return alarms[0] + def _record_change(self, data, now, on_behalf_of=None, type=None): + if not cfg.CONF.alarm.record_history: + return + type = type or (storage.models.AlarmChange.STATE_TRANSITION + if data.get('state') + else storage.models.AlarmChange.RULE_CHANGE) + detail = json.dumps(utils.stringify_timestamps(data)) + user_id = pecan.request.headers.get('X-User-Id') + project_id = pecan.request.headers.get('X-Project-Id') + on_behalf_of = on_behalf_of or project_id + try: + self.conn.record_alarm_change(dict(event_id=str(uuid.uuid4()), + alarm_id=self._id, + type=type, + detail=detail, + user_id=user_id, + project_id=project_id, + on_behalf_of=on_behalf_of, + timestamp=now)) + except NotImplementedError: + pass + @wsme_pecan.wsexpose(Alarm, wtypes.text) def get(self): """Return this alarm.""" @@ -969,23 +1007,30 @@ class AlarmController(rest.RestController): # merge the new values from kwargs into the current # alarm "alarm_in". alarm_in = self._alarm() + now = timeutils.utcnow() + change = data.as_dict(storage.models.Alarm) data.state_timestamp = wsme.Unset data.alarm_id = self._id kwargs = data.as_dict(storage.models.Alarm) for k, v in kwargs.iteritems(): setattr(alarm_in, k, v) if k == 'state': - alarm_in.state_timestamp = timeutils.utcnow() + alarm_in.state_timestamp = now alarm = self.conn.update_alarm(alarm_in) + self._record_change(change, now, on_behalf_of=alarm.project_id) return Alarm.from_db_model(alarm) @wsme_pecan.wsexpose(None, wtypes.text, status_code=204) def delete(self): """Delete this alarm.""" # ensure alarm exists before deleting - alarm_id = self._alarm().alarm_id - self.conn.delete_alarm(alarm_id) + alarm = self._alarm() + self.conn.delete_alarm(alarm.alarm_id) + change = Alarm.from_db_model(alarm).as_dict(storage.models.Alarm) + self._record_change(change, + timeutils.utcnow(), + type=storage.models.AlarmChange.DELETION) # TODO(eglynn): add pagination marker to signature once overall # API support for pagination is finalized @@ -995,10 +1040,13 @@ class AlarmController(rest.RestController): :param q: Filter rules for the changes to be described. """ - # ensure per-tenant segregation - self._alarm() - # TODO(eglynn): history not yet persisted - return [] + # allow history to be returned for deleted alarms, but scope changes + # returned to those carried out on behalf of the auth'd tenant, to + # avoid inappropriate cross-tenant visibility of alarm history + auth_project = acl.get_limited_to_project(pecan.request.headers) + conn = pecan.request.storage_conn + return [AlarmChange.from_db_model(ac) + for ac in conn.get_alarm_changes(self._id, auth_project)] class AlarmsController(rest.RestController): @@ -1011,17 +1059,38 @@ class AlarmsController(rest.RestController): remainder = remainder[:-1] return AlarmController(alarm_id), remainder + def _record_creation(self, conn, data, alarm_id, now): + if not cfg.CONF.alarm.record_history: + return + type = storage.models.AlarmChange.CREATION + detail = json.dumps(utils.stringify_timestamps(data)) + user_id = pecan.request.headers.get('X-User-Id') + project_id = pecan.request.headers.get('X-Project-Id') + try: + conn.record_alarm_change(dict(event_id=str(uuid.uuid4()), + alarm_id=alarm_id, + type=type, + detail=detail, + user_id=user_id, + project_id=project_id, + on_behalf_of=project_id, + timestamp=now)) + except NotImplementedError: + pass + @wsme.validate(Alarm) @wsme_pecan.wsexpose(Alarm, body=Alarm, status_code=201) def post(self, data): """Create a new alarm.""" conn = pecan.request.storage_conn + now = timeutils.utcnow() data.alarm_id = str(uuid.uuid4()) data.user_id = pecan.request.headers.get('X-User-Id') data.project_id = pecan.request.headers.get('X-Project-Id') data.state_timestamp = wsme.Unset - data.timestamp = timeutils.utcnow() + change = data.as_dict(storage.models.Alarm) + data.timestamp = now # make sure alarms are unique by name per project. alarms = list(conn.get_alarms(name=data.name, @@ -1041,6 +1110,7 @@ class AlarmsController(rest.RestController): raise wsme.exc.ClientSideError(error) alarm = conn.create_alarm(alarm_in) + self._record_creation(conn, change, alarm.alarm_id, now) return Alarm.from_db_model(alarm) @wsme_pecan.wsexpose([Alarm], [Query]) diff --git a/ceilometer/utils.py b/ceilometer/utils.py index 60a08e902..d5ca45f96 100644 --- a/ceilometer/utils.py +++ b/ceilometer/utils.py @@ -74,3 +74,10 @@ def sanitize_timestamp(timestamp): if not isinstance(timestamp, datetime.datetime): timestamp = timeutils.parse_isotime(timestamp) return timeutils.normalize_time(timestamp) + + +def stringify_timestamps(data): + """Stringify any datetimes in given dict.""" + isa_timestamp = lambda v: isinstance(v, datetime.datetime) + return dict((k, v.isoformat() if isa_timestamp(v) else v) + for (k, v) in data.iteritems()) diff --git a/etc/ceilometer/ceilometer.conf.sample b/etc/ceilometer/ceilometer.conf.sample index dbb853589..29313b928 100644 --- a/etc/ceilometer/ceilometer.conf.sample +++ b/etc/ceilometer/ceilometer.conf.sample @@ -608,6 +608,14 @@ #threshold_evaluation_interval=60 +# +# Options defined in ceilometer.api.controllers.v2 +# + +# Record alarm change events (boolean value) +#record_history=true + + [rpc_notifier2] # diff --git a/tests/api/v2/test_alarm_scenarios.py b/tests/api/v2/test_alarm_scenarios.py index 1a6597658..641f6b629 100644 --- a/tests/api/v2/test_alarm_scenarios.py +++ b/tests/api/v2/test_alarm_scenarios.py @@ -4,6 +4,7 @@ # # Author: Mehdi Abaakouk # Angus Salkeld +# Eoghan Glynn # # 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 @@ -19,10 +20,14 @@ '''Tests alarm operation ''' +import datetime +import json as jsonutils import logging import uuid import testscenarios +from oslo.config import cfg + from .base import FunctionalTest from ceilometer.storage.models import Alarm @@ -47,8 +52,8 @@ class TestAlarms(FunctionalTest, def setUp(self): super(TestAlarms, self).setUp() - self.auth_headers = {'X-User-Id': str(uuid.uuid1()), - 'X-Project-Id': str(uuid.uuid1())} + self.auth_headers = {'X-User-Id': str(uuid.uuid4()), + 'X-Project-Id': str(uuid.uuid4())} for alarm in [Alarm(name='name1', alarm_id='a', counter_name='meter.test', @@ -178,12 +183,140 @@ class TestAlarms(FunctionalTest, alarms = list(self.conn.get_alarms()) self.assertEqual(2, len(alarms)) - def test_get_alarm_history(self): + def _get_alarm(self, index): data = self.get_json('/alarms') - history = self.get_json('/alarms/%s/history' % data[0]['alarm_id']) + return data[index] + + def _get_alarm_history(self, alarm, auth_headers=None): + return self.get_json('/alarms/%s/history' % alarm['alarm_id'], + headers=auth_headers or self.auth_headers) + + def _update_alarm(self, alarm, data, auth_headers=None): + self.put_json('/alarms/%s' % alarm['alarm_id'], + params=data, + headers=auth_headers or self.auth_headers) + + def _assert_is_subset(self, expected, actual): + for k, v in expected.iteritems(): + self.assertEqual(v, actual.get(k), 'mismatched field: %s' % k) + self.assertTrue(actual['event_id'] is not None) + + def _assert_in_json(self, expected, actual): + for k, v in expected.iteritems(): + fragment = jsonutils.dumps({k: v})[1:-1] + self.assertTrue(fragment in actual, + '%s not in %s' % (fragment, actual)) + + def test_get_unrecorded_alarm_history(self): + cfg.CONF.set_override('record_history', False, group='alarm') + alarm = self._get_alarm(0) + history = self._get_alarm_history(alarm) + self.assertEqual([], history) + self._update_alarm(alarm, dict(name='renamed')) + history = self._get_alarm_history(alarm) self.assertEqual([], history) + def test_get_recorded_alarm_history_on_create(self): + new_alarm = dict(name='new_alarm', + counter_name='other_meter', + comparison_operator='le', + threshold=42.0, + statistic='max') + self.post_json('/alarms', params=new_alarm, status=200, + headers=self.auth_headers) + alarm = self._get_alarm(3) + history = self._get_alarm_history(alarm) + self.assertEqual(1, len(history)) + self._assert_is_subset(dict(alarm_id=alarm['alarm_id'], + on_behalf_of=alarm['project_id'], + project_id=alarm['project_id'], + type='creation', + user_id=alarm['user_id']), + history[0]) + self._assert_in_json(new_alarm, history[0]['detail']) + + def _do_test_get_recorded_alarm_history_on_update(self, + data, + type, + detail, + auth=None): + alarm = self._get_alarm(0) + history = self._get_alarm_history(alarm) + self.assertEqual([], history) + self._update_alarm(alarm, data, auth) + history = self._get_alarm_history(alarm) + self.assertEqual(1, len(history)) + project_id = auth['X-Project-Id'] if auth else alarm['project_id'] + user_id = auth['X-User-Id'] if auth else alarm['user_id'] + self._assert_is_subset(dict(alarm_id=alarm['alarm_id'], + detail=detail, + on_behalf_of=alarm['project_id'], + project_id=project_id, + type=type, + user_id=user_id), + history[0]) + + def test_get_recorded_alarm_history_rule_change(self): + now = datetime.datetime.utcnow().isoformat() + data = dict(name='renamed', timestamp=now) + detail = '{"timestamp": "%s", "name": "renamed"}' % now + self._do_test_get_recorded_alarm_history_on_update(data, + 'rule change', + detail) + + def test_get_recorded_alarm_history_state_transition(self): + data = dict(state='alarm') + detail = '{"state": "alarm"}' + self._do_test_get_recorded_alarm_history_on_update(data, + 'state transition', + detail) + + def test_get_recorded_alarm_history_rule_change_on_behalf_of(self): + data = dict(name='renamed') + detail = '{"name": "renamed"}' + auth = {'X-Roles': 'admin', + 'X-User-Id': str(uuid.uuid4()), + 'X-Project-Id': str(uuid.uuid4())} + self._do_test_get_recorded_alarm_history_on_update(data, + 'rule change', + detail, + auth) + + def test_get_recorded_alarm_history_segregation(self): + data = dict(name='renamed') + detail = '{"name": "renamed"}' + self._do_test_get_recorded_alarm_history_on_update(data, + 'rule change', + detail) + auth = {'X-Roles': 'member', + 'X-User-Id': str(uuid.uuid4()), + 'X-Project-Id': str(uuid.uuid4())} + history = self._get_alarm_history(self._get_alarm(0), auth) + self.assertEqual([], history) + + def test_get_recorded_alarm_history_preserved_after_deletion(self): + alarm = self._get_alarm(0) + history = self._get_alarm_history(alarm) + self.assertEqual([], history) + self._update_alarm(alarm, dict(name='renamed')) + alarm = self._get_alarm(0) + history = self._get_alarm_history(alarm) + self.assertEqual(1, len(history)) + self.delete('/alarms/%s' % alarm['alarm_id'], + headers=self.auth_headers, + status=200) + history = self._get_alarm_history(alarm) + self.assertEqual(2, len(history)) + self._assert_is_subset(dict(alarm_id=alarm['alarm_id'], + on_behalf_of=alarm['project_id'], + project_id=alarm['project_id'], + type='deletion', + user_id=alarm['user_id']), + history[0]) + self._assert_in_json(alarm, history[0]['detail']) + def test_get_nonexistent_alarm_history(self): - response = self.get_json('/alarms/%s/history' % 'foobar', - expect_errors=True) - self.assertEqual(response.status_int, 400) + # the existence of alarm history is independent of the + # continued existence of the alarm itself + history = self._get_alarm_history(dict(alarm_id='foobar')) + self.assertEqual([], history)