Add notification base classes and docs
This adds base classes and documentation for creating notifications. Partial-Bug: 1526408 Change-Id: Ib1b1fa819e8ff0b93afacd0b3de6e6762168e230
This commit is contained in:
parent
d0e49e1b41
commit
2cc70ea93a
176
doc/source/dev/notifications.rst
Normal file
176
doc/source/dev/notifications.rst
Normal file
@ -0,0 +1,176 @@
|
||||
.. _notifications:
|
||||
|
||||
=============
|
||||
Notifications
|
||||
=============
|
||||
|
||||
Ironic notifications are events intended for consumption by external services
|
||||
like a billing or usage system, a monitoring data store, or other OpenStack
|
||||
services. Notifications are sent to these services over a message bus by
|
||||
oslo.messaging's Notifier class [1]_. The consumer sees the notification as a
|
||||
JSON object structured in the following way as defined by oslo.messaging::
|
||||
|
||||
{
|
||||
"priority": <string, defined by the sender>,
|
||||
"event_type": <string, defined by the sender>,
|
||||
"timestamp": <string, the isotime of when the notification emitted>,
|
||||
"publisher_id": <string, defined by the sender>,
|
||||
"message_id": <uuid, generated by oslo>,
|
||||
"payload": <json serialized dict, defined by the sender>
|
||||
}
|
||||
|
||||
Versioned notifications in ironic
|
||||
---------------------------------
|
||||
To make it easier for consumers of ironic's notifications to use predictably,
|
||||
ironic defines each notification and its payload as oslo versioned objects
|
||||
[2]_.
|
||||
|
||||
An increase in the minor version of the payload will indicate that only
|
||||
new fields have been added since the last version, so the consumer can still
|
||||
use the notification as it did previously. An increase in the major version of
|
||||
the payload indicates that the consumer can no longer parse the notification as
|
||||
it did previously, indicating that a field was removed or the type of the
|
||||
payload field changed.
|
||||
|
||||
Ironic exposes a configuration option in the ``DEFAULT`` section called
|
||||
``notification_level`` that indicates the minimum level for which
|
||||
notifications will be emitted. This option is not defined by default, which
|
||||
indicates that no notifications will be sent by ironic. Notification levels
|
||||
may be "debug", "info", "warning", "error", or "critical", and each
|
||||
level follows the OpenStack logging guidelines [3]_. If it's desired that
|
||||
ironic emit all notifications, the config option should be set to "debug", for
|
||||
example. If only "warning", "error", and "critical" notifications are needed,
|
||||
the config option should be set to "warning". This level gets exposed in the
|
||||
notification on the wire as the "priority" field.
|
||||
|
||||
All ironic versioned notifications will be sent on the message bus via the
|
||||
``ironic_versioned_notifications`` topic.
|
||||
|
||||
Ironic also has a set of base classes that assist in clearly defining the
|
||||
notification itself, the payload, and the other fields not auto-generated by
|
||||
oslo (level, event_type and publisher_id). Below describes how to use these
|
||||
base classes to add a new notification to ironic.
|
||||
|
||||
Adding a new notification to ironic
|
||||
-----------------------------------
|
||||
To add a new notification to ironic, new versioned notification classes should
|
||||
be created by subclassing the NotificationBase class to define the notification
|
||||
itself and the NotificationPayloadBase class to define which fields the new
|
||||
notification will contain inside its payload. You may also define a schema to
|
||||
allow the payload to be automatically populated by the fields of an ironic
|
||||
object. Here's an example::
|
||||
|
||||
# The ironic object whose fields you want to use in your schema
|
||||
@base.IronicObjectRegistry.register
|
||||
class ExampleObject(base.IronicObject):
|
||||
# Version 1.0: Initial version
|
||||
VERSION = '1.0'
|
||||
fields = {
|
||||
'id': fields.IntegerField(),
|
||||
'uuid': fields.UUIDField(),
|
||||
'a_useful_field': fields.StringField(),
|
||||
'not_useful_field': fields.StringField()
|
||||
}
|
||||
|
||||
# A class for your new notification
|
||||
@base.IronicObjectRegistry.register
|
||||
class ExampleNotification(notification.NotificationBase):
|
||||
# Version 1.0: Initial version
|
||||
VERSION = '1.0'
|
||||
fields = {
|
||||
'payload': fields.ObjectField('ExampleNotifPayload')
|
||||
}
|
||||
|
||||
# A class for your notification's payload
|
||||
@base.IronicObjectRegistry.register
|
||||
class ExampleNotifPayload(notification.NotificationPayloadBase):
|
||||
# Schemas are optional. They just allow you to reuse other objects'
|
||||
# fields by passing in that object and calling populate_schema with
|
||||
# a kwarg set to the other object.
|
||||
SCHEMA = {
|
||||
'a_useful_field': ('example_obj', 'a_useful_field')
|
||||
}
|
||||
|
||||
# Version 1.0: Initial version
|
||||
VERSION = '1.0'
|
||||
|
||||
fields = {
|
||||
'a_useful_field': fields.StringField(),
|
||||
'an_extra_field': fields.StringField(nullable=True)
|
||||
}
|
||||
|
||||
SCHEMA defines how to populate the payload fields. It's an optional
|
||||
attribute that subclasses may use to easily populate notifications with
|
||||
data from other objects.
|
||||
|
||||
It is a dictionary where every key value pair has the following format::
|
||||
|
||||
<payload_field_name>: (<data_source_name>,
|
||||
<field_of_the_data_source>)
|
||||
|
||||
The ``<payload_field_name>`` is the name where the data will be stored in the
|
||||
payload object; this field has to be defined as a field of the payload.
|
||||
The ``<data_source_name>`` shall refer to name of the parameter passed as
|
||||
kwarg to the payload's ``populate_schema()`` call and this object will be
|
||||
used as the source of the data. The ``<field_of_the_data_source>`` shall be
|
||||
a valid field of the passed argument.
|
||||
|
||||
The SCHEMA needs to be applied with the ``populate_schema()`` call before the
|
||||
notification can be emitted.
|
||||
|
||||
The value of the ``payload.<payload_field_name>`` field will be set by the
|
||||
``<data_source_name>.<field_of_the_data_source>`` field. The
|
||||
``<data_source_name>`` will not be part of the payload object internal or
|
||||
external representation.
|
||||
|
||||
Payload fields that are not set by the SCHEMA can be filled in the same
|
||||
way as in any versioned object.
|
||||
|
||||
Then, to create a payload, you would do something like the following. Note
|
||||
that if you choose to define a schema in the SCHEMA class variable, you must
|
||||
populate the schema by calling ``populate_schema(example_obj=my_example_obj)``
|
||||
before emitting the notification is allowed::
|
||||
|
||||
my_example_obj = ExampleObject(id=1,
|
||||
a_useful_field='important',
|
||||
not_useful_field='blah')
|
||||
|
||||
# an_extra_field is optional since it's not a part of the SCHEMA and is a
|
||||
# nullable field in the class fields
|
||||
my_notify_payload = ExampleNotifyPayload(an_extra_field='hello')
|
||||
# populate the schema with the ExampleObject fields
|
||||
my_notify_payload.populate_schema(example_obj=my_example_obj)
|
||||
|
||||
You then create the notification with the oslo required fields (event_type,
|
||||
publisher_id, and level, all sender fields needed by oslo that are defined
|
||||
in the ironic notification base classes) and emit it::
|
||||
|
||||
notify = ExampleNotification(
|
||||
event_type=notification.EventType(object='example_obj',
|
||||
action='do_something', phase='start'),
|
||||
publisher=notification.NotificationPublisher(service='conductor',
|
||||
host='cond-hostname01'),
|
||||
level=fields.NotificationLevel.DEBUG,
|
||||
payload=my_notify_payload)
|
||||
notify.emit(context)
|
||||
|
||||
This will send the following notification over the message bus::
|
||||
|
||||
{
|
||||
"priority": "debug",
|
||||
"payload":{
|
||||
"ironic_object.namespace":"ironic",
|
||||
"ironic_object.name":"ExampleNotifyPayload",
|
||||
"ironic_object.version":"1.0",
|
||||
"ironic_object.data":{
|
||||
"a_useful_field":"important",
|
||||
"an_extra_field":"hello"
|
||||
}
|
||||
},
|
||||
"event_type":"baremetal.example_obj.do_something.start",
|
||||
"publisher_id":"conductor.cond-hostname01"
|
||||
}
|
||||
|
||||
.. [1] http://docs.openstack.org/developer/oslo.messaging/notifier.html
|
||||
.. [2] http://docs.openstack.org/developer/oslo.versionedobjects
|
||||
.. [3] https://wiki.openstack.org/wiki/LoggingStandards#Log_level_definitions
|
@ -76,6 +76,7 @@ primarily for developers.
|
||||
|
||||
Ironic System Architecture <dev/architecture>
|
||||
Provisioning State Machine <dev/states>
|
||||
Notifications <dev/notifications>
|
||||
|
||||
|
||||
Writing Drivers
|
||||
|
@ -105,6 +105,12 @@
|
||||
# (string value)
|
||||
#my_ip = 127.0.0.1
|
||||
|
||||
# Specifies the minimum level for which to send notifications.
|
||||
# If not set, no notifications will be sent. The default is
|
||||
# for this option to be unset. (string value)
|
||||
# Allowed values: debug, info, warning, error, critical
|
||||
#notification_level = <None>
|
||||
|
||||
# Directory where the ironic python module is installed.
|
||||
# (string value)
|
||||
#pybasedir = /usr/lib/python/site-packages/ironic/ironic
|
||||
@ -1481,6 +1487,8 @@
|
||||
#remote_image_user_domain =
|
||||
|
||||
# Port to be used for iRMC operations (port value)
|
||||
# Minimum value: 0
|
||||
# Maximum value: 65535
|
||||
# Allowed values: 443, 80
|
||||
#port = 443
|
||||
|
||||
|
@ -611,3 +611,19 @@ class NetworkError(IronicException):
|
||||
class IncompleteLookup(Invalid):
|
||||
_msg_fmt = _("At least one of 'addresses' and 'node_uuid' parameters "
|
||||
"is required")
|
||||
|
||||
|
||||
class NotificationSchemaObjectError(IronicException):
|
||||
_msg_fmt = _("Expected object %(obj)s when populating notification payload"
|
||||
" but got object %(source)s")
|
||||
|
||||
|
||||
class NotificationSchemaKeyError(IronicException):
|
||||
_msg_fmt = _("Object %(obj)s doesn't have the field \"%(field)s\" "
|
||||
"required for populating notification schema key "
|
||||
"\"%(key)s\"")
|
||||
|
||||
|
||||
class NotificationPayloadError(IronicException):
|
||||
_msg_fmt = _("Payload not populated when trying to send notification "
|
||||
"\"%(class_name)s\"")
|
||||
|
@ -21,9 +21,11 @@ from ironic.common import exception
|
||||
|
||||
|
||||
CONF = cfg.CONF
|
||||
|
||||
TRANSPORT = None
|
||||
NOTIFICATION_TRANSPORT = None
|
||||
NOTIFIER = None
|
||||
SENSORS_NOTIFIER = None
|
||||
VERSIONED_NOTIFIER = None
|
||||
|
||||
ALLOWED_EXMODS = [
|
||||
exception.__name__,
|
||||
@ -38,7 +40,8 @@ TRANSPORT_ALIASES = {
|
||||
|
||||
|
||||
def init(conf):
|
||||
global TRANSPORT, NOTIFICATION_TRANSPORT, NOTIFIER
|
||||
global TRANSPORT, NOTIFICATION_TRANSPORT
|
||||
global SENSORS_NOTIFIER, VERSIONED_NOTIFIER
|
||||
exmods = get_allowed_exmods()
|
||||
TRANSPORT = messaging.get_transport(conf,
|
||||
allowed_remote_exmods=exmods,
|
||||
@ -47,19 +50,32 @@ def init(conf):
|
||||
conf,
|
||||
allowed_remote_exmods=exmods,
|
||||
aliases=TRANSPORT_ALIASES)
|
||||
|
||||
serializer = RequestContextSerializer(messaging.JsonPayloadSerializer())
|
||||
NOTIFIER = messaging.Notifier(NOTIFICATION_TRANSPORT,
|
||||
serializer=serializer)
|
||||
SENSORS_NOTIFIER = messaging.Notifier(NOTIFICATION_TRANSPORT,
|
||||
serializer=serializer)
|
||||
if conf.notification_level is None:
|
||||
VERSIONED_NOTIFIER = messaging.Notifier(NOTIFICATION_TRANSPORT,
|
||||
serializer=serializer,
|
||||
driver='noop')
|
||||
else:
|
||||
VERSIONED_NOTIFIER = messaging.Notifier(NOTIFICATION_TRANSPORT,
|
||||
serializer=serializer,
|
||||
topics=['ironic_versioned_'
|
||||
'notifications'])
|
||||
|
||||
|
||||
def cleanup():
|
||||
global TRANSPORT, NOTIFICATION_TRANSPORT, NOTIFIER
|
||||
global TRANSPORT, NOTIFICATION_TRANSPORT
|
||||
global SENSORS_NOTIFIER, VERSIONED_NOTIFIER
|
||||
assert TRANSPORT is not None
|
||||
assert NOTIFICATION_TRANSPORT is not None
|
||||
assert NOTIFIER is not None
|
||||
assert SENSORS_NOTIFIER is not None
|
||||
assert VERSIONED_NOTIFIER is not None
|
||||
TRANSPORT.cleanup()
|
||||
NOTIFICATION_TRANSPORT.cleanup()
|
||||
TRANSPORT = NOTIFICATION_TRANSPORT = NOTIFIER = None
|
||||
TRANSPORT = NOTIFICATION_TRANSPORT = None
|
||||
SENSORS_NOTIFIER = VERSIONED_NOTIFIER = None
|
||||
|
||||
|
||||
def set_defaults(control_exchange):
|
||||
@ -123,8 +139,14 @@ def get_server(target, endpoints, serializer=None):
|
||||
serializer=serializer)
|
||||
|
||||
|
||||
def get_notifier(service=None, host=None, publisher_id=None):
|
||||
assert NOTIFIER is not None
|
||||
def get_sensors_notifier(service=None, host=None, publisher_id=None):
|
||||
assert SENSORS_NOTIFIER is not None
|
||||
if not publisher_id:
|
||||
publisher_id = "%s.%s" % (service, host or CONF.host)
|
||||
return NOTIFIER.prepare(publisher_id=publisher_id)
|
||||
return SENSORS_NOTIFIER.prepare(publisher_id=publisher_id)
|
||||
|
||||
|
||||
def get_versioned_notifier(publisher_id=None):
|
||||
assert VERSIONED_NOTIFIER is not None
|
||||
assert publisher_id is not None
|
||||
return VERSIONED_NOTIFIER.prepare(publisher_id=publisher_id)
|
||||
|
@ -51,7 +51,7 @@ class BaseConductorManager(object):
|
||||
host = CONF.host
|
||||
self.host = host
|
||||
self.topic = topic
|
||||
self.notifier = rpc.get_notifier()
|
||||
self.sensors_notifier = rpc.get_sensors_notifier()
|
||||
self._started = False
|
||||
|
||||
def init_host(self, admin_context=None):
|
||||
|
@ -1893,8 +1893,8 @@ class ConductorManager(base_manager.BaseConductorManager):
|
||||
message['payload'] = (
|
||||
self._filter_out_unsupported_types(sensors_data))
|
||||
if message['payload']:
|
||||
self.notifier.info(context, "hardware.ipmi.metrics",
|
||||
message)
|
||||
self.sensors_notifier.info(
|
||||
context, "hardware.ipmi.metrics", message)
|
||||
finally:
|
||||
# Yield on every iteration
|
||||
eventlet.sleep(0)
|
||||
|
@ -151,6 +151,17 @@ netconf_opts = [
|
||||
'"127.0.0.1".')),
|
||||
]
|
||||
|
||||
# NOTE(mariojv) By default, accessing this option when it's unset will return
|
||||
# None, indicating no notifications will be sent. oslo.config returns None by
|
||||
# default for options without set defaults that aren't required.
|
||||
notification_opts = [
|
||||
cfg.StrOpt('notification_level',
|
||||
choices=['debug', 'info', 'warning', 'error', 'critical'],
|
||||
help=_('Specifies the minimum level for which to send '
|
||||
'notifications. If not set, no notifications will '
|
||||
'be sent. The default is for this option to be unset.'))
|
||||
]
|
||||
|
||||
path_opts = [
|
||||
cfg.StrOpt('pybasedir',
|
||||
default=os.path.abspath(os.path.join(os.path.dirname(__file__),
|
||||
@ -198,6 +209,7 @@ def register_opts(conf):
|
||||
conf.register_opts(image_opts)
|
||||
conf.register_opts(img_cache_opts)
|
||||
conf.register_opts(netconf_opts)
|
||||
conf.register_opts(notification_opts)
|
||||
conf.register_opts(path_opts)
|
||||
conf.register_opts(service_opts)
|
||||
conf.register_opts(utils_opts)
|
||||
|
@ -23,6 +23,7 @@ _default_opt_lists = [
|
||||
ironic.conf.default.image_opts,
|
||||
ironic.conf.default.img_cache_opts,
|
||||
ironic.conf.default.netconf_opts,
|
||||
ironic.conf.default.notification_opts,
|
||||
ironic.conf.default.path_opts,
|
||||
ironic.conf.default.service_opts,
|
||||
ironic.conf.default.utils_opts,
|
||||
|
@ -78,6 +78,10 @@ class ListOfStringsField(object_fields.ListOfStringsField):
|
||||
pass
|
||||
|
||||
|
||||
class ObjectField(object_fields.ObjectField):
|
||||
pass
|
||||
|
||||
|
||||
class FlexibleDict(object_fields.FieldType):
|
||||
@staticmethod
|
||||
def coerce(obj, attr, value):
|
||||
@ -98,6 +102,24 @@ class FlexibleDictField(object_fields.AutoTypedField):
|
||||
super(FlexibleDictField, self)._null(obj, attr)
|
||||
|
||||
|
||||
class NotificationLevel(object_fields.Enum):
|
||||
DEBUG = 'debug'
|
||||
INFO = 'info'
|
||||
WARNING = 'warning'
|
||||
ERROR = 'error'
|
||||
CRITICAL = 'critical'
|
||||
|
||||
ALL = (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
|
||||
def __init__(self):
|
||||
super(NotificationLevel, self).__init__(
|
||||
valid_values=NotificationLevel.ALL)
|
||||
|
||||
|
||||
class NotificationLevelField(object_fields.BaseEnumField):
|
||||
AUTO_TYPE = NotificationLevel()
|
||||
|
||||
|
||||
class MACAddress(object_fields.FieldType):
|
||||
@staticmethod
|
||||
def coerce(obj, attr, value):
|
||||
|
158
ironic/objects/notification.py
Normal file
158
ironic/objects/notification.py
Normal file
@ -0,0 +1,158 @@
|
||||
# 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 ironic.common import exception
|
||||
from ironic.common import rpc
|
||||
from ironic.objects import base
|
||||
from ironic.objects import fields
|
||||
|
||||
|
||||
CONF = cfg.CONF
|
||||
|
||||
|
||||
# Definition of notification levels in increasing order of severity
|
||||
NOTIFY_LEVELS = {
|
||||
fields.NotificationLevel.DEBUG: 0,
|
||||
fields.NotificationLevel.INFO: 1,
|
||||
fields.NotificationLevel.WARNING: 2,
|
||||
fields.NotificationLevel.ERROR: 3,
|
||||
fields.NotificationLevel.CRITICAL: 4
|
||||
}
|
||||
|
||||
|
||||
@base.IronicObjectRegistry.register
|
||||
class EventType(base.IronicObject):
|
||||
"""Defines the event_type to be sent on the wire.
|
||||
|
||||
An EventType must specify the object being acted on, a string describing
|
||||
the action being taken on the notification, and the phase of the action,
|
||||
if applicable.
|
||||
"""
|
||||
# Version 1.0: Initial version
|
||||
VERSION = '1.0'
|
||||
|
||||
fields = {
|
||||
'object': fields.StringField(nullable=False),
|
||||
'action': fields.StringField(nullable=False),
|
||||
'phase': fields.StringField(nullable=True)
|
||||
}
|
||||
|
||||
def to_event_type_field(self):
|
||||
parts = ['baremetal', self.object, self.action]
|
||||
if self.obj_attr_is_set('phase') and self.phase is not None:
|
||||
parts.append(self.phase)
|
||||
return '.'.join(parts)
|
||||
|
||||
|
||||
# NOTE(mariojv) This class will not be used directly and is just a base class
|
||||
# for notifications, so we don't need to register it.
|
||||
@base.IronicObjectRegistry.register_if(False)
|
||||
class NotificationBase(base.IronicObject):
|
||||
"""Base class for versioned notifications.
|
||||
|
||||
Subclasses must define the "payload" field, which must be a subclass of
|
||||
NotificationPayloadBase.
|
||||
"""
|
||||
# Version 1.0: Initial version
|
||||
VERSION = '1.0'
|
||||
|
||||
fields = {
|
||||
'level': fields.NotificationLevelField(),
|
||||
'event_type': fields.ObjectField('EventType'),
|
||||
'publisher': fields.ObjectField('NotificationPublisher')
|
||||
}
|
||||
|
||||
# NOTE(mariojv) This may be a candidate for something oslo.messaging
|
||||
# implements instead of in ironic.
|
||||
def _should_notify(self):
|
||||
"""Determine whether the notification should be sent.
|
||||
|
||||
A notification is sent when the level of the notification is
|
||||
greater than or equal to the level specified in the
|
||||
configuration, in the increasing order of DEBUG, INFO, WARNING,
|
||||
ERROR, CRITICAL.
|
||||
|
||||
:return: True if notification should be sent, False otherwise.
|
||||
"""
|
||||
if CONF.notification_level is None:
|
||||
return False
|
||||
return (NOTIFY_LEVELS[self.level] >=
|
||||
NOTIFY_LEVELS[CONF.notification_level])
|
||||
|
||||
def emit(self, context):
|
||||
"""Send the notification."""
|
||||
if not self._should_notify():
|
||||
return
|
||||
if not self.payload.populated:
|
||||
raise exception.NotificationPayloadError(
|
||||
class_name=self.__class__.__name__)
|
||||
# NOTE(mariojv) By default, oslo_versionedobjects includes a list
|
||||
# of "changed fields" for the object in the output of
|
||||
# obj_to_primitive. This is unneeded since every field of the
|
||||
# object will look changed, since each payload is a newly created
|
||||
# object, so we drop the changes.
|
||||
self.payload.obj_reset_changes()
|
||||
event_type = self.event_type.to_event_type_field()
|
||||
publisher_id = '%s.%s' % (self.publisher.service, self.publisher.host)
|
||||
payload = self.payload.obj_to_primitive()
|
||||
|
||||
notifier = rpc.get_versioned_notifier(publisher_id)
|
||||
notify = getattr(notifier, self.level)
|
||||
notify(context, event_type=event_type, payload=payload)
|
||||
|
||||
|
||||
# NOTE(mariojv) This class will not be used directly and is just a base class
|
||||
# for notifications, so we don't need to register it.
|
||||
@base.IronicObjectRegistry.register_if(False)
|
||||
class NotificationPayloadBase(base.IronicObject):
|
||||
"""Base class for the payload of versioned notifications."""
|
||||
|
||||
SCHEMA = {}
|
||||
# Version 1.0: Initial version
|
||||
VERSION = '1.0'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(NotificationPayloadBase, self).__init__(*args, **kwargs)
|
||||
# If SCHEMA is empty, the payload is already populated
|
||||
self.populated = not self.SCHEMA
|
||||
|
||||
def populate_schema(self, **kwargs):
|
||||
"""Populate the object based on the SCHEMA and the source objects
|
||||
|
||||
:param kwargs: A dict contains the source object and the keys defined
|
||||
in the SCHEMA
|
||||
"""
|
||||
for key, (obj, field) in self.SCHEMA.items():
|
||||
try:
|
||||
source = kwargs[obj]
|
||||
except KeyError:
|
||||
raise exception.NotificationSchemaObjectError(obj=obj,
|
||||
source=kwargs)
|
||||
try:
|
||||
setattr(self, key, getattr(source, field))
|
||||
except Exception:
|
||||
raise exception.NotificationSchemaKeyError(obj=obj,
|
||||
field=field,
|
||||
key=key)
|
||||
self.populated = True
|
||||
|
||||
|
||||
@base.IronicObjectRegistry.register
|
||||
class NotificationPublisher(base.IronicObject):
|
||||
# Version 1.0: Initial version
|
||||
VERSION = '1.0'
|
||||
|
||||
fields = {
|
||||
'service': fields.StringField(nullable=False),
|
||||
'host': fields.StringField(nullable=False)
|
||||
}
|
@ -31,6 +31,7 @@ eventlet.monkey_patch(os=False)
|
||||
import fixtures
|
||||
from oslo_config import fixture as config_fixture
|
||||
from oslo_log import log as logging
|
||||
from oslo_serialization import jsonutils
|
||||
from oslo_utils import uuidutils
|
||||
import testtools
|
||||
|
||||
@ -162,3 +163,8 @@ class TestCase(testtools.TestCase):
|
||||
return os.path.join(root, project_file)
|
||||
else:
|
||||
return root
|
||||
|
||||
def assertJsonEqual(self, expected, observed):
|
||||
"""Asserts that 2 complex data structures are json equivalent."""
|
||||
self.assertEqual(jsonutils.dumps(expected, sort_keys=True),
|
||||
jsonutils.dumps(observed, sort_keys=True))
|
||||
|
@ -27,17 +27,109 @@ class TestUtils(base.TestCase):
|
||||
@mock.patch.object(messaging, 'JsonPayloadSerializer', autospec=True)
|
||||
@mock.patch.object(messaging, 'get_notification_transport', autospec=True)
|
||||
@mock.patch.object(messaging, 'get_transport', autospec=True)
|
||||
def test_init_globals(self, mock_get_transport, mock_get_notification,
|
||||
mock_serializer, mock_notifier):
|
||||
def test_init_globals_notifications_disabled(self, mock_get_transport,
|
||||
mock_get_notification,
|
||||
mock_json_serializer,
|
||||
mock_notifier):
|
||||
self._test_init_globals(False, mock_get_transport,
|
||||
mock_get_notification, mock_json_serializer,
|
||||
mock_notifier)
|
||||
|
||||
@mock.patch.object(messaging, 'Notifier', autospec=True)
|
||||
@mock.patch.object(messaging, 'JsonPayloadSerializer', autospec=True)
|
||||
@mock.patch.object(messaging, 'get_notification_transport', autospec=True)
|
||||
@mock.patch.object(messaging, 'get_transport', autospec=True)
|
||||
def test_init_globals_notifications_enabled(self, mock_get_transport,
|
||||
mock_get_notification,
|
||||
mock_json_serializer,
|
||||
mock_notifier):
|
||||
self.config(notification_level='debug')
|
||||
self._test_init_globals(True, mock_get_transport,
|
||||
mock_get_notification, mock_json_serializer,
|
||||
mock_notifier)
|
||||
|
||||
def _test_init_globals(self, notifications_enabled, mock_get_transport,
|
||||
mock_get_notification, mock_json_serializer,
|
||||
mock_notifier):
|
||||
rpc.TRANSPORT = None
|
||||
rpc.NOTIFICATION_TRANSPORT = None
|
||||
rpc.NOTIFIER = None
|
||||
rpc.SENSORS_NOTIFIER = None
|
||||
rpc.VERSIONED_NOTIFIER = None
|
||||
mock_request_serializer = mock.Mock()
|
||||
mock_request_serializer.return_value = mock.Mock()
|
||||
rpc.RequestContextSerializer = mock_request_serializer
|
||||
|
||||
# Make sure that two separate Notifiers are instantiated: one for the
|
||||
# regular RPC transport, one for the notification transport
|
||||
mock_notifiers = [mock.Mock()] * 2
|
||||
mock_notifier.side_effect = mock_notifiers
|
||||
|
||||
rpc.init(CONF)
|
||||
|
||||
self.assertEqual(mock_get_transport.return_value, rpc.TRANSPORT)
|
||||
self.assertEqual(mock_get_notification.return_value,
|
||||
rpc.NOTIFICATION_TRANSPORT)
|
||||
self.assertTrue(mock_serializer.called)
|
||||
self.assertEqual(mock_notifier.return_value, rpc.NOTIFIER)
|
||||
self.assertTrue(mock_json_serializer.called)
|
||||
|
||||
if not notifications_enabled:
|
||||
notifier_calls = [
|
||||
mock.call(
|
||||
rpc.NOTIFICATION_TRANSPORT,
|
||||
serializer=mock_request_serializer.return_value),
|
||||
mock.call(
|
||||
rpc.NOTIFICATION_TRANSPORT,
|
||||
serializer=mock_request_serializer.return_value,
|
||||
driver='noop')
|
||||
]
|
||||
else:
|
||||
notifier_calls = [
|
||||
mock.call(
|
||||
rpc.NOTIFICATION_TRANSPORT,
|
||||
serializer=mock_request_serializer.return_value),
|
||||
mock.call(
|
||||
rpc.NOTIFICATION_TRANSPORT,
|
||||
serializer=mock_request_serializer.return_value,
|
||||
topics=['ironic_versioned_notifications'])
|
||||
]
|
||||
|
||||
mock_notifier.assert_has_calls(notifier_calls)
|
||||
|
||||
self.assertEqual(mock_notifiers[0], rpc.SENSORS_NOTIFIER)
|
||||
self.assertEqual(mock_notifiers[1], rpc.VERSIONED_NOTIFIER)
|
||||
|
||||
def test_get_sensors_notifier(self):
|
||||
rpc.SENSORS_NOTIFIER = mock.Mock(autospec=True)
|
||||
rpc.get_sensors_notifier(service='conductor', host='my_conductor',
|
||||
publisher_id='a_great_publisher')
|
||||
rpc.SENSORS_NOTIFIER.prepare.assert_called_once_with(
|
||||
publisher_id='a_great_publisher')
|
||||
|
||||
def test_get_sensors_notifier_no_publisher_id(self):
|
||||
rpc.SENSORS_NOTIFIER = mock.Mock(autospec=True)
|
||||
rpc.get_sensors_notifier(service='conductor', host='my_conductor')
|
||||
rpc.SENSORS_NOTIFIER.prepare.assert_called_once_with(
|
||||
publisher_id='conductor.my_conductor')
|
||||
|
||||
def test_get_sensors_notifier_no_notifier(self):
|
||||
rpc.SENSORS_NOTIFIER = None
|
||||
self.assertRaises(AssertionError, rpc.get_sensors_notifier)
|
||||
|
||||
def test_get_versioned_notifier(self):
|
||||
rpc.VERSIONED_NOTIFIER = mock.Mock(autospec=True)
|
||||
rpc.get_versioned_notifier(publisher_id='a_great_publisher')
|
||||
rpc.VERSIONED_NOTIFIER.prepare.assert_called_once_with(
|
||||
publisher_id='a_great_publisher')
|
||||
|
||||
def test_get_versioned_notifier_no_publisher_id(self):
|
||||
rpc.VERSIONED_NOTIFIER = mock.Mock()
|
||||
self.assertRaises(AssertionError,
|
||||
rpc.get_versioned_notifier, publisher_id=None)
|
||||
|
||||
def test_get_versioned_notifier_no_notifier(self):
|
||||
rpc.VERSIONED_NOTIFIER = None
|
||||
self.assertRaises(
|
||||
AssertionError,
|
||||
rpc.get_versioned_notifier, publisher_id='a_great_publisher')
|
||||
|
||||
|
||||
class TestRequestContextSerializer(base.TestCase):
|
||||
|
@ -105,3 +105,18 @@ class TestStringFieldThatAcceptsCallable(test_base.TestCase):
|
||||
expected = ('StringAcceptsCallable(default=test_default_function-%s,'
|
||||
'nullable=False)' % self.test_default_function_hash)
|
||||
self.assertEqual(expected, repr(self.field))
|
||||
|
||||
|
||||
class TestNotificationLevelField(test_base.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
super(TestNotificationLevelField, self).setUp()
|
||||
self.field = fields.NotificationLevelField()
|
||||
|
||||
def test_coerce_good_value(self):
|
||||
self.assertEqual(fields.NotificationLevel.WARNING,
|
||||
self.field.coerce('obj', 'attr', 'warning'))
|
||||
|
||||
def test_coerce_bad_value(self):
|
||||
self.assertRaises(ValueError, self.field.coerce, 'obj', 'attr',
|
||||
'not_a_priority')
|
||||
|
243
ironic/tests/unit/objects/test_notification.py
Normal file
243
ironic/tests/unit/objects/test_notification.py
Normal file
@ -0,0 +1,243 @@
|
||||
# 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 mock
|
||||
|
||||
from ironic.common import exception
|
||||
from ironic.objects import base
|
||||
from ironic.objects import fields
|
||||
from ironic.objects import notification
|
||||
from ironic.tests import base as test_base
|
||||
|
||||
|
||||
class TestNotificationBase(test_base.TestCase):
|
||||
|
||||
@base.IronicObjectRegistry.register_if(False)
|
||||
class TestObject(base.IronicObject):
|
||||
VERSION = '1.0'
|
||||
fields = {
|
||||
'fake_field_1': fields.StringField(),
|
||||
'fake_field_2': fields.IntegerField(nullable=True)
|
||||
}
|
||||
|
||||
@base.IronicObjectRegistry.register_if(False)
|
||||
class TestNotificationPayload(notification.NotificationPayloadBase):
|
||||
VERSION = '1.0'
|
||||
|
||||
SCHEMA = {
|
||||
'fake_field_a': ('test_obj', 'fake_field_1'),
|
||||
'fake_field_b': ('test_obj', 'fake_field_2')
|
||||
}
|
||||
|
||||
fields = {
|
||||
'fake_field_a': fields.StringField(),
|
||||
'fake_field_b': fields.IntegerField(),
|
||||
'an_extra_field': fields.StringField(nullable=False),
|
||||
'an_optional_field': fields.IntegerField(nullable=True)
|
||||
}
|
||||
|
||||
@base.IronicObjectRegistry.register_if(False)
|
||||
class TestNotificationPayloadEmptySchema(
|
||||
notification.NotificationPayloadBase):
|
||||
VERSION = '1.0'
|
||||
|
||||
fields = {
|
||||
'fake_field': fields.StringField()
|
||||
}
|
||||
|
||||
@base.IronicObjectRegistry.register_if(False)
|
||||
class TestNotification(notification.NotificationBase):
|
||||
VERSION = '1.0'
|
||||
fields = {
|
||||
'payload': fields.ObjectField('TestNotificationPayload')
|
||||
}
|
||||
|
||||
@base.IronicObjectRegistry.register_if(False)
|
||||
class TestNotificationEmptySchema(notification.NotificationBase):
|
||||
VERSION = '1.0'
|
||||
fields = {
|
||||
'payload': fields.ObjectField('TestNotificationPayloadEmptySchema')
|
||||
}
|
||||
|
||||
def setUp(self):
|
||||
super(TestNotificationBase, self).setUp()
|
||||
self.fake_obj = self.TestObject(fake_field_1='fake1', fake_field_2=2)
|
||||
|
||||
def _verify_notification(self, mock_notifier, mock_context,
|
||||
expected_event_type, expected_payload,
|
||||
expected_publisher, notif_level):
|
||||
mock_notifier.prepare.assert_called_once_with(
|
||||
publisher_id=expected_publisher)
|
||||
# Handler actually sending out the notification depends on the
|
||||
# notification level
|
||||
mock_notify = getattr(mock_notifier.prepare.return_value, notif_level)
|
||||
self.assertTrue(mock_notify.called)
|
||||
self.assertEqual(mock_context, mock_notify.call_args[0][0])
|
||||
self.assertEqual(expected_event_type,
|
||||
mock_notify.call_args[1]['event_type'])
|
||||
actual_payload = mock_notify.call_args[1]['payload']
|
||||
self.assertJsonEqual(expected_payload, actual_payload)
|
||||
|
||||
@mock.patch('ironic.common.rpc.VERSIONED_NOTIFIER')
|
||||
def test_emit_notification(self, mock_notifier):
|
||||
self.config(notification_level='debug')
|
||||
payload = self.TestNotificationPayload(an_extra_field='extra',
|
||||
an_optional_field=1)
|
||||
payload.populate_schema(test_obj=self.fake_obj)
|
||||
notif = self.TestNotification(
|
||||
event_type=notification.EventType(
|
||||
object='test_object', action='test', phase='start'),
|
||||
level=fields.NotificationLevel.DEBUG,
|
||||
publisher=notification.NotificationPublisher(
|
||||
service='ironic-conductor',
|
||||
host='host'),
|
||||
payload=payload)
|
||||
|
||||
mock_context = mock.Mock()
|
||||
notif.emit(mock_context)
|
||||
|
||||
self._verify_notification(
|
||||
mock_notifier,
|
||||
mock_context,
|
||||
expected_event_type='baremetal.test_object.test.start',
|
||||
expected_payload={
|
||||
'ironic_object.name': 'TestNotificationPayload',
|
||||
'ironic_object.data': {
|
||||
'fake_field_a': 'fake1',
|
||||
'fake_field_b': 2,
|
||||
'an_extra_field': 'extra',
|
||||
'an_optional_field': 1
|
||||
},
|
||||
'ironic_object.version': '1.0',
|
||||
'ironic_object.namespace': 'ironic'},
|
||||
expected_publisher='ironic-conductor.host',
|
||||
notif_level=fields.NotificationLevel.DEBUG)
|
||||
|
||||
@mock.patch('ironic.common.rpc.VERSIONED_NOTIFIER')
|
||||
def test_no_emit_level_too_low(self, mock_notifier):
|
||||
# Make sure notification doesn't emit when set notification
|
||||
# level < config level
|
||||
self.config(notification_level='warning')
|
||||
payload = self.TestNotificationPayload(an_extra_field='extra',
|
||||
an_optional_field=1)
|
||||
payload.populate_schema(test_obj=self.fake_obj)
|
||||
notif = self.TestNotification(
|
||||
event_type=notification.EventType(
|
||||
object='test_object', action='test', phase='start'),
|
||||
level=fields.NotificationLevel.DEBUG,
|
||||
publisher=notification.NotificationPublisher(
|
||||
service='ironic-conductor',
|
||||
host='host'),
|
||||
payload=payload)
|
||||
|
||||
mock_context = mock.Mock()
|
||||
notif.emit(mock_context)
|
||||
|
||||
self.assertFalse(mock_notifier.called)
|
||||
|
||||
@mock.patch('ironic.common.rpc.VERSIONED_NOTIFIER')
|
||||
def test_no_emit_notifs_disabled(self, mock_notifier):
|
||||
# Make sure notifications aren't emitted when notification_level
|
||||
# isn't defined, indicating notifications should be disabled
|
||||
payload = self.TestNotificationPayload(an_extra_field='extra',
|
||||
an_optional_field=1)
|
||||
payload.populate_schema(test_obj=self.fake_obj)
|
||||
notif = self.TestNotification(
|
||||
event_type=notification.EventType(
|
||||
object='test_object', action='test', phase='start'),
|
||||
level=fields.NotificationLevel.DEBUG,
|
||||
publisher=notification.NotificationPublisher(
|
||||
service='ironic-conductor',
|
||||
host='host'),
|
||||
payload=payload)
|
||||
|
||||
mock_context = mock.Mock()
|
||||
notif.emit(mock_context)
|
||||
|
||||
self.assertFalse(mock_notifier.called)
|
||||
|
||||
@mock.patch('ironic.common.rpc.VERSIONED_NOTIFIER')
|
||||
def test_no_emit_schema_not_populated(self, mock_notifier):
|
||||
self.config(notification_level='debug')
|
||||
payload = self.TestNotificationPayload(an_extra_field='extra',
|
||||
an_optional_field=1)
|
||||
notif = self.TestNotification(
|
||||
event_type=notification.EventType(
|
||||
object='test_object', action='test', phase='start'),
|
||||
level=fields.NotificationLevel.DEBUG,
|
||||
publisher=notification.NotificationPublisher(
|
||||
service='ironic-conductor',
|
||||
host='host'),
|
||||
payload=payload)
|
||||
|
||||
mock_context = mock.Mock()
|
||||
self.assertRaises(exception.NotificationPayloadError, notif.emit,
|
||||
mock_context)
|
||||
self.assertFalse(mock_notifier.called)
|
||||
|
||||
@mock.patch('ironic.common.rpc.VERSIONED_NOTIFIER')
|
||||
def test_emit_notification_empty_schema(self, mock_notifier):
|
||||
self.config(notification_level='debug')
|
||||
payload = self.TestNotificationPayloadEmptySchema(fake_field='123')
|
||||
notif = self.TestNotificationEmptySchema(
|
||||
event_type=notification.EventType(
|
||||
object='test_object', action='test', phase='fail'),
|
||||
level=fields.NotificationLevel.ERROR,
|
||||
publisher=notification.NotificationPublisher(
|
||||
service='ironic-conductor',
|
||||
host='host'),
|
||||
payload=payload)
|
||||
|
||||
mock_context = mock.Mock()
|
||||
notif.emit(mock_context)
|
||||
|
||||
self._verify_notification(
|
||||
mock_notifier,
|
||||
mock_context,
|
||||
expected_event_type='baremetal.test_object.test.fail',
|
||||
expected_payload={
|
||||
'ironic_object.name': 'TestNotificationPayloadEmptySchema',
|
||||
'ironic_object.data': {
|
||||
'fake_field': '123',
|
||||
},
|
||||
'ironic_object.version': '1.0',
|
||||
'ironic_object.namespace': 'ironic'},
|
||||
expected_publisher='ironic-conductor.host',
|
||||
notif_level=fields.NotificationLevel.ERROR)
|
||||
|
||||
def test_populate_schema(self):
|
||||
payload = self.TestNotificationPayload(an_extra_field='extra',
|
||||
an_optional_field=1)
|
||||
payload.populate_schema(test_obj=self.fake_obj)
|
||||
self.assertEqual('extra', payload.an_extra_field)
|
||||
self.assertEqual(1, payload.an_optional_field)
|
||||
self.assertEqual(self.fake_obj.fake_field_1, payload.fake_field_a)
|
||||
self.assertEqual(self.fake_obj.fake_field_2, payload.fake_field_b)
|
||||
|
||||
def test_populate_schema_missing_obj_field(self):
|
||||
test_obj = self.TestObject(fake_field_1='populated')
|
||||
payload = self.TestNotificationPayload(an_extra_field='too extra')
|
||||
self.assertRaises(exception.NotificationSchemaKeyError,
|
||||
payload.populate_schema,
|
||||
test_obj=test_obj)
|
||||
|
||||
def test_event_type_with_phase(self):
|
||||
event_type = notification.EventType(
|
||||
object="some_obj", action="some_action", phase="some_phase")
|
||||
self.assertEqual("baremetal.some_obj.some_action.some_phase",
|
||||
event_type.to_event_type_field())
|
||||
|
||||
def test_event_type_without_phase(self):
|
||||
event_type = notification.EventType(
|
||||
object="some_obj", action="some_action")
|
||||
self.assertEqual("baremetal.some_obj.some_action",
|
||||
event_type.to_event_type_field())
|
@ -409,7 +409,9 @@ expected_object_fingerprints = {
|
||||
'Chassis': '1.3-d656e039fd8ae9f34efc232ab3980905',
|
||||
'Port': '1.6-609504503d68982a10f495659990084b',
|
||||
'Portgroup': '1.1-e57da9ca808d3696c34dad8125564696',
|
||||
'Conductor': '1.1-5091f249719d4a465062a1b3dc7f860d'
|
||||
'Conductor': '1.1-5091f249719d4a465062a1b3dc7f860d',
|
||||
'EventType': '1.0-3daeec50c6deb956990255f92b863333',
|
||||
'NotificationPublisher': '1.0-51a09397d6c0687771fb5be9a999605d',
|
||||
}
|
||||
|
||||
|
||||
|
@ -0,0 +1,6 @@
|
||||
---
|
||||
features:
|
||||
- Add support for inter-service notifications (disabled by default until the
|
||||
``notification_level`` configuration option is set). For more information,
|
||||
see the notifications documentation in the developer's guide
|
||||
(http://docs.openstack.org/developer/ironic/dev/notifications.html).
|
Loading…
Reference in New Issue
Block a user