#
# Copyright 2012 New Dream Network, LLC (DreamHost)
# Copyright 2013 IBM Corp.
# Copyright 2013 eNovance <licensing@enovance.com>
# Copyright Ericsson AB 2013. All rights reserved
# Copyright 2014 Hewlett-Packard Company
# Copyright 2015 Huawei Technologies Co., Ltd.
#
# 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.
import datetime
import json
import uuid
import croniter
from oslo_config import cfg
from oslo_context import context
from oslo_utils import netutils
from oslo_utils import timeutils
import pecan
from pecan import rest
import pytz
import six
from stevedore import extension
import wsme
from wsme import types as wtypes
import wsmeext.pecan as wsme_pecan
import ceilometer
from ceilometer import alarm as ceilometer_alarm
from ceilometer.alarm.storage import models as alarm_models
from ceilometer.api.controllers.v2.alarm_rules import combination
from ceilometer.api.controllers.v2 import base
from ceilometer.api.controllers.v2 import utils as v2_utils
from ceilometer.api import rbac
from ceilometer.i18n import _
from ceilometer import messaging
from ceilometer.openstack.common import log
from ceilometer import utils
LOG = log.getLogger(__name__)
ALARM_API_OPTS = [
cfg.BoolOpt('record_history',
default=True,
help='Record alarm change events.'
),
cfg.IntOpt('user_alarm_quota',
default=None,
help='Maximum number of alarms defined for a user.'
),
cfg.IntOpt('project_alarm_quota',
default=None,
help='Maximum number of alarms defined for a project.'
),
]
cfg.CONF.register_opts(ALARM_API_OPTS, group='alarm')
state_kind = ["ok", "alarm", "insufficient data"]
state_kind_enum = wtypes.Enum(str, *state_kind)
severity_kind = ["low", "moderate", "critical"]
severity_kind_enum = wtypes.Enum(str, *severity_kind)
[docs]class OverQuota(base.ClientSideError):
def __init__(self, data):
d = {
'u': data.user_id,
'p': data.project_id
}
super(OverQuota, self).__init__(
_("Alarm quota exceeded for user %(u)s on project %(p)s") % d,
status_code=403)
def is_over_quota(conn, project_id, user_id):
"""Returns False if an alarm is within the set quotas, True otherwise.
:param conn: a backend connection object
:param project_id: the ID of the project setting the alarm
:param user_id: the ID of the user setting the alarm
"""
over_quota = False
# Start by checking for user quota
user_alarm_quota = cfg.CONF.alarm.user_alarm_quota
if user_alarm_quota is not None:
user_alarms = list(conn.get_alarms(user=user_id))
over_quota = len(user_alarms) >= user_alarm_quota
# If the user quota isn't reached, we check for the project quota
if not over_quota:
project_alarm_quota = cfg.CONF.alarm.project_alarm_quota
if project_alarm_quota is not None:
project_alarms = list(conn.get_alarms(project=project_id))
over_quota = len(project_alarms) >= project_alarm_quota
return over_quota
[docs]class CronType(wtypes.UserType):
"""A user type that represents a cron format."""
basetype = six.string_types
name = 'cron'
@staticmethod
[docs] def validate(value):
# raises ValueError if invalid
croniter.croniter(value)
return value
[docs]class AlarmTimeConstraint(base.Base):
"""Representation of a time constraint on an alarm."""
name = wsme.wsattr(wtypes.text, mandatory=True)
"The name of the constraint"
_description = None # provide a default
[docs] def get_description(self):
if not self._description:
return ('Time constraint at %s lasting for %s seconds'
% (self.start, self.duration))
return self._description
[docs] def set_description(self, value):
self._description = value
description = wsme.wsproperty(wtypes.text, get_description,
set_description)
"The description of the constraint"
start = wsme.wsattr(CronType(), mandatory=True)
"Start point of the time constraint, in cron format"
duration = wsme.wsattr(wtypes.IntegerType(minimum=0), mandatory=True)
"How long the constraint should last, in seconds"
timezone = wsme.wsattr(wtypes.text, default="")
"Timezone of the constraint"
[docs] def as_dict(self):
return self.as_dict_from_keys(['name', 'description', 'start',
'duration', 'timezone'])
@staticmethod
[docs] def validate(tc):
if tc.timezone:
try:
pytz.timezone(tc.timezone)
except Exception:
raise base.ClientSideError(_("Timezone %s is not valid")
% tc.timezone)
return tc
@classmethod
[docs] def sample(cls):
return cls(name='SampleConstraint',
description='nightly build every night at 23h for 3 hours',
start='0 23 * * *',
duration=10800,
timezone='Europe/Ljubljana')
ALARMS_RULES = extension.ExtensionManager("ceilometer.alarm.rule")
LOG.debug("alarm rules plugin loaded: %s" % ",".join(ALARMS_RULES.names()))
[docs]class Alarm(base.Base):
"""Representation of an alarm.
.. note::
combination_rule and threshold_rule are mutually exclusive. The *type*
of the alarm should be set to *threshold* or *combination* and the
appropriate rule should be filled.
"""
alarm_id = wtypes.text
"The UUID of the alarm"
name = wsme.wsattr(wtypes.text, mandatory=True)
"The name for the alarm"
_description = None # provide a default
[docs] def get_description(self):
rule = getattr(self, '%s_rule' % self.type, None)
if not self._description:
if hasattr(rule, 'default_description'):
return six.text_type(rule.default_description)
return "%s alarm rule" % self.type
return self._description
[docs] def set_description(self, value):
self._description = value
description = wsme.wsproperty(wtypes.text, get_description,
set_description)
"The description of the alarm"
enabled = wsme.wsattr(bool, default=True)
"This alarm is enabled?"
ok_actions = wsme.wsattr([wtypes.text], default=[])
"The actions to do when alarm state change to ok"
alarm_actions = wsme.wsattr([wtypes.text], default=[])
"The actions to do when alarm state change to alarm"
insufficient_data_actions = wsme.wsattr([wtypes.text], default=[])
"The actions to do when alarm state change to insufficient data"
repeat_actions = wsme.wsattr(bool, default=False)
"The actions should be re-triggered on each evaluation cycle"
type = base.AdvEnum('type', str, *ALARMS_RULES.names(),
mandatory=True)
"Explicit type specifier to select which rule to follow below."
time_constraints = wtypes.wsattr([AlarmTimeConstraint], default=[])
"""Describe time constraints for the alarm"""
# These settings are ignored in the PUT or POST operations, but are
# filled in for GET
project_id = wtypes.text
"The ID of the project or tenant that owns the alarm"
user_id = wtypes.text
"The ID of the user who created the alarm"
timestamp = datetime.datetime
"The date of the last alarm definition update"
state = base.AdvEnum('state', str, *state_kind,
default='insufficient data')
"The state offset the alarm"
state_timestamp = datetime.datetime
"The date of the last alarm state changed"
severity = base.AdvEnum('severity', str, *severity_kind,
default='low')
"The severity of the alarm"
def __init__(self, rule=None, time_constraints=None, **kwargs):
super(Alarm, self).__init__(**kwargs)
if rule:
setattr(self, '%s_rule' % self.type,
ALARMS_RULES[self.type].plugin(**rule))
if time_constraints:
self.time_constraints = [AlarmTimeConstraint(**tc)
for tc in time_constraints]
@staticmethod
[docs] def validate(alarm):
Alarm.check_rule(alarm)
Alarm.check_alarm_actions(alarm)
ALARMS_RULES[alarm.type].plugin.validate_alarm(alarm)
if alarm.time_constraints:
tc_names = [tc.name for tc in alarm.time_constraints]
if len(tc_names) > len(set(tc_names)):
error = _("Time constraint names must be "
"unique for a given alarm.")
raise base.ClientSideError(error)
return alarm
@staticmethod
[docs] def check_rule(alarm):
rule = '%s_rule' % alarm.type
if getattr(alarm, rule) in (wtypes.Unset, None):
error = _("%(rule)s must be set for %(type)s"
" type alarm") % {"rule": rule, "type": alarm.type}
raise base.ClientSideError(error)
rule_set = None
for ext in ALARMS_RULES:
name = "%s_rule" % ext.name
if getattr(alarm, name):
if rule_set is None:
rule_set = name
else:
error = _("%(rule1)s and %(rule2)s cannot be set at the "
"same time") % {'rule1': rule_set, 'rule2': name}
raise base.ClientSideError(error)
@staticmethod
[docs] def check_alarm_actions(alarm):
actions_schema = ceilometer_alarm.NOTIFIER_SCHEMAS
for state in state_kind:
actions_name = state.replace(" ", "_") + '_actions'
actions = getattr(alarm, actions_name)
if not actions:
continue
for action in actions:
try:
url = netutils.urlsplit(action)
except Exception:
error = _("Unable to parse action %s") % action
raise base.ClientSideError(error)
if url.scheme not in actions_schema:
error = _("Unsupported action %s") % action
raise base.ClientSideError(error)
@classmethod
[docs] def sample(cls):
return cls(alarm_id=None,
name="SwiftObjectAlarm",
description="An alarm",
type='combination',
time_constraints=[AlarmTimeConstraint.sample().as_dict()],
user_id="c96c887c216949acbdfbd8b494863567",
project_id="c96c887c216949acbdfbd8b494863567",
enabled=True,
timestamp=datetime.datetime.utcnow(),
state="ok",
severity="moderate",
state_timestamp=datetime.datetime.utcnow(),
ok_actions=["http://site:8000/ok"],
alarm_actions=["http://site:8000/alarm"],
insufficient_data_actions=["http://site:8000/nodata"],
repeat_actions=False,
combination_rule=combination.AlarmCombinationRule.sample(),
)
[docs] def as_dict(self, db_model):
d = super(Alarm, self).as_dict(db_model)
for k in d:
if k.endswith('_rule'):
del d[k]
d['rule'] = getattr(self, "%s_rule" % self.type).as_dict()
if self.time_constraints:
d['time_constraints'] = [tc.as_dict()
for tc in self.time_constraints]
return d
Alarm.add_attributes(**{"%s_rule" % ext.name: ext.plugin
for ext in ALARMS_RULES})
[docs]class AlarmChange(base.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"
type = wtypes.Enum(str,
'creation',
'rule change',
'state transition',
'deletion')
"The type of change"
detail = wtypes.text
"JSON fragment describing change"
project_id = wtypes.text
"The project ID of the initiating identity"
user_id = wtypes.text
"The user ID of the initiating identity"
on_behalf_of = wtypes.text
"The tenant on behalf of which the change is being made"
timestamp = datetime.datetime
"The time/date of the alarm change"
@classmethod
[docs] def sample(cls):
return cls(alarm_id='e8ff32f772a44a478182c3fe1f7cad6a',
type='rule change',
detail='{"threshold": 42.0, "evaluation_periods": 4}',
user_id="3e5d11fda79448ac99ccefb20be187ca",
project_id="b6f16144010811e387e4de429e99ee8c",
on_behalf_of="92159030020611e3b26dde429e99ee8c",
timestamp=datetime.datetime.utcnow(),
)
def _send_notification(event, payload):
notification = event.replace(" ", "_")
notification = "alarm.%s" % notification
transport = messaging.get_transport()
notifier = messaging.get_notifier(transport, publisher_id="ceilometer.api")
# FIXME(sileht): perhaps we need to copy some infos from the
# pecan request headers like nova does
notifier.info(context.RequestContext(), notification, payload)
[docs]class AlarmController(rest.RestController):
"""Manages operations on a single alarm."""
_custom_actions = {
'history': ['GET'],
'state': ['PUT', 'GET'],
}
def __init__(self, alarm_id):
pecan.request.context['alarm_id'] = alarm_id
self._id = alarm_id
def _alarm(self):
self.conn = pecan.request.alarm_storage_conn
auth_project = rbac.get_limited_to_project(pecan.request.headers)
alarms = list(self.conn.get_alarms(alarm_id=self._id,
project=auth_project))
if not alarms:
raise base.AlarmNotFound(alarm=self._id, auth_project=auth_project)
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 alarm_models.AlarmChange.RULE_CHANGE
scrubbed_data = utils.stringify_timestamps(data)
detail = json.dumps(scrubbed_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
payload = 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)
try:
self.conn.record_alarm_change(payload)
except ceilometer.NotImplementedError:
pass
# Revert to the pre-json'ed details ...
payload['detail'] = scrubbed_data
_send_notification(type, payload)
@wsme_pecan.wsexpose(Alarm)
[docs] def get(self):
"""Return this alarm."""
rbac.enforce('get_alarm', pecan.request)
return Alarm.from_db_model(self._alarm())
@wsme_pecan.wsexpose(Alarm, body=Alarm)
[docs] def put(self, data):
"""Modify this alarm.
:param data: an alarm within the request body.
"""
rbac.enforce('change_alarm', pecan.request)
# Ensure alarm exists
alarm_in = self._alarm()
now = timeutils.utcnow()
data.alarm_id = self._id
user, project = rbac.get_limited_to(pecan.request.headers)
if user:
data.user_id = user
elif data.user_id == wtypes.Unset:
data.user_id = alarm_in.user_id
if project:
data.project_id = project
elif data.project_id == wtypes.Unset:
data.project_id = alarm_in.project_id
data.timestamp = now
if alarm_in.state != data.state:
data.state_timestamp = now
else:
data.state_timestamp = alarm_in.state_timestamp
# make sure alarms are unique by name per project.
if alarm_in.name != data.name:
alarms = list(self.conn.get_alarms(name=data.name,
project=data.project_id))
if alarms:
raise base.ClientSideError(
_("Alarm with name=%s exists") % data.name,
status_code=409)
ALARMS_RULES[data.type].plugin.update_hook(data)
old_alarm = Alarm.from_db_model(alarm_in).as_dict(alarm_models.Alarm)
updated_alarm = data.as_dict(alarm_models.Alarm)
try:
alarm_in = alarm_models.Alarm(**updated_alarm)
except Exception:
LOG.exception(_("Error while putting alarm: %s") % updated_alarm)
raise base.ClientSideError(_("Alarm incorrect"))
alarm = self.conn.update_alarm(alarm_in)
change = dict((k, v) for k, v in updated_alarm.items()
if v != old_alarm[k] and k not in
['timestamp', 'state_timestamp'])
self._record_change(change, now, on_behalf_of=alarm.project_id)
return Alarm.from_db_model(alarm)
@wsme_pecan.wsexpose(None, status_code=204)
[docs] def delete(self):
"""Delete this alarm."""
rbac.enforce('delete_alarm', pecan.request)
# ensure alarm exists before deleting
alarm = self._alarm()
self.conn.delete_alarm(alarm.alarm_id)
change = Alarm.from_db_model(alarm).as_dict(alarm_models.Alarm)
self._record_change(change,
timeutils.utcnow(),
type=alarm_models.AlarmChange.DELETION)
# TODO(eglynn): add pagination marker to signature once overall
# API support for pagination is finalized
@wsme_pecan.wsexpose([AlarmChange], [base.Query])
[docs] def history(self, q=None):
"""Assembles the alarm history requested.
:param q: Filter rules for the changes to be described.
"""
rbac.enforce('alarm_history', pecan.request)
q = q or []
# 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 = rbac.get_limited_to_project(pecan.request.headers)
conn = pecan.request.alarm_storage_conn
kwargs = v2_utils.query_to_kwargs(
q, conn.get_alarm_changes, ['on_behalf_of', 'alarm_id'])
return [AlarmChange.from_db_model(ac)
for ac in conn.get_alarm_changes(self._id, auth_project,
**kwargs)]
@wsme.validate(state_kind_enum)
@wsme_pecan.wsexpose(state_kind_enum, body=state_kind_enum)
[docs] def put_state(self, state):
"""Set the state of this alarm.
:param state: an alarm state within the request body.
"""
rbac.enforce('change_alarm_state', pecan.request)
# note(sileht): body are not validated by wsme
# Workaround for https://bugs.launchpad.net/wsme/+bug/1227229
if state not in state_kind:
raise base.ClientSideError(_("state invalid"))
now = timeutils.utcnow()
alarm = self._alarm()
alarm.state = state
alarm.state_timestamp = now
alarm = self.conn.update_alarm(alarm)
change = {'state': alarm.state}
self._record_change(change, now, on_behalf_of=alarm.project_id,
type=alarm_models.AlarmChange.STATE_TRANSITION)
return alarm.state
@wsme_pecan.wsexpose(state_kind_enum)
[docs] def get_state(self):
"""Get the state of this alarm."""
rbac.enforce('get_alarm_state', pecan.request)
alarm = self._alarm()
return alarm.state
[docs]class AlarmsController(rest.RestController):
"""Manages operations on the alarms collection."""
@pecan.expose()
def _lookup(self, alarm_id, *remainder):
return AlarmController(alarm_id), remainder
@staticmethod
def _record_creation(conn, data, alarm_id, now):
if not cfg.CONF.alarm.record_history:
return
type = alarm_models.AlarmChange.CREATION
scrubbed_data = utils.stringify_timestamps(data)
detail = json.dumps(scrubbed_data)
user_id = pecan.request.headers.get('X-User-Id')
project_id = pecan.request.headers.get('X-Project-Id')
payload = 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)
try:
conn.record_alarm_change(payload)
except ceilometer.NotImplementedError:
pass
# Revert to the pre-json'ed details ...
payload['detail'] = scrubbed_data
_send_notification(type, payload)
@wsme_pecan.wsexpose(Alarm, body=Alarm, status_code=201)
[docs] def post(self, data):
"""Create a new alarm.
:param data: an alarm within the request body.
"""
rbac.enforce('create_alarm', pecan.request)
conn = pecan.request.alarm_storage_conn
now = timeutils.utcnow()
data.alarm_id = str(uuid.uuid4())
user_limit, project_limit = rbac.get_limited_to(pecan.request.headers)
def _set_ownership(aspect, owner_limitation, header):
attr = '%s_id' % aspect
requested_owner = getattr(data, attr)
explicit_owner = requested_owner != wtypes.Unset
caller = pecan.request.headers.get(header)
if (owner_limitation and explicit_owner
and requested_owner != caller):
raise base.ProjectNotAuthorized(requested_owner, aspect)
actual_owner = (owner_limitation or
requested_owner if explicit_owner else caller)
setattr(data, attr, actual_owner)
_set_ownership('user', user_limit, 'X-User-Id')
_set_ownership('project', project_limit, 'X-Project-Id')
# Check if there's room for one more alarm
if is_over_quota(conn, data.project_id, data.user_id):
raise OverQuota(data)
data.timestamp = now
data.state_timestamp = now
ALARMS_RULES[data.type].plugin.create_hook(data)
change = data.as_dict(alarm_models.Alarm)
# make sure alarms are unique by name per project.
alarms = list(conn.get_alarms(name=data.name,
project=data.project_id))
if alarms:
raise base.ClientSideError(
_("Alarm with name='%s' exists") % data.name,
status_code=409)
try:
alarm_in = alarm_models.Alarm(**change)
except Exception:
LOG.exception(_("Error while posting alarm: %s") % change)
raise base.ClientSideError(_("Alarm incorrect"))
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], [base.Query])
[docs] def get_all(self, q=None):
"""Return all alarms, based on the query provided.
:param q: Filter rules for the alarms to be returned.
"""
rbac.enforce('get_alarms', pecan.request)
q = q or []
# Timestamp is not supported field for Simple Alarm queries
kwargs = v2_utils.query_to_kwargs(
q, pecan.request.alarm_storage_conn.get_alarms,
allow_timestamps=False)
return [Alarm.from_db_model(m)
for m in pecan.request.alarm_storage_conn.get_alarms(**kwargs)]