From 360ffdc7b3b4d05888da56c0736f78c59c8531e0 Mon Sep 17 00:00:00 2001 From: Danni Shi Date: Tue, 2 May 2023 10:12:30 -0400 Subject: [PATCH] Add notifications for leases add unit tests Move emit notification to objects layer --- esi_leap/api/controllers/v1/lease.py | 2 +- esi_leap/common/exception.py | 16 + esi_leap/common/notification_utils.py | 139 +++++++++ esi_leap/common/rpc.py | 103 +++++++ esi_leap/conf/__init__.py | 2 + esi_leap/conf/notification.py | 34 +++ esi_leap/conf/opts.py | 1 + esi_leap/objects/fields.py | 35 +++ esi_leap/objects/lease.py | 113 ++++++- esi_leap/objects/notification.py | 173 +++++++++++ esi_leap/resource_objects/ironic_node.py | 3 + esi_leap/resource_objects/test_node.py | 3 + .../tests/common/test_notification_utils.py | 98 ++++++ esi_leap/tests/common/test_rpc.py | 153 ++++++++++ esi_leap/tests/objects/test_fields.py | 32 ++ esi_leap/tests/objects/test_lease.py | 132 +++++++- esi_leap/tests/objects/test_notification.py | 288 ++++++++++++++++++ requirements.txt | 1 + test-requirements.txt | 1 + 19 files changed, 1315 insertions(+), 14 deletions(-) create mode 100644 esi_leap/common/notification_utils.py create mode 100644 esi_leap/common/rpc.py create mode 100644 esi_leap/conf/notification.py create mode 100644 esi_leap/objects/notification.py create mode 100644 esi_leap/tests/common/test_notification_utils.py create mode 100644 esi_leap/tests/common/test_rpc.py create mode 100644 esi_leap/tests/objects/test_notification.py diff --git a/esi_leap/api/controllers/v1/lease.py b/esi_leap/api/controllers/v1/lease.py index 32fd13f8..1291053d 100644 --- a/esi_leap/api/controllers/v1/lease.py +++ b/esi_leap/api/controllers/v1/lease.py @@ -190,7 +190,7 @@ def delete(self, lease_id): request, 'esi_leap:lease:get', lease_id, statuses.LEASE_CAN_DELETE) - lease.cancel() + lease.cancel(request) @staticmethod def _lease_get_all_authorize_filters(cdict, diff --git a/esi_leap/common/exception.py b/esi_leap/common/exception.py index b392e1c0..013a50ae 100644 --- a/esi_leap/common/exception.py +++ b/esi_leap/common/exception.py @@ -149,3 +149,19 @@ class InvalidTimeRange(ESILeapException): class NodeNotFound(ESILeapException): msg_fmt = _('Encountered an error fetching info for node %(uuid)s ' '(%(resource_type)s): %(err)s') + + +class NotificationPayloadError(ESILeapException): + _msg_fmt = _("Payload not populated when trying to send notification " + "\"%(class_name)s\"") + + +class NotificationSchemaObjectError(ESILeapException): + _msg_fmt = _("Expected object %(obj)s when populating notification payload" + " but got object %(source)s") + + +class NotificationSchemaKeyError(ESILeapException): + _msg_fmt = _("Object %(obj)s doesn't have the field \"%(field)s\" " + "required for populating notification schema key " + "\"%(key)s\"") diff --git a/esi_leap/common/notification_utils.py b/esi_leap/common/notification_utils.py new file mode 100644 index 00000000..e43ab379 --- /dev/null +++ b/esi_leap/common/notification_utils.py @@ -0,0 +1,139 @@ +# 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 contextlib + +from oslo_config import cfg +from oslo_log import log +from oslo_messaging import exceptions as oslo_msg_exc +from oslo_utils import excutils +from oslo_versionedobjects import exception as oslo_vo_exc + +from esi_leap.common import exception +from esi_leap.common.i18n import _ +from esi_leap.objects import fields +from esi_leap.objects import notification + +LOG = log.getLogger(__name__) +CONF = cfg.CONF + + +def _emit_api_notification(context, obj, action, level, status, + crud_notify_obj, **kwargs): + """Helper for emitting API notifications. + + :param context: request context. + :param obj: resource rpc object. + :param action: Action string to go in the EventType. + :param level: Notification level. One of + `esi_leap.objects.fields.NotificationLevel.ALL` + :param status: Status to go in the EventType. One of + `esi_leap.objects.fields.NotificationStatus.ALL` + :param kwargs: kwargs to use when creating the notification payload. + """ + resource = obj.__class__.__name__.lower() + extra_args = kwargs + try: + try: + if resource not in crud_notify_obj: + notification_name = payload_name = _("is not defined") + raise KeyError(_("Unsupported resource: %s") % resource) + else: + notification_method, payload_method = crud_notify_obj[resource] + + notification_name = notification_method.__name__ + payload_name = payload_method.__name__ + finally: + # Prepare our exception message just in case + exception_values = {"resource": resource, + "uuid": obj.uuid, + "action": action, + "status": status, + "level": level, + "notification_method": notification_name, + "payload_method": payload_name} + exception_message = (_("Failed to send esi_leap.%(resource)s." + "%(action)s.%(status)s notification for " + "%(resource)s %(uuid)s with level " + "%(level)s, notification method " + "%(notification_method)s, payload method " + "%(payload_method)s, error %(error)s")) + payload = payload_method(obj, **extra_args) + notification_method( + publisher=notification.NotificationPublisher( + service='esi-leap-api', host=CONF.host), + event_type=notification.EventType( + object=resource, action=action, status=status), + level=level, + payload=payload).emit(context) + except (exception.NotificationSchemaObjectError, + exception.NotificationSchemaKeyError, + exception.NotificationPayloadError, + oslo_msg_exc.MessageDeliveryFailure, + oslo_vo_exc.VersionedObjectsException) as e: + exception_values['error'] = e + LOG.warning(exception_message, exception_values) + LOG.exception(e.msg_fmt) + + except Exception as e: + exception_values['error'] = e + LOG.exception(exception_message, exception_values) + + +def emit_start_notification(context, obj, action, crud_notify_obj, **kwargs): + """Helper for emitting API 'start' notifications. + + :param context: request context. + :param obj: resource rpc object. + :param action: Action string to go in the EventType. + :param kwargs: kwargs to use when creating the notification payload. + """ + _emit_api_notification(context, obj, action, + fields.NotificationLevel.INFO, + fields.NotificationStatus.START, + crud_notify_obj, + **kwargs) + + +@contextlib.contextmanager +def handle_error_notification(context, obj, action, crud_notify_obj, **kwargs): + """Context manager to handle any error notifications. + + :param context: request context. + :param obj: resource rpc object. + :param action: Action string to go in the EventType. + :param kwargs: kwargs to use when creating the notification payload. + """ + try: + yield + except Exception: + with excutils.save_and_reraise_exception(): + _emit_api_notification(context, obj, action, + fields.NotificationLevel.ERROR, + fields.NotificationStatus.ERROR, + crud_notify_obj, + **kwargs) + + +def emit_end_notification(context, obj, action, crud_notify_obj, **kwargs): + """Helper for emitting API 'end' notifications. + + :param context: request context. + :param obj: resource rpc object. + :param action: Action string to go in the EventType. + :param kwargs: kwargs to use when creating the notification payload. + """ + _emit_api_notification(context, obj, action, + fields.NotificationLevel.INFO, + fields.NotificationStatus.END, + crud_notify_obj, + **kwargs) diff --git a/esi_leap/common/rpc.py b/esi_leap/common/rpc.py new file mode 100644 index 00000000..2827a6de --- /dev/null +++ b/esi_leap/common/rpc.py @@ -0,0 +1,103 @@ +# 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.fic language governing permissions and limitations +# under the License. + +from oslo_context import context as ctx +import oslo_messaging as messaging +from osprofiler import profiler + +from esi_leap.common import exception + +NOTIFICATION_TRANSPORT = None +VERSIONED_NOTIFIER = None + +ALLOWED_EXMODS = [ + exception.__name__, +] +EXTRA_EXMODS = [] +TOPICS = ['esi_leap_versioned_notifications'] + + +def init(conf): + global NOTIFICATION_TRANSPORT + global VERSIONED_NOTIFIER + exmods = get_allowed_exmods() + NOTIFICATION_TRANSPORT = messaging.get_notification_transport( + conf, + allowed_remote_exmods=exmods) + + serializer = RequestContextSerializer(messaging.JsonPayloadSerializer()) + + if conf.notification.notification_level is None: + VERSIONED_NOTIFIER = messaging.Notifier(NOTIFICATION_TRANSPORT, + serializer=serializer, + driver='noop') + else: + VERSIONED_NOTIFIER = messaging.Notifier( + NOTIFICATION_TRANSPORT, + serializer=serializer, + topics=TOPICS) + + +def cleanup(): + global NOTIFICATION_TRANSPORT + global VERSIONED_NOTIFIER + assert NOTIFICATION_TRANSPORT is not None + assert VERSIONED_NOTIFIER is not None + NOTIFICATION_TRANSPORT.cleanup() + NOTIFICATION_TRANSPORT = None + VERSIONED_NOTIFIER = None + + +def get_allowed_exmods(): + return ALLOWED_EXMODS + EXTRA_EXMODS + + +# RequestContextSerializer borrowed from Ironic +class RequestContextSerializer(messaging.Serializer): + + def __init__(self, base): + self._base = base + + def serialize_entity(self, context, entity): + if not self._base: + return entity + return self._base.serialize_entity(context, entity) + + def deserialize_entity(self, context, entity): + if not self._base: + return entity + return self._base.deserialize_entity(context, entity) + + def serialize_context(self, context): + _context = context.to_dict() + prof = profiler.get() + if prof: + trace_info = { + "hmac_key": prof.hmac_key, + "base_id": prof.get_base_id(), + "parent_id": prof.get_id() + } + _context.update({"trace_info": trace_info}) + return _context + + def deserialize_context(self, context): + trace_info = context.pop("trace_info", None) + if trace_info: + profiler.init(**trace_info) + return ctx.RequestContext.from_dict(context) + + +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/esi_leap/conf/__init__.py b/esi_leap/conf/__init__.py index ae6efca2..e4e32405 100644 --- a/esi_leap/conf/__init__.py +++ b/esi_leap/conf/__init__.py @@ -16,6 +16,7 @@ from esi_leap.conf import ironic from esi_leap.conf import keystone from esi_leap.conf import netconf +from esi_leap.conf import notification from esi_leap.conf import pecan from oslo_config import cfg @@ -28,4 +29,5 @@ ironic.register_opts(CONF) keystone.register_opts(CONF) netconf.register_opts(CONF) +notification.register_opts(CONF) pecan.register_opts(CONF) diff --git a/esi_leap/conf/notification.py b/esi_leap/conf/notification.py new file mode 100644 index 00000000..4767e83d --- /dev/null +++ b/esi_leap/conf/notification.py @@ -0,0 +1,34 @@ +# 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 esi_leap.common.i18n import _ +from oslo_config import cfg + +# borrowed from Ironic +opts = [ + cfg.StrOpt('notification_level', + choices=[('debug', _('"debug" level')), + ('info', _('"info" level')), + ('warning', _('"warning" level')), + ('error', _('"error" level')), + ('critical', _('"critical" level'))], + 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.')), +] + + +notification_group = cfg.OptGroup('notification', title='Notification Options') + + +def register_opts(conf): + conf.register_opts(opts, group=notification_group) diff --git a/esi_leap/conf/opts.py b/esi_leap/conf/opts.py index a76e89c3..4c109c7f 100644 --- a/esi_leap/conf/opts.py +++ b/esi_leap/conf/opts.py @@ -19,6 +19,7 @@ ('ironic', esi_leap.conf.ironic.list_opts()), ('keystone', esi_leap.conf.keystone.list_opts()), ('pecan', esi_leap.conf.pecan.opts), + ('notification', esi_leap.conf.notification.opts), ] diff --git a/esi_leap/objects/fields.py b/esi_leap/objects/fields.py index 95bc50b3..7e715b82 100644 --- a/esi_leap/objects/fields.py +++ b/esi_leap/objects/fields.py @@ -57,3 +57,38 @@ class StringField(object_fields.StringField): class UUIDField(object_fields.UUIDField): pass + + +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 NotificationStatus(object_fields.Enum): + START = 'start' + END = 'end' + ERROR = 'error' + SUCCESS = 'success' + + ALL = (START, END, ERROR, SUCCESS) + + def __init__(self): + super(NotificationStatus, self).__init__( + valid_values=NotificationStatus.ALL) + + +class NotificationStatusField(object_fields.BaseEnumField): + AUTO_TYPE = NotificationStatus() diff --git a/esi_leap/objects/lease.py b/esi_leap/objects/lease.py index 5a1dc818..a5331b66 100644 --- a/esi_leap/objects/lease.py +++ b/esi_leap/objects/lease.py @@ -13,11 +13,13 @@ import datetime from esi_leap.common import exception +from esi_leap.common import notification_utils as notify from esi_leap.common import statuses from esi_leap.common import utils from esi_leap.db import api as dbapi from esi_leap.objects import base from esi_leap.objects import fields +from esi_leap.objects import notification from esi_leap.objects import offer as offer_obj from esi_leap.resource_objects import get_resource_object @@ -29,6 +31,74 @@ LOG = logging.getLogger(__name__) +@versioned_objects_base.VersionedObjectRegistry.register +class LeaseCRUDNotification(notification.NotificationBase): + """Notification emitted when a lease is created or deleted.""" + + fields = { + 'payload': fields.ObjectField('LeaseCRUDPayload') + } + + +@versioned_objects_base.VersionedObjectRegistry.register +class LeaseCRUDPayload(notification.NotificationPayloadBase): + """Payload schema for when a lease is created or deleted.""" + # Version 1.0: Initial version + VERSION = '1.0' + + SCHEMA = { + 'id': ('lease', 'id'), + 'name': ('lease', 'name'), + 'uuid': ('lease', 'uuid'), + 'project_id': ('lease', 'project_id'), + 'owner_id': ('lease', 'owner_id'), + 'resource_type': ('lease', 'resource_type'), + 'resource_uuid': ('lease', 'resource_uuid'), + 'start_time': ('lease', 'start_time'), + 'end_time': ('lease', 'end_time'), + 'fulfill_time': ('lease', 'fulfill_time'), + 'expire_time': ('lease', 'expire_time'), + 'properties': ('lease', 'properties'), + 'offer_uuid': ('lease', 'offer_uuid'), + 'parent_lease_uuid': ('lease', 'parent_lease_uuid'), + 'node_name': ('node', '_node_name'), + 'node_uuid': ('node', '_uuid'), + 'node_provision_state': ('node', '_provision_state'), + 'node_power_state': ('node', '_power_state'), + } + + fields = { + 'id': fields.IntegerField(), + 'name': fields.StringField(nullable=True), + 'uuid': fields.UUIDField(), + 'project_id': fields.StringField(), + 'owner_id': fields.StringField(), + 'resource_type': fields.StringField(), + 'resource_uuid': fields.StringField(), + 'start_time': fields.DateTimeField(nullable=True), + 'end_time': fields.DateTimeField(nullable=True), + 'fulfill_time': fields.DateTimeField(nullable=True), + 'expire_time': fields.DateTimeField(nullable=True), + 'status': fields.StringField(), + 'properties': fields.FlexibleDictField(nullable=True), + 'offer_uuid': fields.UUIDField(nullable=True), + 'parent_lease_uuid': fields.UUIDField(nullable=True), + 'node_name': fields.StringField(nullable=True), + 'node_uuid': fields.UUIDField(), + 'node_provision_state': fields.StringField(), + 'node_power_state': fields.StringField(), + } + + def __init__(self, lease, node): + super(LeaseCRUDPayload, self).__init__() + self.populate_schema(lease=lease, node=node) + + +CRUD_NOTIFY_OBJ = { + 'lease': (LeaseCRUDNotification, LeaseCRUDPayload), +} + + @versioned_objects_base.VersionedObjectRegistry.register class Lease(base.ESILEAPObject): dbapi = dbapi.get_instance() @@ -106,7 +176,7 @@ def create(self, context=None): db_lease = self.dbapi.lease_create(updates) self._from_db_object(context, self, db_lease) - def cancel(self): + def cancel(self, context=None): leases = Lease.get_all( {'parent_lease_uuid': self.uuid, 'status': statuses.LEASE_CAN_DELETE}, @@ -132,14 +202,29 @@ def cancel(self): parent_lease = Lease.get(self.parent_lease_uuid) resource.set_lease(parent_lease) - self.status = statuses.DELETED - self.expire_time = datetime.datetime.now() + resource._node_name = resource.get_resource_name() + resource._provision_state \ + = resource._get_node_attr('provision_state') + resource._power_state = resource._get_node_attr('power_state') + + notify.emit_start_notification(context, self, + 'delete', CRUD_NOTIFY_OBJ, + node=resource) + with notify.handle_error_notification(context, self, + 'delete', + CRUD_NOTIFY_OBJ, + node=resource): + self.status = statuses.DELETED + self.expire_time = datetime.datetime.now() + notify.emit_end_notification(context, self, + 'delete', CRUD_NOTIFY_OBJ, + node=resource) except Exception as e: LOG.info('Error canceling lease: %s: %s' % (type(e).__name__, e)) LOG.info('Setting lease status to WAIT') self.status = statuses.WAIT_CANCEL - self.save(None) + self.save(context) def destroy(self): self.dbapi.lease_destroy(self.uuid) @@ -160,9 +245,25 @@ def fulfill(self, context=None): resource = self.resource_object() resource.set_lease(self) + resource._node_name = resource.get_resource_name() + resource._provision_state = \ + resource._get_node_attr('provision_state') + resource._power_state = resource._get_node_attr('power_state') # activate lease - self.status = statuses.ACTIVE - self.fulfill_time = datetime.datetime.now() + notify.emit_start_notification(context, self, + 'fulfill', + CRUD_NOTIFY_OBJ, + node=resource) + with notify.handle_error_notification(context, self, + 'fulfill', + CRUD_NOTIFY_OBJ, + node=resource): + self.status = statuses.ACTIVE + self.fulfill_time = datetime.datetime.now() + notify.emit_end_notification(context, self, + 'fulfill', + CRUD_NOTIFY_OBJ, + node=resource) except Exception as e: LOG.info('Error fulfilling lease: %s: %s' % (type(e).__name__, e)) diff --git a/esi_leap/objects/notification.py b/esi_leap/objects/notification.py new file mode 100644 index 00000000..44476f12 --- /dev/null +++ b/esi_leap/objects/notification.py @@ -0,0 +1,173 @@ +# 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 oslo_versionedobjects import base as versioned_objects_base + +from esi_leap.common import exception +from esi_leap.common import rpc +from esi_leap.objects import base +from esi_leap.objects import fields + + +CONF = cfg.CONF + +# Notification object borrowed from Ironic + +# 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 +} + + +@versioned_objects_base.VersionedObjectRegistry.register +class EventType(base.ESILEAPObject): + """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 status of the action. + """ + # Version 1.0: Initial version + VERSION = '1.0' + + fields = { + 'object': fields.StringField(nullable=False), + 'action': fields.StringField(nullable=False), + 'status': fields.NotificationStatusField() + } + + def to_event_type_field(self): + """Constructs string for event_type to be sent on the wire. + + The string is in the format: esi_leap... + + :raises: ValueError if self.status is not one of + :class:`fields.NotificationStatusField` + :returns: event_type string + """ + parts = ['esi_leap', self.object, self.action, self.status] + return '.'.join(parts) + + +class NotificationBase(base.ESILEAPObject): + """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') + } + + 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.notification_level is None: + return False + return (NOTIFY_LEVELS[self.level] >= + NOTIFY_LEVELS[CONF.notification.notification_level]) + + def emit(self, context): + """Send the notification. + + :raises: NotificationPayloadError + :raises: oslo_versionedobjects.exceptions.MessageDeliveryFailure + """ + if not self._should_notify(): + return + if not self.payload.populated: + raise exception.NotificationPayloadError( + class_name=self.__class__.__name__) + + 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) + + +class NotificationPayloadBase(base.ESILEAPObject): + """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 + :raises: NotificationSchemaObjectError + :raises: NotificationSchemaKeyError + """ + 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 NotImplementedError: + # The object is missing (a value for) field. Oslo try to load + # value via obj_load_attr() method which is not implemented. + # If this field is nullable in this payload, set its payload + # value to None. + field_obj = self.fields.get(key) + if field_obj is not None and getattr(field_obj, 'nullable', + False): + setattr(self, key, None) + continue + raise exception.NotificationSchemaKeyError(obj=obj, + field=field, + key=key) + except Exception: + raise exception.NotificationSchemaKeyError(obj=obj, + field=field, + key=key) + self.populated = True + + +@versioned_objects_base.VersionedObjectRegistry.register +class NotificationPublisher(base.ESILEAPObject): + # Version 1.0: Initial version + VERSION = '1.0' + + fields = { + 'service': fields.StringField(nullable=False), + 'host': fields.StringField(nullable=False) + } diff --git a/esi_leap/resource_objects/ironic_node.py b/esi_leap/resource_objects/ironic_node.py index 176af675..af693d86 100644 --- a/esi_leap/resource_objects/ironic_node.py +++ b/esi_leap/resource_objects/ironic_node.py @@ -44,6 +44,9 @@ def __init__(self, ident): else: self._node = None self._uuid = ident + self._node_name = None + self._provision_state = None + self._power_state = None def get_resource_uuid(self): return self._uuid diff --git a/esi_leap/resource_objects/test_node.py b/esi_leap/resource_objects/test_node.py index 0a3f1dda..4ca0a07f 100644 --- a/esi_leap/resource_objects/test_node.py +++ b/esi_leap/resource_objects/test_node.py @@ -47,3 +47,6 @@ def expire_lease(self, lease): def resource_admin_project_id(self): return self._project_id + + def _get_node_attr(self, attr): + return 'null' diff --git a/esi_leap/tests/common/test_notification_utils.py b/esi_leap/tests/common/test_notification_utils.py new file mode 100644 index 00000000..af81c480 --- /dev/null +++ b/esi_leap/tests/common/test_notification_utils.py @@ -0,0 +1,98 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import datetime + +import mock + +from oslo_utils import uuidutils + +from esi_leap.common import notification_utils as notif_utils +from esi_leap.objects import fields +from esi_leap.objects import lease as lease_obj +from esi_leap.tests import base as tests_base + +start = datetime.datetime(2016, 7, 16, 19, 20, 30) + + +class FakeIronicNode(object): + def __init__(self): + self._node_name = 'fake-node' + self.properties = {'lease_uuid': '001'} + self._provision_state = 'available' + self._uuid = "fake_uuid" + self.resource_class = 'baremetal' + self._power_state = "on" + + +class APINotifyTestCase(tests_base.TestCase): + + def setUp(self): + super(APINotifyTestCase, self).setUp() + self.lease_notify_mock = mock.Mock() + self.lease_notify_mock.__name__ = 'LeaseCRUDNotification' + self.crud_notify_obj = { + 'lease': (self.lease_notify_mock, + lease_obj.LeaseCRUDPayload), + } + self.test_lease = lease_obj.Lease( + id=12345, + name="test_lease", + start_time=datetime.datetime(2016, 7, 16, 19, 20, 30), + end_time=datetime.datetime(2016, 8, 16, 19, 20, 30), + fulfill_time=datetime.datetime(2016, 7, 16, 19, 21, 30), + expire_time=datetime.datetime(2016, 8, 16, 19, 21, 30), + uuid=uuidutils.generate_uuid(), + resource_type='test_node', + resource_uuid='111', + project_id='lesseeid', + owner_id='ownerid', + parent_lease_uuid=None, + status='created', + properties=None + ) + self.fake_node = FakeIronicNode() + + def test_common_params(self): + self.config(host='fake-host') + test_level = fields.NotificationLevel.INFO + test_status = fields.NotificationStatus.SUCCESS + notif_utils._emit_api_notification(self.context, self.test_lease, + 'fulfill', test_level, + test_status, + self.crud_notify_obj, + node=self.fake_node) + init_kwargs = self.lease_notify_mock.call_args[1] + publisher = init_kwargs['publisher'] + event_type = init_kwargs['event_type'] + level = init_kwargs['level'] + self.assertEqual('fake-host', publisher.host) + self.assertEqual('esi-leap-api', publisher.service) + self.assertEqual('fulfill', event_type.action) + self.assertEqual(test_status, event_type.status) + self.assertEqual(test_level, level) + + def test_lease_notification(self): + test_level = fields.NotificationLevel.INFO + test_status = fields.NotificationStatus.SUCCESS + notif_utils._emit_api_notification(self.context, self.test_lease, + 'fulfill', test_level, + test_status, + self.crud_notify_obj, + node=self.fake_node) + init_kwargs = self.lease_notify_mock.call_args[1] + payload = init_kwargs['payload'] + event_type = init_kwargs['event_type'] + self.assertEqual('lease', event_type.object) + self.assertEqual(self.test_lease.uuid, payload.uuid) + self.assertEqual(self.test_lease.project_id, payload.project_id) + self.assertEqual(self.test_lease.fulfill_time, payload.fulfill_time) diff --git a/esi_leap/tests/common/test_rpc.py b/esi_leap/tests/common/test_rpc.py new file mode 100644 index 00000000..d995efc3 --- /dev/null +++ b/esi_leap/tests/common/test_rpc.py @@ -0,0 +1,153 @@ +# 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 esi_leap.common import rpc +from esi_leap.tests import base + +import mock + +from oslo_config import cfg +from oslo_context import context as ctx +import oslo_messaging as messaging + +CONF = cfg.CONF + + +# TestUtils borrowed from Ironic +class TestUtils(base.TestCase): + + @mock.patch.object(messaging, 'Notifier', autospec=True) + @mock.patch.object(messaging, 'JsonPayloadSerializer', autospec=True) + @mock.patch.object(messaging, 'get_notification_transport', autospec=True) + def test_init_globals_notifications_disabled(self, + mock_get_notification, + mock_json_serializer, + mock_notifier): + self._test_init_globals(False, + 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) + def test_init_globals_notifications_enabled(self, + mock_get_notification, + mock_json_serializer, + mock_notifier): + self.config(notification_level='debug', group='notification') + self._test_init_globals(True, + mock_get_notification, mock_json_serializer, + mock_notifier) + + def _test_init_globals( + self, notifications_enabled, + mock_get_notification, mock_json_serializer, + mock_notifier, + versioned_notifications_topics=( + ['esi_leap_versioned_notifications'])): + + rpc.NOTIFICATION_TRANSPORT = None + rpc.VERSIONED_NOTIFIER = None + mock_request_serializer = mock.Mock() + mock_request_serializer.return_value = mock.Mock() + rpc.RequestContextSerializer = mock_request_serializer + + mock_notifier.return_value = mock.Mock() + + rpc.init(CONF) + + self.assertEqual(mock_get_notification.return_value, + rpc.NOTIFICATION_TRANSPORT) + self.assertTrue(mock_json_serializer.called) + + if not notifications_enabled: + mock_notifier.assert_any_call( + rpc.NOTIFICATION_TRANSPORT, + serializer=mock_request_serializer.return_value, + driver='noop') + else: + mock_notifier.assert_any_call( + rpc.NOTIFICATION_TRANSPORT, + serializer=mock_request_serializer.return_value, + topics=versioned_notifications_topics) + + self.assertEqual(mock_notifier.return_value, rpc.VERSIONED_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): + + def setUp(self): + super(TestRequestContextSerializer, self).setUp() + + self.mock_serializer = mock.MagicMock() + self.serializer = rpc.RequestContextSerializer(self.mock_serializer) + self.context = ctx.RequestContext() + self.entity = {'foo': 'bar'} + + def test_serialize_entity(self): + self.serializer.serialize_entity(self.context, self.entity) + self.mock_serializer.serialize_entity.assert_called_with( + self.context, self.entity) + + def test_serialize_entity_empty_base(self): + # NOTE(viktors): Return False for check `if self.serializer._base:` + bool_args = {'__bool__': lambda *args: False, + '__nonzero__': lambda *args: False} + self.mock_serializer.configure_mock(**bool_args) + + entity = self.serializer.serialize_entity(self.context, self.entity) + self.assertFalse(self.mock_serializer.serialize_entity.called) + # If self.serializer._base is empty, return entity directly + self.assertEqual(self.entity, entity) + + def test_deserialize_entity(self): + self.serializer.deserialize_entity(self.context, self.entity) + self.mock_serializer.deserialize_entity.assert_called_with( + self.context, self.entity) + + def test_deserialize_entity_empty_base(self): + # NOTE(viktors): Return False for check `if self.serializer._base:` + bool_args = {'__bool__': lambda *args: False, + '__nonzero__': lambda *args: False} + self.mock_serializer.configure_mock(**bool_args) + + entity = self.serializer.deserialize_entity(self.context, self.entity) + self.assertFalse(self.mock_serializer.serialize_entity.called) + self.assertEqual(self.entity, entity) + + def test_serialize_context(self): + serialize_values = self.serializer.serialize_context(self.context) + + self.assertEqual(self.context.to_dict(), serialize_values) + + def test_deserialize_context(self): + serialize_values = self.context.to_dict() + new_context = self.serializer.deserialize_context(serialize_values) + self.assertEqual(serialize_values, new_context.to_dict()) + self.assertIsInstance(new_context, ctx.RequestContext) diff --git a/esi_leap/tests/objects/test_fields.py b/esi_leap/tests/objects/test_fields.py index 1259b784..77b3b8a0 100644 --- a/esi_leap/tests/objects/test_fields.py +++ b/esi_leap/tests/objects/test_fields.py @@ -38,3 +38,35 @@ def test_coerce_nullable_translation(self): # nullable self.field = fields.FlexibleDictField(nullable=True) self.assertEqual({}, self.field.coerce('obj', 'attr', None)) + + +# NotificationLevelField borrowed from Ironic +class TestNotificationLevelField(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') + + +# NotificationStatusField borrowed from Ironic +class TestNotificationStatusField(base.TestCase): + + def setUp(self): + super(TestNotificationStatusField, self).setUp() + self.field = fields.NotificationStatusField() + + def test_coerce_good_value(self): + self.assertEqual(fields.NotificationStatus.START, + self.field.coerce('obj', 'attr', 'start')) + + def test_coerce_bad_value(self): + self.assertRaises(ValueError, self.field.coerce, 'obj', 'attr', + 'not_a_priority') diff --git a/esi_leap/tests/objects/test_lease.py b/esi_leap/tests/objects/test_lease.py index 9d312d62..33bd5c9a 100644 --- a/esi_leap/tests/objects/test_lease.py +++ b/esi_leap/tests/objects/test_lease.py @@ -13,15 +13,36 @@ import datetime import mock from oslo_utils import uuidutils + import tempfile import threading from esi_leap.common import exception from esi_leap.common import statuses +from esi_leap.objects import fields as obj_fields from esi_leap.objects import lease as lease_obj from esi_leap.objects import offer as offer_obj from esi_leap.resource_objects.test_node import TestNode -from esi_leap.tests import base +import esi_leap.tests.base as base + + +class FakeIronicNode(object): + def __init__(self): + self._node_name = 'fake-node' + self.properties = {'lease_uuid': '001'} + self._provision_state = 'available' + self._uuid = "fake_uuid" + self.resource_class = 'baremetal' + self._power_state = 'On' + + def get_resource_name(self): + return 'fake-node' + + def _get_node_attr(self, attr): + if attr == 'provision_state': + return 'available' + if attr == 'power_state': + return 'on' class TestLeaseObject(base.DBTestCase): @@ -245,7 +266,9 @@ def update_mock(updates): @mock.patch('esi_leap.objects.lease.Lease.resource_object') @mock.patch('esi_leap.resource_objects.test_node.TestNode.set_lease') @mock.patch('esi_leap.objects.lease.Lease.save') - def test_fulfill(self, mock_save, mock_set_lease, mock_ro): + @mock.patch('esi_leap.common.notification_utils' + '._emit_api_notification') + def test_fulfill(self, mock_notify, mock_save, mock_set_lease, mock_ro): lease = lease_obj.Lease(self.context, **self.test_lease_dict) test_node = TestNode('test-node', '12345') @@ -253,6 +276,14 @@ def test_fulfill(self, mock_save, mock_set_lease, mock_ro): lease.fulfill() + mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'fulfill', + obj_fields.NotificationLevel.INFO, + obj_fields.NotificationStatus.START, + mock.ANY, node=mock.ANY), + mock.call(mock.ANY, mock.ANY, 'fulfill', + obj_fields.NotificationLevel.INFO, + obj_fields.NotificationStatus.END, + mock.ANY, node=mock.ANY)]) mock_ro.assert_called_once() mock_set_lease.assert_called_once() mock_save.assert_called_once() @@ -261,7 +292,10 @@ def test_fulfill(self, mock_save, mock_set_lease, mock_ro): @mock.patch('esi_leap.objects.lease.Lease.resource_object') @mock.patch('esi_leap.resource_objects.test_node.TestNode.set_lease') @mock.patch('esi_leap.objects.lease.Lease.save') - def test_fulfill_error(self, mock_save, mock_set_lease, mock_ro): + @mock.patch('esi_leap.common.notification_utils' + '._emit_api_notification') + def test_fulfill_error(self, mock_notify, mock_save, + mock_set_lease, mock_ro): lease = lease_obj.Lease(self.context, **self.test_lease_dict) test_node = TestNode('test-node', '12345') @@ -270,6 +304,7 @@ def test_fulfill_error(self, mock_save, mock_set_lease, mock_ro): lease.fulfill() + mock_notify.assert_not_called() mock_ro.assert_called_once() mock_set_lease.assert_called_once() mock_save.assert_called_once() @@ -281,7 +316,10 @@ def test_fulfill_error(self, mock_save, mock_set_lease, mock_ro): @mock.patch('esi_leap.resource_objects.test_node.TestNode.get_lease_uuid') @mock.patch('esi_leap.resource_objects.test_node.TestNode.expire_lease') @mock.patch('esi_leap.objects.lease.Lease.save') - def test_cancel(self, mock_save, mock_expire_lease, mock_glu, mock_ro, + @mock.patch('esi_leap.common.notification_utils' + '._emit_api_notification') + def test_cancel(self, mock_notify, mock_save, + mock_expire_lease, mock_glu, mock_ro, mock_lg, mock_sl): lease = lease_obj.Lease(self.context, **self.test_lease_dict) test_node = TestNode('test-node', '12345') @@ -291,6 +329,14 @@ def test_cancel(self, mock_save, mock_expire_lease, mock_glu, mock_ro, lease.cancel() + mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'delete', + obj_fields.NotificationLevel.INFO, + obj_fields.NotificationStatus.START, + mock.ANY, node=mock.ANY), + mock.call(mock.ANY, mock.ANY, 'delete', + obj_fields.NotificationLevel.INFO, + obj_fields.NotificationStatus.END, + mock.ANY, node=mock.ANY)]) mock_sl.assert_not_called() mock_lg.assert_not_called() mock_ro.assert_called_once() @@ -305,7 +351,10 @@ def test_cancel(self, mock_save, mock_expire_lease, mock_glu, mock_ro, @mock.patch('esi_leap.resource_objects.test_node.TestNode.get_lease_uuid') @mock.patch('esi_leap.resource_objects.test_node.TestNode.expire_lease') @mock.patch('esi_leap.objects.lease.Lease.save') - def test_cancel_error(self, mock_save, mock_expire_lease, mock_glu, + @mock.patch('esi_leap.common.notification_utils' + '._emit_api_notification') + def test_cancel_error(self, mock_notify, mock_save, + mock_expire_lease, mock_glu, mock_ro, mock_lg, mock_sl): lease = lease_obj.Lease(self.context, **self.test_lease_dict) test_node = TestNode('test-node', '12345') @@ -316,6 +365,7 @@ def test_cancel_error(self, mock_save, mock_expire_lease, mock_glu, lease.cancel() + mock_notify.assert_not_called() mock_sl.assert_not_called() mock_lg.assert_not_called() mock_ro.assert_called_once() @@ -330,7 +380,10 @@ def test_cancel_error(self, mock_save, mock_expire_lease, mock_glu, @mock.patch('esi_leap.resource_objects.test_node.TestNode.get_lease_uuid') @mock.patch('esi_leap.resource_objects.test_node.TestNode.expire_lease') @mock.patch('esi_leap.objects.lease.Lease.save') - def test_cancel_with_parent(self, mock_save, mock_expire_lease, mock_glu, + @mock.patch('esi_leap.common.notification_utils' + '._emit_api_notification') + def test_cancel_with_parent(self, mock_notify, mock_save, + mock_expire_lease, mock_glu, mock_ro, mock_lg, mock_sl): lease = lease_obj.Lease(self.context, **self.test_lease_parent_lease_dict) @@ -341,6 +394,14 @@ def test_cancel_with_parent(self, mock_save, mock_expire_lease, mock_glu, lease.cancel() + mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'delete', + obj_fields.NotificationLevel.INFO, + obj_fields.NotificationStatus.START, + mock.ANY, node=mock.ANY), + mock.call(mock.ANY, mock.ANY, 'delete', + obj_fields.NotificationLevel.INFO, + obj_fields.NotificationStatus.END, + mock.ANY, node=mock.ANY)]) mock_sl.assert_called_once() mock_lg.assert_called_once() mock_ro.assert_called_once() @@ -353,7 +414,10 @@ def test_cancel_with_parent(self, mock_save, mock_expire_lease, mock_glu, @mock.patch('esi_leap.resource_objects.test_node.TestNode.get_lease_uuid') @mock.patch('esi_leap.resource_objects.test_node.TestNode.expire_lease') @mock.patch('esi_leap.objects.lease.Lease.save') - def test_cancel_no_expire(self, mock_save, mock_expire_lease, mock_glu, + @mock.patch('esi_leap.common.notification_utils' + '._emit_api_notification') + def test_cancel_no_expire(self, mock_notify, mock_save, + mock_expire_lease, mock_glu, mock_ro): lease = lease_obj.Lease(self.context, **self.test_lease_dict) test_node = TestNode('test-node', '12345') @@ -363,6 +427,14 @@ def test_cancel_no_expire(self, mock_save, mock_expire_lease, mock_glu, lease.cancel() + mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'delete', + obj_fields.NotificationLevel.INFO, + obj_fields.NotificationStatus.START, + mock.ANY, node=mock.ANY), + mock.call(mock.ANY, mock.ANY, 'delete', + obj_fields.NotificationLevel.INFO, + obj_fields.NotificationStatus.END, + mock.ANY, node=mock.ANY)]) mock_ro.assert_called_once() mock_glu.assert_called_once() mock_expire_lease.assert_not_called() @@ -499,3 +571,49 @@ def test_resource_object(self, mock_gro): lease.resource_object() mock_gro.assert_called_once_with(lease.resource_type, lease.resource_uuid) + + +class TestLeaseCRUDPayloads(base.DBTestCase): + + def setUp(self): + super(TestLeaseCRUDPayloads, self).setUp() + self.lease = lease_obj.Lease( + id='12345', + name='test_lease', + start_time=datetime.datetime(2016, 7, 16, 19, 20, 30), + end_time=datetime.datetime(2016, 8, 16, 19, 20, 30), + fulfill_time=datetime.datetime(2016, 7, 16, 19, 21, 30), + expire_time=datetime.datetime(2016, 8, 16, 19, 21, 30), + uuid='13921c8d-ce11-4b6d-99ed-10e19d184e5f', + resource_type='test_node', + resource_uuid='111', + project_id='lesseeid', + owner_id='ownerid', + parent_lease_uuid=None, + offer_uuid=None, + properties=None, + ) + self.node = FakeIronicNode() + + def test_lease_crud_payload(self): + payload = lease_obj.LeaseCRUDPayload(self.lease, self.node) + self.assertEqual(self.lease.id, payload.id) + self.assertEqual(self.lease.name, payload.name) + self.assertEqual(self.lease.start_time, payload.start_time) + self.assertEqual(self.lease.end_time, payload.end_time) + self.assertEqual(self.lease.fulfill_time, payload.fulfill_time) + self.assertEqual(self.lease.expire_time, payload.expire_time) + self.assertEqual(self.lease.uuid, payload.uuid) + self.assertEqual(self.lease.resource_type, payload.resource_type) + self.assertEqual(self.lease.resource_uuid, payload.resource_uuid) + self.assertEqual(self.lease.project_id, payload.project_id) + self.assertEqual(self.lease.owner_id, payload.owner_id) + self.assertEqual(self.lease.parent_lease_uuid, + payload.parent_lease_uuid) + self.assertEqual(self.lease.offer_uuid, payload.offer_uuid) + self.assertEqual(self.lease.properties, payload.properties) + self.assertEqual(self.node._node_name, payload.node_name) + self.assertEqual(self.node._uuid, payload.node_uuid) + self.assertEqual(self.node._power_state, payload.node_power_state) + self.assertEqual(self.node._provision_state, + payload.node_provision_state) diff --git a/esi_leap/tests/objects/test_notification.py b/esi_leap/tests/objects/test_notification.py new file mode 100644 index 00000000..156cf7d5 --- /dev/null +++ b/esi_leap/tests/objects/test_notification.py @@ -0,0 +1,288 @@ +# 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 esi_leap.common import exception +from esi_leap.objects import base +from esi_leap.objects import fields +from esi_leap.objects import notification +from esi_leap.tests import base as test_base + +from oslo_serialization import jsonutils +from oslo_versionedobjects import base as versioned_objects_base + + +# Notification object borrowed from Ironic +class TestNotificationBase(test_base.TestCase): + + @versioned_objects_base.VersionedObjectRegistry.register + class TestObject(base.ESILEAPObject): + VERSION = '1.0' + fields = { + 'fake_field_1': fields.StringField(nullable=True), + 'fake_field_2': fields.IntegerField(nullable=True) + } + + @versioned_objects_base.VersionedObjectRegistry.register + class TestObjectMissingField(base.ESILEAPObject): + VERSION = '1.0' + fields = { + 'fake_field_1': fields.StringField(nullable=True), + } + + @versioned_objects_base.VersionedObjectRegistry.register + 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(nullable=True), + 'fake_field_b': fields.IntegerField(nullable=False), + 'an_extra_field': fields.StringField(nullable=False), + 'an_optional_field': fields.IntegerField(nullable=True) + } + + @versioned_objects_base.VersionedObjectRegistry.register + class TestNotificationPayloadEmptySchema( + notification.NotificationPayloadBase): + VERSION = '1.0' + + fields = { + 'fake_field': fields.StringField() + } + + @versioned_objects_base.VersionedObjectRegistry.register + class TestNotification(notification.NotificationBase): + VERSION = '1.0' + fields = { + 'payload': fields.ObjectField('TestNotificationPayload') + } + + @versioned_objects_base.VersionedObjectRegistry.register + 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.assertEqual(jsonutils.dumps(expected_payload, sort_keys=True), + jsonutils.dumps(actual_payload, sort_keys=True)) + + @mock.patch('esi_leap.common.rpc.VERSIONED_NOTIFIER') + def test_emit_notification(self, mock_notifier): + self.config(notification_level='debug', group='notification') + 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', + status=fields.NotificationStatus.START), + level=fields.NotificationLevel.DEBUG, + publisher=notification.NotificationPublisher( + service='esi-leap-api', + host='host'), + payload=payload) + + mock_context = mock.Mock() + notif.emit(mock_context) + + self._verify_notification( + mock_notifier, + mock_context, + expected_event_type='esi_leap.test_object.test.start', + expected_payload={ + 'esi_leap_object.name': 'TestNotificationPayload', + 'esi_leap_object.data': { + 'fake_field_a': 'fake1', + 'fake_field_b': 2, + 'an_extra_field': 'extra', + 'an_optional_field': 1 + }, + 'esi_leap_object.version': '1.0', + 'esi_leap_object.namespace': 'esi_leap'}, + expected_publisher='esi-leap-api.host', + notif_level=fields.NotificationLevel.DEBUG) + + @mock.patch('esi_leap.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', group='notification') + 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', + status=fields.NotificationStatus.START), + level=fields.NotificationLevel.DEBUG, + publisher=notification.NotificationPublisher( + service='esi-leap-api', + host='host'), + payload=payload) + + mock_context = mock.Mock() + notif.emit(mock_context) + + self.assertFalse(mock_notifier.called) + + @mock.patch('esi_leap.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', + status=fields.NotificationStatus.START), + level=fields.NotificationLevel.DEBUG, + publisher=notification.NotificationPublisher( + service='esi-leap-api', + host='host'), + payload=payload) + + mock_context = mock.Mock() + notif.emit(mock_context) + + self.assertFalse(mock_notifier.called) + + @mock.patch('esi_leap.common.rpc.VERSIONED_NOTIFIER') + def test_no_emit_schema_not_populated(self, mock_notifier): + self.config(notification_level='debug', group='notification') + payload = self.TestNotificationPayload(an_extra_field='extra', + an_optional_field=1) + notif = self.TestNotification( + event_type=notification.EventType( + object='test_object', action='test', + status=fields.NotificationStatus.START), + level=fields.NotificationLevel.DEBUG, + publisher=notification.NotificationPublisher( + service='esi-leap-api', + host='host'), + payload=payload) + + mock_context = mock.Mock() + self.assertRaises(exception.NotificationPayloadError, notif.emit, + mock_context) + self.assertFalse(mock_notifier.called) + + @mock.patch('esi_leap.common.rpc.VERSIONED_NOTIFIER') + def test_emit_notification_empty_schema(self, mock_notifier): + self.config(notification_level='debug', group='notification') + payload = self.TestNotificationPayloadEmptySchema(fake_field='123') + notif = self.TestNotificationEmptySchema( + event_type=notification.EventType( + object='test_object', action='test', + status=fields.NotificationStatus.ERROR), + level=fields.NotificationLevel.ERROR, + publisher=notification.NotificationPublisher( + service='esi-leap-api', + host='host'), + payload=payload) + + mock_context = mock.Mock() + notif.emit(mock_context) + + self._verify_notification( + mock_notifier, + mock_context, + expected_event_type='esi_leap.test_object.test.error', + expected_payload={ + 'esi_leap_object.name': 'TestNotificationPayloadEmptySchema', + 'esi_leap_object.data': { + 'fake_field': '123', + }, + 'esi_leap_object.version': '1.0', + 'esi_leap_object.namespace': 'esi_leap'}, + expected_publisher='esi-leap-api.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_required_obj_field(self): + test_obj = self.TestObject(fake_field_1='populated') + # this payload requires missing fake_field_b + payload = self.TestNotificationPayload(an_extra_field='too extra') + self.assertRaises(exception.NotificationSchemaKeyError, + payload.populate_schema, + test_obj=test_obj) + + def test_populate_schema_nullable_field_auto_populates(self): + """Test that nullable fields always end up in the payload.""" + test_obj = self.TestObject(fake_field_2=123) + payload = self.TestNotificationPayload() + payload.populate_schema(test_obj=test_obj) + self.assertIsNone(payload.fake_field_a) + + def test_populate_schema_no_object_field(self): + test_obj = self.TestObjectMissingField(fake_field_1='foo') + payload = self.TestNotificationPayload() + self.assertRaises(exception.NotificationSchemaKeyError, + payload.populate_schema, + test_obj=test_obj) + + def test_event_type_with_status(self): + event_type = notification.EventType( + object="some_obj", action="some_action", status="success") + self.assertEqual("esi_leap.some_obj.some_action.success", + event_type.to_event_type_field()) + + def test_event_type_without_status_fails(self): + event_type = notification.EventType( + object="some_obj", action="some_action") + self.assertRaises(NotImplementedError, + event_type.to_event_type_field) + + def test_event_type_invalid_status_fails(self): + self.assertRaises(ValueError, + notification.EventType, object="some_obj", + action="some_action", status="invalid") + + def test_event_type_make_status_invalid(self): + def make_status_invalid(): + event_type.status = "Roar" + + event_type = notification.EventType( + object='test_object', action='test', status='start') + self.assertRaises(ValueError, make_status_invalid) diff --git a/requirements.txt b/requirements.txt index f47edce6..a47d5dce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,6 +24,7 @@ oslo.service!=1.28.1,>=1.24.0 # Apache-2.0 oslo.upgradecheck>=0.1.0 # Apache-2.0 oslo.utils>=3.33.0 # Apache-2.0 oslo.versionedobjects>=1.31.2 # Apache-2.0 +osprofiler>=1.5.0 # Apache-2.0 netaddr>=0.7.18 # BSD python-ironicclient>=2.3.0 # Apache-2.0 python-keystoneclient>=3.8.0 # Apache-2.0 diff --git a/test-requirements.txt b/test-requirements.txt index 58c2adc6..fca675b5 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -10,6 +10,7 @@ Babel!=2.4.0,>=2.3.4 # BSD PyMySQL>=0.7.6 # MIT License iso8601>=0.1.11 # MIT oslotest>=3.2.0 # Apache-2.0 +osprofiler>=1.5.0 # Apache-2.0 stestr>=1.0.0 # Apache-2.0 psycopg2>=2.6.2 # LGPL/ZPL testtools>=2.2.0 # MIT