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:
Mario Villaplana 2016-06-28 16:06:12 +00:00
parent d0e49e1b41
commit 2cc70ea93a
17 changed files with 799 additions and 19 deletions

View 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

View File

@ -76,6 +76,7 @@ primarily for developers.
Ironic System Architecture <dev/architecture>
Provisioning State Machine <dev/states>
Notifications <dev/notifications>
Writing Drivers

View File

@ -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

View File

@ -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\"")

View File

@ -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,
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)

View File

@ -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):

View File

@ -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)

View File

@ -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)

View File

@ -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,

View File

@ -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):

View 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)
}

View File

@ -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))

View File

@ -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):

View File

@ -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')

View 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())

View File

@ -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',
}

View File

@ -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).