diff --git a/doc/source/dev/notifications.rst b/doc/source/dev/notifications.rst new file mode 100644 index 0000000000..9cc249be98 --- /dev/null +++ b/doc/source/dev/notifications.rst @@ -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": , + "event_type": , + "timestamp": , + "publisher_id": , + "message_id": , + "payload": + } + +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:: + + : (, + ) + +The ```` 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 ```` 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 ```` 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.`` field will be set by the +``.`` field. The +```` 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 diff --git a/doc/source/index.rst b/doc/source/index.rst index 002d6a3864..7ed14b01c7 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -76,6 +76,7 @@ primarily for developers. Ironic System Architecture Provisioning State Machine + Notifications Writing Drivers diff --git a/etc/ironic/ironic.conf.sample b/etc/ironic/ironic.conf.sample index 47296cc986..844adbd761 100644 --- a/etc/ironic/ironic.conf.sample +++ b/etc/ironic/ironic.conf.sample @@ -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 = + # 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 diff --git a/ironic/common/exception.py b/ironic/common/exception.py index b483b8d7de..83648d0cea 100644 --- a/ironic/common/exception.py +++ b/ironic/common/exception.py @@ -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\"") diff --git a/ironic/common/rpc.py b/ironic/common/rpc.py index 7ea30e11b7..5e6f11bfef 100644 --- a/ironic/common/rpc.py +++ b/ironic/common/rpc.py @@ -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) diff --git a/ironic/conductor/base_manager.py b/ironic/conductor/base_manager.py index cf9176a6ff..5e0852704b 100644 --- a/ironic/conductor/base_manager.py +++ b/ironic/conductor/base_manager.py @@ -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): diff --git a/ironic/conductor/manager.py b/ironic/conductor/manager.py index 1af0b924e7..f9527e5384 100644 --- a/ironic/conductor/manager.py +++ b/ironic/conductor/manager.py @@ -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) diff --git a/ironic/conf/default.py b/ironic/conf/default.py index 8e4a94309a..b3f2f07ee7 100644 --- a/ironic/conf/default.py +++ b/ironic/conf/default.py @@ -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) diff --git a/ironic/conf/opts.py b/ironic/conf/opts.py index b1571bfddc..c8df8bf0ce 100644 --- a/ironic/conf/opts.py +++ b/ironic/conf/opts.py @@ -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, diff --git a/ironic/objects/fields.py b/ironic/objects/fields.py index c1add2c630..a61ccff7d5 100644 --- a/ironic/objects/fields.py +++ b/ironic/objects/fields.py @@ -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): diff --git a/ironic/objects/notification.py b/ironic/objects/notification.py new file mode 100644 index 0000000000..5dcb009bb8 --- /dev/null +++ b/ironic/objects/notification.py @@ -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) + } diff --git a/ironic/tests/base.py b/ironic/tests/base.py index e46ec07d2b..404b160c4a 100644 --- a/ironic/tests/base.py +++ b/ironic/tests/base.py @@ -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)) diff --git a/ironic/tests/unit/common/test_rpc.py b/ironic/tests/unit/common/test_rpc.py index a4a3e17c13..197f553eb8 100644 --- a/ironic/tests/unit/common/test_rpc.py +++ b/ironic/tests/unit/common/test_rpc.py @@ -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): diff --git a/ironic/tests/unit/objects/test_fields.py b/ironic/tests/unit/objects/test_fields.py index 1291c619c8..18c439349b 100644 --- a/ironic/tests/unit/objects/test_fields.py +++ b/ironic/tests/unit/objects/test_fields.py @@ -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') diff --git a/ironic/tests/unit/objects/test_notification.py b/ironic/tests/unit/objects/test_notification.py new file mode 100644 index 0000000000..75808a9ed5 --- /dev/null +++ b/ironic/tests/unit/objects/test_notification.py @@ -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()) diff --git a/ironic/tests/unit/objects/test_objects.py b/ironic/tests/unit/objects/test_objects.py index cdc719f87d..6482c809fb 100644 --- a/ironic/tests/unit/objects/test_objects.py +++ b/ironic/tests/unit/objects/test_objects.py @@ -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', } diff --git a/releasenotes/notes/add-notifications-97b6c79c18b48073.yaml b/releasenotes/notes/add-notifications-97b6c79c18b48073.yaml new file mode 100644 index 0000000000..6310b6f93d --- /dev/null +++ b/releasenotes/notes/add-notifications-97b6c79c18b48073.yaml @@ -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).