diff --git a/doc/source/deploy/notifications.rst b/doc/source/deploy/notifications.rst index 5c9f89d932..cf7510f88e 100644 --- a/doc/source/deploy/notifications.rst +++ b/doc/source/deploy/notifications.rst @@ -69,6 +69,142 @@ The notifications that ironic emits are described here. They are listed (alphabetically) by service first, then by event_type. All examples below show payloads before serialization to JSON. +------------------------ +ironic-api notifications +------------------------ + +Resources CRUD notifications +---------------------------- + +These notifications are emitted from API service when ironic resources are +modified as part of create, update, or delete (CRUD) [3]_ procedures. All +CRUD notifications are emitted at INFO level, except for "error" status that +is emitted at ERROR level. + +List of CRUD notifications for chassis: + +* ``baremetal.chassis.create.start`` +* ``baremetal.chassis.create.end`` +* ``baremetal.chassis.create.error`` +* ``baremetal.chassis.update.start`` +* ``baremetal.chassis.update.end`` +* ``baremetal.chassis.update.error`` +* ``baremetal.chassis.delete.start`` +* ``baremetal.chassis.delete.end`` +* ``baremetal.chassis.delete.error`` + +Example of chassis CRUD notification:: + + { + "priority": "info", + "payload":{ + "ironic_object.namespace":"ironic", + "ironic_object.name":"ChassisCRUDPayload", + "ironic_object.version":"1.0", + "ironic_object.data":{ + "created_at": "2016-04-10T10:13:03+00:00", + "description": "bare 28", + "extra": {}, + "updated_at": "2016-04-27T21:11:03+00:00", + "uuid": "1910f669-ce8b-43c2-b1d8-cf3d65be815e", + } + }, + "event_type":"baremetal.chassis.update.end", + "publisher_id":"ironic-api.hostname02" + } + +List of CRUD notifications for node: + +* ``baremetal.node.create.start`` +* ``baremetal.node.create.end`` +* ``baremetal.node.create.error`` +* ``baremetal.node.update.start`` +* ``baremetal.node.update.end`` +* ``baremetal.node.update.error`` +* ``baremetal.node.delete.start`` +* ``baremetal.node.delete.end`` +* ``baremetal.node.delete.error`` + +Example of node CRUD notification:: + + { + "priority": "info", + "payload":{ + "ironic_object.namespace":"ironic", + "ironic_object.name":"NodeCRUDPayload", + "ironic_object.version":"1.0", + "ironic_object.data":{ + "chassis_uuid": "db0eef9d-45b2-4dc0-94a8-fc283c01171f", + "clean_step": None, + "console_enabled": False, + "created_at": "2016-01-26T20:41:03+00:00", + "driver": "fake", + "driver_info": { + "host": "192.168.0.111"}, + "extra": {}, + "inspection_finished_at": None, + "inspection_started_at": None, + "instance_info": {}, + "instance_uuid": None, + "last_error": None, + "maintenance": False, + "maintenance_reason": None, + "network_interface": "flat", + "name": None, + "power_state": "power off", + "properties": { + "memory_mb": 4096, + "cpu_arch": "x86_64", + "local_gb": 10, + "cpus": 8}, + "provision_state": "deploying", + "provision_updated_at": "2016-01-27T20:41:03+00:00", + "resource_class": None, + "target_power_state": None, + "target_provision_state": "active", + "updated_at": "2016-01-27T20:41:03+00:00", + "uuid": "1be26c0b-03f2-4d2e-ae87-c02d7f33c123", + } + }, + "event_type":"baremetal.node.update.end", + "publisher_id":"ironic-api.hostname02" + } + +List of CRUD notifications for port: + +* ``baremetal.port.create.start`` +* ``baremetal.port.create.end`` +* ``baremetal.port.create.error`` +* ``baremetal.port.update.start`` +* ``baremetal.port.update.end`` +* ``baremetal.port.update.error`` +* ``baremetal.port.delete.start`` +* ``baremetal.port.delete.end`` +* ``baremetal.port.delete.error`` + +Example of port CRUD notification:: + + { + "priority": "info", + "payload":{ + "ironic_object.namespace":"ironic", + "ironic_object.name":"PortCRUDPayload", + "ironic_object.version":"1.0", + "ironic_object.data":{ + "address": "77:66:23:34:11:b7", + "created_at": "2016-02-11T15:23:03+00:00", + "node_uuid": "5b236cab-ad4e-4220-b57c-e827e858745a", + "extra": {}, + "local_link_connection": {}, + "pxe_enabled": True, + "updated_at": "2016-03-27T20:41:03+00:00", + "uuid": "1be26c0b-03f2-4d2e-ae87-c02d7f33c123", + } + }, + "event_type":"baremetal.port.update.end", + "publisher_id":"ironic-api.hostname02" + } + ------------------------------ ironic-conductor notifications ------------------------------ @@ -257,3 +393,4 @@ indicate a node's provision states before state change, "event" is the FSM .. [1] https://wiki.openstack.org/wiki/LoggingStandards#Log_level_definitions .. [2] https://www.rabbitmq.com/documentation.html +.. [3] https://en.wikipedia.org/wiki/Create,_read,_update_and_delete diff --git a/ironic/api/controllers/v1/chassis.py b/ironic/api/controllers/v1/chassis.py index 6f837e221b..467584494c 100644 --- a/ironic/api/controllers/v1/chassis.py +++ b/ironic/api/controllers/v1/chassis.py @@ -16,6 +16,7 @@ import datetime from ironic_lib import metrics_utils +from oslo_utils import uuidutils import pecan from pecan import rest from six.moves import http_client @@ -26,6 +27,7 @@ from ironic.api.controllers import base from ironic.api.controllers import link from ironic.api.controllers.v1 import collection from ironic.api.controllers.v1 import node +from ironic.api.controllers.v1 import notification_utils as notify from ironic.api.controllers.v1 import types from ironic.api.controllers.v1 import utils as api_utils from ironic.api import expose @@ -270,12 +272,19 @@ class ChassisController(rest.RestController): :param chassis: a chassis within the request body. """ - cdict = pecan.request.context.to_policy_values() + context = pecan.request.context + cdict = context.to_policy_values() policy.authorize('baremetal:chassis:create', cdict, cdict) - new_chassis = objects.Chassis(pecan.request.context, - **chassis.as_dict()) - new_chassis.create() + # NOTE(yuriyz): UUID is mandatory for notifications payload + if not chassis.uuid: + chassis.uuid = uuidutils.generate_uuid() + + new_chassis = objects.Chassis(context, **chassis.as_dict()) + notify.emit_start_notification(context, new_chassis, 'create') + with notify.handle_error_notification(context, new_chassis, 'create'): + new_chassis.create() + notify.emit_end_notification(context, new_chassis, 'create') # Set the HTTP Location Header pecan.response.location = link.build_url('chassis', new_chassis.uuid) return Chassis.convert_with_links(new_chassis) @@ -289,11 +298,11 @@ class ChassisController(rest.RestController): :param chassis_uuid: UUID of a chassis. :param patch: a json PATCH document to apply to this chassis. """ - cdict = pecan.request.context.to_policy_values() + context = pecan.request.context + cdict = context.to_policy_values() policy.authorize('baremetal:chassis:update', cdict, cdict) - rpc_chassis = objects.Chassis.get_by_uuid(pecan.request.context, - chassis_uuid) + rpc_chassis = objects.Chassis.get_by_uuid(context, chassis_uuid) try: chassis = Chassis( **api_utils.apply_jsonpatch(rpc_chassis.as_dict(), patch)) @@ -313,7 +322,10 @@ class ChassisController(rest.RestController): if rpc_chassis[field] != patch_val: rpc_chassis[field] = patch_val - rpc_chassis.save() + notify.emit_start_notification(context, rpc_chassis, 'update') + with notify.handle_error_notification(context, rpc_chassis, 'update'): + rpc_chassis.save() + notify.emit_end_notification(context, rpc_chassis, 'update') return Chassis.convert_with_links(rpc_chassis) @METRICS.timer('ChassisController.delete') @@ -323,9 +335,12 @@ class ChassisController(rest.RestController): :param chassis_uuid: UUID of a chassis. """ - cdict = pecan.request.context.to_policy_values() + context = pecan.request.context + cdict = context.to_policy_values() policy.authorize('baremetal:chassis:delete', cdict, cdict) - rpc_chassis = objects.Chassis.get_by_uuid(pecan.request.context, - chassis_uuid) - rpc_chassis.destroy() + rpc_chassis = objects.Chassis.get_by_uuid(context, chassis_uuid) + notify.emit_start_notification(context, rpc_chassis, 'delete') + with notify.handle_error_notification(context, rpc_chassis, 'delete'): + rpc_chassis.destroy() + notify.emit_end_notification(context, rpc_chassis, 'delete') diff --git a/ironic/api/controllers/v1/node.py b/ironic/api/controllers/v1/node.py index 8f0d833180..7ea56a9bb5 100644 --- a/ironic/api/controllers/v1/node.py +++ b/ironic/api/controllers/v1/node.py @@ -29,6 +29,7 @@ from wsme import types as wtypes from ironic.api.controllers import base from ironic.api.controllers import link from ironic.api.controllers.v1 import collection +from ironic.api.controllers.v1 import notification_utils as notify from ironic.api.controllers.v1 import port from ironic.api.controllers.v1 import portgroup from ironic.api.controllers.v1 import types @@ -776,7 +777,9 @@ class Node(base.APIBase): # that as_dict() will contain chassis_id field when converting it # before saving it in the database. self.fields.append('chassis_id') - setattr(self, 'chassis_uuid', kwargs.get('chassis_id', wtypes.Unset)) + if 'chassis_uuid' not in kwargs: + setattr(self, 'chassis_uuid', kwargs.get('chassis_id', + wtypes.Unset)) @staticmethod def _convert_with_links(node, url, fields=None, show_states_links=True, @@ -1395,7 +1398,8 @@ class NodesController(rest.RestController): :param node: a node within the request body. """ - cdict = pecan.request.context.to_policy_values() + context = pecan.request.context + cdict = context.to_policy_values() policy.authorize('baremetal:node:create', cdict, cdict) if self.from_chassis: @@ -1431,13 +1435,19 @@ class NodesController(rest.RestController): self._check_names_acceptable([node.name], error_msg) node.provision_state = api_utils.initial_node_provision_state() - new_node = objects.Node(pecan.request.context, - **node.as_dict()) - new_node = pecan.request.rpcapi.create_node( - pecan.request.context, new_node, topic) + new_node = objects.Node(context, **node.as_dict()) + notify.emit_start_notification(context, new_node, 'create', + chassis_uuid=node.chassis_uuid) + with notify.handle_error_notification(context, new_node, 'create', + chassis_uuid=node.chassis_uuid): + new_node = pecan.request.rpcapi.create_node(context, + new_node, topic) # Set the HTTP Location Header pecan.response.location = link.build_url('nodes', new_node.uuid) - return Node.convert_with_links(new_node) + api_node = Node.convert_with_links(new_node) + notify.emit_end_notification(context, new_node, 'create', + chassis_uuid=api_node.chassis_uuid) + return api_node @METRICS.timer('NodesController.patch') @wsme.validate(types.uuid, [NodePatchType]) @@ -1448,7 +1458,8 @@ class NodesController(rest.RestController): :param node_ident: UUID or logical name of a node. :param patch: a json PATCH document to apply to this node. """ - cdict = pecan.request.context.to_policy_values() + context = pecan.request.context + cdict = context.to_policy_values() policy.authorize('baremetal:node:update', cdict, cdict) if self.from_chassis: @@ -1508,10 +1519,19 @@ class NodesController(rest.RestController): e.code = http_client.BAD_REQUEST raise self._check_driver_changed_and_console_enabled(rpc_node, node_ident) - new_node = pecan.request.rpcapi.update_node( - pecan.request.context, rpc_node, topic) - return Node.convert_with_links(new_node) + notify.emit_start_notification(context, rpc_node, 'update', + chassis_uuid=node.chassis_uuid) + with notify.handle_error_notification(context, rpc_node, 'update', + chassis_uuid=node.chassis_uuid): + new_node = pecan.request.rpcapi.update_node(context, + rpc_node, topic) + + api_node = Node.convert_with_links(new_node) + notify.emit_end_notification(context, new_node, 'update', + chassis_uuid=api_node.chassis_uuid) + + return api_node @METRICS.timer('NodesController.delete') @expose.expose(None, types.uuid_or_name, @@ -1521,19 +1541,28 @@ class NodesController(rest.RestController): :param node_ident: UUID or logical name of a node. """ - cdict = pecan.request.context.to_policy_values() + context = pecan.request.context + cdict = context.to_policy_values() policy.authorize('baremetal:node:delete', cdict, cdict) if self.from_chassis: raise exception.OperationNotPermitted() rpc_node = api_utils.get_rpc_node(node_ident) + chassis_uuid = None + if rpc_node.chassis_id: + chassis_uuid = objects.Chassis.get_by_id(context, + rpc_node.chassis_id).uuid + notify.emit_start_notification(context, rpc_node, 'delete', + chassis_uuid=chassis_uuid) + with notify.handle_error_notification(context, rpc_node, 'delete', + chassis_uuid=chassis_uuid): + try: + topic = pecan.request.rpcapi.get_topic_for(rpc_node) + except exception.NoValidHost as e: + e.code = http_client.BAD_REQUEST + raise - try: - topic = pecan.request.rpcapi.get_topic_for(rpc_node) - except exception.NoValidHost as e: - e.code = http_client.BAD_REQUEST - raise - - pecan.request.rpcapi.destroy_node(pecan.request.context, - rpc_node.uuid, topic) + pecan.request.rpcapi.destroy_node(context, rpc_node.uuid, topic) + notify.emit_end_notification(context, rpc_node, 'delete', + chassis_uuid=chassis_uuid) diff --git a/ironic/api/controllers/v1/notification_utils.py b/ironic/api/controllers/v1/notification_utils.py new file mode 100644 index 0000000000..cc4d2e25e2 --- /dev/null +++ b/ironic/api/controllers/v1/notification_utils.py @@ -0,0 +1,150 @@ +# 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 wsme import types as wtypes + +from ironic.common import exception +from ironic.common.i18n import _ +from ironic.objects import chassis as chassis_objects +from ironic.objects import fields +from ironic.objects import node as node_objects +from ironic.objects import notification +from ironic.objects import port as port_objects + +LOG = log.getLogger(__name__) +CONF = cfg.CONF + + +CRUD_NOTIFY_OBJ = { + 'chassis': (chassis_objects.ChassisCRUDNotification, + chassis_objects.ChassisCRUDPayload), + 'node': (node_objects.NodeCRUDNotification, + node_objects.NodeCRUDPayload), + 'port': (port_objects.PortCRUDNotification, + port_objects.PortCRUDPayload) +} + + +def _emit_api_notification(context, obj, action, level, status, **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 + `ironic.objects.fields.NotificationLevel.ALL` + :param status: Status to go in the EventType. One of + `ironic.objects.fields.NotificationStatus.ALL` + :param **kwargs: kwargs to use when creating the notification payload. + """ + resource = obj.__class__.__name__.lower() + # value wsme.Unset can be passed from API representation of resource + extra_args = {k: (v if v != wtypes.Unset else None) + for k, v in kwargs.items()} + try: + try: + if resource not in CRUD_NOTIFY_OBJ: + notification_name = payload_name = _("is not defined") + raise KeyError(_("Unsupported resource: %s") % resource) + 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 baremetal.%(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) + if resource == 'node': + notification.mask_secrets(payload) + notification_method( + publisher=notification.NotificationPublisher( + service='ironic-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) + except Exception as e: + exception_values['error'] = e + LOG.exception(exception_message, exception_values) + + +def emit_start_notification(context, obj, action, **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, + **kwargs) + + +@contextlib.contextmanager +def handle_error_notification(context, obj, action, **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, + **kwargs) + + +def emit_end_notification(context, obj, action, **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, + **kwargs) diff --git a/ironic/api/controllers/v1/port.py b/ironic/api/controllers/v1/port.py index 1148fbc8e9..512179e923 100644 --- a/ironic/api/controllers/v1/port.py +++ b/ironic/api/controllers/v1/port.py @@ -26,6 +26,7 @@ from wsme import types as wtypes from ironic.api.controllers import base from ironic.api.controllers import link from ironic.api.controllers.v1 import collection +from ironic.api.controllers.v1 import notification_utils as notify from ironic.api.controllers.v1 import types from ironic.api.controllers.v1 import utils as api_utils from ironic.api import expose @@ -494,7 +495,8 @@ class PortsController(rest.RestController): :param port: a port within the request body. :raises: NotAcceptable, HTTPNotFound, Conflict """ - cdict = pecan.request.context.to_policy_values() + context = pecan.request.context + cdict = context.to_policy_values() policy.authorize('baremetal:port:create', cdict, cdict) if self.parent_node_ident or self.parent_portgroup_ident: @@ -512,7 +514,7 @@ class PortsController(rest.RestController): vif = extra.get('vif_port_id') if extra else None if (pdict.get('portgroup_uuid') and (pdict.get('pxe_enabled') or vif)): - rpc_pg = objects.Portgroup.get_by_uuid(pecan.request.context, + rpc_pg = objects.Portgroup.get_by_uuid(context, pdict['portgroup_uuid']) if not rpc_pg.standalone_ports_supported: msg = _("Port group %s doesn't support standalone ports. " @@ -522,10 +524,19 @@ class PortsController(rest.RestController): raise exception.Conflict( msg % pdict['portgroup_uuid']) - new_port = objects.Port(pecan.request.context, - **pdict) + # NOTE(yuriyz): UUID is mandatory for notifications payload + if not pdict.get('uuid'): + pdict['uuid'] = uuidutils.generate_uuid() - new_port.create() + new_port = objects.Port(context, **pdict) + + notify.emit_start_notification(context, new_port, 'create', + node_uuid=port.node_uuid) + with notify.handle_error_notification(context, new_port, 'create', + node_uuid=port.node_uuid): + new_port.create() + notify.emit_end_notification(context, new_port, 'create', + node_uuid=port.node_uuid) # Set the HTTP Location Header pecan.response.location = link.build_url('ports', new_port.uuid) return Port.convert_with_links(new_port) @@ -540,7 +551,8 @@ class PortsController(rest.RestController): :param patch: a json PATCH document to apply to this port. :raises: NotAcceptable, HTTPNotFound """ - cdict = pecan.request.context.to_policy_values() + context = pecan.request.context + cdict = context.to_policy_values() policy.authorize('baremetal:port:update', cdict, cdict) if self.parent_node_ident or self.parent_portgroup_ident: @@ -559,7 +571,7 @@ class PortsController(rest.RestController): not api_utils.allow_portgroups_subcontrollers()): raise exception.NotAcceptable() - rpc_port = objects.Port.get_by_uuid(pecan.request.context, port_uuid) + rpc_port = objects.Port.get_by_uuid(context, port_uuid) try: port_dict = rpc_port.as_dict() # NOTE(lucasagomes): @@ -591,14 +603,20 @@ class PortsController(rest.RestController): if rpc_port[field] != patch_val: rpc_port[field] = patch_val - rpc_node = objects.Node.get_by_id(pecan.request.context, - rpc_port.node_id) - topic = pecan.request.rpcapi.get_topic_for(rpc_node) + rpc_node = objects.Node.get_by_id(context, rpc_port.node_id) + notify.emit_start_notification(context, rpc_port, 'update', + node_uuid=rpc_node.uuid) + with notify.handle_error_notification(context, rpc_port, 'update', + node_uuid=rpc_node.uuid): + topic = pecan.request.rpcapi.get_topic_for(rpc_node) + new_port = pecan.request.rpcapi.update_port(context, rpc_port, + topic) - new_port = pecan.request.rpcapi.update_port( - pecan.request.context, rpc_port, topic) + api_port = Port.convert_with_links(new_port) + notify.emit_end_notification(context, new_port, 'update', + node_uuid=api_port.node_uuid) - return Port.convert_with_links(new_port) + return api_port @METRICS.timer('PortsController.delete') @expose.expose(None, types.uuid, status_code=http_client.NO_CONTENT) @@ -608,16 +626,20 @@ class PortsController(rest.RestController): :param port_uuid: UUID of a port. :raises OperationNotPermitted, HTTPNotFound """ - cdict = pecan.request.context.to_policy_values() + context = pecan.request.context + cdict = context.to_policy_values() policy.authorize('baremetal:port:delete', cdict, cdict) if self.parent_node_ident or self.parent_portgroup_ident: raise exception.OperationNotPermitted() - rpc_port = objects.Port.get_by_uuid(pecan.request.context, - port_uuid) - rpc_node = objects.Node.get_by_id(pecan.request.context, - rpc_port.node_id) - topic = pecan.request.rpcapi.get_topic_for(rpc_node) - pecan.request.rpcapi.destroy_port(pecan.request.context, - rpc_port, topic) + rpc_port = objects.Port.get_by_uuid(context, port_uuid) + rpc_node = objects.Node.get_by_id(context, rpc_port.node_id) + notify.emit_start_notification(context, rpc_port, 'delete', + node_uuid=rpc_node.uuid) + with notify.handle_error_notification(context, rpc_port, 'delete', + node_uuid=rpc_node.uuid): + topic = pecan.request.rpcapi.get_topic_for(rpc_node) + pecan.request.rpcapi.destroy_port(context, rpc_port, topic) + notify.emit_end_notification(context, rpc_port, 'delete', + node_uuid=rpc_node.uuid) diff --git a/ironic/conductor/notification_utils.py b/ironic/conductor/notification_utils.py index 3fad981194..ab75df75e9 100644 --- a/ironic/conductor/notification_utils.py +++ b/ironic/conductor/notification_utils.py @@ -13,7 +13,6 @@ from oslo_config import cfg from oslo_log import log from oslo_messaging import exceptions as oslo_msg_exc -from oslo_utils import strutils from oslo_versionedobjects import exception as oslo_vo_exc from ironic.common import exception @@ -26,17 +25,6 @@ LOG = log.getLogger(__name__) CONF = cfg.CONF -def mask_secrets(payload): - """Remove secrets from payload object.""" - mask = '******' - if hasattr(payload, 'instance_info'): - payload.instance_info = strutils.mask_dict_password( - payload.instance_info, mask) - if 'image_url' in payload.instance_info: - payload.instance_info['image_url'] = mask - # TODO(yuriyz): add "driver_info" support - - def _emit_conductor_node_notification(task, notification_method, payload_method, action, level, status, **kwargs): @@ -70,7 +58,7 @@ def _emit_conductor_node_notification(task, notification_method, "payload_method %(payload_method)s, error " "%(error)s")) payload = payload_method(task.node, **kwargs) - mask_secrets(payload) + notification.mask_secrets(payload) notification_method( publisher=notification.NotificationPublisher( service='ironic-conductor', host=CONF.host), diff --git a/ironic/objects/chassis.py b/ironic/objects/chassis.py index 102dc5a96c..fe7575cb71 100644 --- a/ironic/objects/chassis.py +++ b/ironic/objects/chassis.py @@ -21,6 +21,7 @@ from ironic.common import exception from ironic.db import api as dbapi from ironic.objects import base from ironic.objects import fields as object_fields +from ironic.objects import notification @base.IronicObjectRegistry.register @@ -195,3 +196,40 @@ class Chassis(base.IronicObject, object_base.VersionedObjectDictCompat): """ current = self.__class__.get_by_uuid(self._context, uuid=self.uuid) self.obj_refresh(current) + + +@base.IronicObjectRegistry.register +class ChassisCRUDNotification(notification.NotificationBase): + """Notification emitted when ironic creates, updates, deletes a chassis.""" + # Version 1.0: Initial version + VERSION = '1.0' + + fields = { + 'payload': object_fields.ObjectField('ChassisCRUDPayload') + } + + +@base.IronicObjectRegistry.register +class ChassisCRUDPayload(notification.NotificationPayloadBase): + # Version 1.0: Initial version + VERSION = '1.0' + + SCHEMA = { + 'description': ('chassis', 'description'), + 'extra': ('chassis', 'extra'), + 'created_at': ('chassis', 'created_at'), + 'updated_at': ('chassis', 'updated_at'), + 'uuid': ('chassis', 'uuid') + } + + fields = { + 'description': object_fields.StringField(nullable=True), + 'extra': object_fields.FlexibleDictField(nullable=True), + 'created_at': object_fields.DateTimeField(nullable=True), + 'updated_at': object_fields.DateTimeField(nullable=True), + 'uuid': object_fields.UUIDField() + } + + def __init__(self, chassis, **kwargs): + super(ChassisCRUDPayload, self).__init__(**kwargs) + self.populate_schema(chassis=chassis) diff --git a/ironic/objects/node.py b/ironic/objects/node.py index bd63a17707..d66f99239d 100644 --- a/ironic/objects/node.py +++ b/ironic/objects/node.py @@ -439,10 +439,11 @@ class NodePayload(notification.NotificationPayloadBase): # Version 1.0: Initial version, based off of Node version 1.18. # Version 1.1: Type of network_interface changed to just nullable string # similar to version 1.20 of Node. - VERSION = '1.1' + # Version 1.2: Add nullable to console_enabled and maintenance. + VERSION = '1.2' fields = { 'clean_step': object_fields.FlexibleDictField(nullable=True), - 'console_enabled': object_fields.BooleanField(), + 'console_enabled': object_fields.BooleanField(nullable=True), 'created_at': object_fields.DateTimeField(nullable=True), 'driver': object_fields.StringField(nullable=True), 'extra': object_fields.FlexibleDictField(nullable=True), @@ -450,7 +451,7 @@ class NodePayload(notification.NotificationPayloadBase): 'inspection_started_at': object_fields.DateTimeField(nullable=True), 'instance_uuid': object_fields.UUIDField(nullable=True), 'last_error': object_fields.StringField(nullable=True), - 'maintenance': object_fields.BooleanField(), + 'maintenance': object_fields.BooleanField(nullable=True), 'maintenance_reason': object_fields.StringField(nullable=True), 'network_interface': object_fields.StringField(nullable=True), 'name': object_fields.StringField(nullable=True), @@ -486,7 +487,8 @@ class NodeSetPowerStatePayload(NodePayload): """Payload schema for when ironic changes a node's power state.""" # Version 1.0: Initial version # Version 1.1: Parent NodePayload version 1.1 - VERSION = '1.1' + # Version 1.2: Parent NodePayload version 1.2 + VERSION = '1.2' fields = { # "to_power" indicates the future target_power_state of the node. A @@ -528,7 +530,8 @@ class NodeCorrectedPowerStatePayload(NodePayload): """ # Version 1.0: Initial version # Version 1.1: Parent NodePayload version 1.1 - VERSION = '1.1' + # Version 1.2: Parent NodePayload version 1.2 + VERSION = '1.2' fields = { 'from_power': object_fields.StringField(nullable=True) @@ -555,7 +558,8 @@ class NodeSetProvisionStatePayload(NodePayload): """Payload schema for when ironic changes a node provision state.""" # Version 1.0: Initial version # Version 1.1: Parent NodePayload version 1.1 - VERSION = '1.1' + # Version 1.2: Parent NodePayload version 1.2 + VERSION = '1.2' SCHEMA = dict(NodePayload.SCHEMA, **{'instance_info': ('node', 'instance_info')}) @@ -572,3 +576,34 @@ class NodeSetProvisionStatePayload(NodePayload): super(NodeSetProvisionStatePayload, self).__init__( node, event=event, previous_provision_state=prev_state, previous_target_provision_state=prev_target) + + +@base.IronicObjectRegistry.register +class NodeCRUDNotification(notification.NotificationBase): + """Notification emitted when ironic creates, updates or deletes a node.""" + # Version 1.0: Initial version + VERSION = '1.0' + + fields = { + 'payload': object_fields.ObjectField('NodeCRUDPayload') + } + + +@base.IronicObjectRegistry.register +class NodeCRUDPayload(NodePayload): + """Payload schema for when ironic creates, updates or deletes a node.""" + # Version 1.0: Initial version + VERSION = '1.0' + + SCHEMA = dict(NodePayload.SCHEMA, + **{'instance_info': ('node', 'instance_info'), + 'driver_info': ('node', 'driver_info')}) + + fields = { + 'chassis_uuid': object_fields.UUIDField(nullable=True), + 'instance_info': object_fields.FlexibleDictField(nullable=True), + 'driver_info': object_fields.FlexibleDictField(nullable=True) + } + + def __init__(self, node, chassis_uuid): + super(NodeCRUDPayload, self).__init__(node, chassis_uuid=chassis_uuid) diff --git a/ironic/objects/notification.py b/ironic/objects/notification.py index 91f44bd147..650e3ebd84 100644 --- a/ironic/objects/notification.py +++ b/ironic/objects/notification.py @@ -10,6 +10,7 @@ # License for the specific language governing permissions and limitations # under the License. from oslo_config import cfg +from oslo_utils import strutils from ironic.common import exception from ironic.common import rpc @@ -182,3 +183,16 @@ class NotificationPublisher(base.IronicObject): 'service': fields.StringField(nullable=False), 'host': fields.StringField(nullable=False) } + + +def mask_secrets(payload): + """Remove secrets from payload object.""" + mask = '******' + if hasattr(payload, 'instance_info'): + payload.instance_info = strutils.mask_dict_password( + payload.instance_info, mask) + if 'image_url' in payload.instance_info: + payload.instance_info['image_url'] = mask + if hasattr(payload, 'driver_info'): + payload.driver_info = strutils.mask_dict_password( + payload.driver_info, mask) diff --git a/ironic/objects/port.py b/ironic/objects/port.py index 98a83cd27e..11d2b050a0 100644 --- a/ironic/objects/port.py +++ b/ironic/objects/port.py @@ -22,6 +22,7 @@ from ironic.common import exception from ironic.db import api as dbapi from ironic.objects import base from ironic.objects import fields as object_fields +from ironic.objects import notification @base.IronicObjectRegistry.register @@ -289,3 +290,47 @@ class Port(base.IronicObject, object_base.VersionedObjectDictCompat): """ current = self.__class__.get_by_uuid(self._context, uuid=self.uuid) self.obj_refresh(current) + + +@base.IronicObjectRegistry.register +class PortCRUDNotification(notification.NotificationBase): + """Notification emitted when ironic creates, updates or deletes a port.""" + # Version 1.0: Initial version + VERSION = '1.0' + + fields = { + 'payload': object_fields.ObjectField('PortCRUDPayload') + } + + +@base.IronicObjectRegistry.register +class PortCRUDPayload(notification.NotificationPayloadBase): + # Version 1.0: Initial version + VERSION = '1.0' + + SCHEMA = { + 'address': ('port', 'address'), + 'extra': ('port', 'extra'), + 'local_link_connection': ('port', 'local_link_connection'), + 'pxe_enabled': ('port', 'pxe_enabled'), + 'created_at': ('port', 'created_at'), + 'updated_at': ('port', 'updated_at'), + 'uuid': ('port', 'uuid') + } + + fields = { + 'address': object_fields.MACAddressField(nullable=True), + 'extra': object_fields.FlexibleDictField(nullable=True), + 'local_link_connection': object_fields.FlexibleDictField( + nullable=True), + 'pxe_enabled': object_fields.BooleanField(nullable=True), + 'node_uuid': object_fields.UUIDField(), + 'created_at': object_fields.DateTimeField(nullable=True), + 'updated_at': object_fields.DateTimeField(nullable=True), + 'uuid': object_fields.UUIDField() + # TODO(yuriyz): add "portgroup_uuid" field with portgroup notifications + } + + def __init__(self, port, node_uuid): + super(PortCRUDPayload, self).__init__(node_uuid=node_uuid) + self.populate_schema(port=port) diff --git a/ironic/tests/unit/api/v1/test_chassis.py b/ironic/tests/unit/api/v1/test_chassis.py index d0f55e4066..87412e1d8c 100644 --- a/ironic/tests/unit/api/v1/test_chassis.py +++ b/ironic/tests/unit/api/v1/test_chassis.py @@ -29,6 +29,9 @@ from wsme import types as wtypes from ironic.api.controllers import base as api_base from ironic.api.controllers import v1 as api_v1 from ironic.api.controllers.v1 import chassis as api_chassis +from ironic.api.controllers.v1 import notification_utils +from ironic import objects +from ironic.objects import fields as obj_fields from ironic.tests import base from ironic.tests.unit.api import base as test_api_base from ironic.tests.unit.api import utils as apiutils @@ -252,8 +255,9 @@ class TestPatch(test_api_base.BaseApiTest): self.assertEqual('application/json', response.content_type) self.assertTrue(response.json['error_message']) + @mock.patch.object(notification_utils, '_emit_api_notification') @mock.patch.object(timeutils, 'utcnow') - def test_replace_singular(self, mock_utcnow): + def test_replace_singular(self, mock_utcnow, mock_notify): chassis = obj_utils.get_test_chassis(self.context) description = 'chassis-new-description' test_time = datetime.datetime(2000, 1, 1, 0, 0) @@ -269,6 +273,27 @@ class TestPatch(test_api_base.BaseApiTest): return_updated_at = timeutils.parse_isotime( result['updated_at']).replace(tzinfo=None) self.assertEqual(test_time, return_updated_at) + mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'update', + obj_fields.NotificationLevel.INFO, + obj_fields.NotificationStatus.START), + mock.call(mock.ANY, mock.ANY, 'update', + obj_fields.NotificationLevel.INFO, + obj_fields.NotificationStatus.END)]) + + @mock.patch.object(notification_utils, '_emit_api_notification') + @mock.patch.object(objects.Chassis, 'save') + def test_update_error(self, mock_save, mock_notify): + mock_save.side_effect = Exception() + chassis = obj_utils.get_test_chassis(self.context) + self.patch_json('/chassis/%s' % chassis.uuid, [{'path': '/description', + 'value': 'new', 'op': 'replace'}], + expect_errors=True) + mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'update', + obj_fields.NotificationLevel.INFO, + obj_fields.NotificationStatus.START), + mock.call(mock.ANY, mock.ANY, 'update', + obj_fields.NotificationLevel.ERROR, + obj_fields.NotificationStatus.ERROR)]) def test_replace_multi(self): extra = {"foo1": "bar1", "foo2": "bar2", "foo3": "bar3"} @@ -386,8 +411,9 @@ class TestPatch(test_api_base.BaseApiTest): class TestPost(test_api_base.BaseApiTest): + @mock.patch.object(notification_utils, '_emit_api_notification') @mock.patch.object(timeutils, 'utcnow') - def test_create_chassis(self, mock_utcnow): + def test_create_chassis(self, mock_utcnow, mock_notify): cdict = apiutils.chassis_post_data() test_time = datetime.datetime(2000, 1, 1, 0, 0) mock_utcnow.return_value = test_time @@ -405,6 +431,25 @@ class TestPost(test_api_base.BaseApiTest): expected_location = '/v1/chassis/%s' % cdict['uuid'] self.assertEqual(urlparse.urlparse(response.location).path, expected_location) + mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'create', + obj_fields.NotificationLevel.INFO, + obj_fields.NotificationStatus.START), + mock.call(mock.ANY, mock.ANY, 'create', + obj_fields.NotificationLevel.INFO, + obj_fields.NotificationStatus.END)]) + + @mock.patch.object(notification_utils, '_emit_api_notification') + @mock.patch.object(objects.Chassis, 'create') + def test_create_chassis_error(self, mock_save, mock_notify): + mock_save.side_effect = Exception() + cdict = apiutils.chassis_post_data() + self.post_json('/chassis', cdict, expect_errors=True) + mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'create', + obj_fields.NotificationLevel.INFO, + obj_fields.NotificationStatus.START), + mock.call(mock.ANY, mock.ANY, 'create', + obj_fields.NotificationLevel.ERROR, + obj_fields.NotificationStatus.ERROR)]) def test_create_chassis_doesnt_contain_id(self): with mock.patch.object(self.dbapi, 'create_chassis', @@ -417,7 +462,9 @@ class TestPost(test_api_base.BaseApiTest): # Check that 'id' is not in first arg of positional args self.assertNotIn('id', cc_mock.call_args[0][0]) - def test_create_chassis_generate_uuid(self): + @mock.patch.object(notification_utils.LOG, 'exception', autospec=True) + @mock.patch.object(notification_utils.LOG, 'warning', autospec=True) + def test_create_chassis_generate_uuid(self, mock_warning, mock_exception): cdict = apiutils.chassis_post_data() del cdict['uuid'] self.post_json('/chassis', cdict) @@ -425,6 +472,8 @@ class TestPost(test_api_base.BaseApiTest): self.assertEqual(cdict['description'], result['chassis'][0]['description']) self.assertTrue(uuidutils.is_uuid_like(result['chassis'][0]['uuid'])) + self.assertFalse(mock_warning.called) + self.assertFalse(mock_exception.called) def test_post_nodes_subresource(self): chassis = obj_utils.create_test_chassis(self.context) @@ -472,7 +521,8 @@ class TestPost(test_api_base.BaseApiTest): class TestDelete(test_api_base.BaseApiTest): - def test_delete_chassis(self): + @mock.patch.object(notification_utils, '_emit_api_notification') + def test_delete_chassis(self, mock_notify): chassis = obj_utils.create_test_chassis(self.context) self.delete('/chassis/%s' % chassis.uuid) response = self.get_json('/chassis/%s' % chassis.uuid, @@ -480,8 +530,15 @@ class TestDelete(test_api_base.BaseApiTest): self.assertEqual(http_client.NOT_FOUND, response.status_int) self.assertEqual('application/json', response.content_type) self.assertTrue(response.json['error_message']) + mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'delete', + obj_fields.NotificationLevel.INFO, + obj_fields.NotificationStatus.START), + mock.call(mock.ANY, mock.ANY, 'delete', + obj_fields.NotificationLevel.INFO, + obj_fields.NotificationStatus.END)]) - def test_delete_chassis_with_node(self): + @mock.patch.object(notification_utils, '_emit_api_notification') + def test_delete_chassis_with_node(self, mock_notify): chassis = obj_utils.create_test_chassis(self.context) obj_utils.create_test_node(self.context, chassis_id=chassis.id) response = self.delete('/chassis/%s' % chassis.uuid, @@ -490,6 +547,12 @@ class TestDelete(test_api_base.BaseApiTest): self.assertEqual('application/json', response.content_type) self.assertTrue(response.json['error_message']) self.assertIn(chassis.uuid, response.json['error_message']) + mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'delete', + obj_fields.NotificationLevel.INFO, + obj_fields.NotificationStatus.START), + mock.call(mock.ANY, mock.ANY, 'delete', + obj_fields.NotificationLevel.ERROR, + obj_fields.NotificationStatus.ERROR)]) def test_delete_chassis_not_found(self): uuid = uuidutils.generate_uuid() diff --git a/ironic/tests/unit/api/v1/test_nodes.py b/ironic/tests/unit/api/v1/test_nodes.py index 513e0a5058..8d8cca97aa 100644 --- a/ironic/tests/unit/api/v1/test_nodes.py +++ b/ironic/tests/unit/api/v1/test_nodes.py @@ -31,6 +31,7 @@ from wsme import types as wtypes from ironic.api.controllers import base as api_base from ironic.api.controllers import v1 as api_v1 from ironic.api.controllers.v1 import node as api_node +from ironic.api.controllers.v1 import notification_utils from ironic.api.controllers.v1 import utils as api_utils from ironic.api.controllers.v1 import versions from ironic.common import boot_devices @@ -39,6 +40,7 @@ from ironic.common import exception from ironic.common import states from ironic.conductor import rpcapi from ironic import objects +from ironic.objects import fields as obj_fields from ironic.tests import base from ironic.tests.unit.api import base as test_api_base from ironic.tests.unit.api import utils as test_api_utils @@ -1077,7 +1079,8 @@ class TestPatch(test_api_base.BaseApiTest): self.mock_cnps = p.start() self.addCleanup(p.stop) - def test_update_ok(self): + @mock.patch.object(notification_utils, '_emit_api_notification') + def test_update_ok(self, mock_notify): self.mock_update_node.return_value = self.node (self .mock_update_node @@ -1094,6 +1097,14 @@ class TestPatch(test_api_base.BaseApiTest): timeutils.parse_isotime(response.json['updated_at'])) self.mock_update_node.assert_called_once_with( mock.ANY, mock.ANY, 'test-topic') + mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'update', + obj_fields.NotificationLevel.INFO, + obj_fields.NotificationStatus.START, + chassis_uuid=self.chassis.uuid), + mock.call(mock.ANY, mock.ANY, 'update', + obj_fields.NotificationLevel.INFO, + obj_fields.NotificationStatus.END, + chassis_uuid=self.chassis.uuid)]) def test_update_by_name_unsupported(self): self.mock_update_node.return_value = self.node @@ -1137,7 +1148,8 @@ class TestPatch(test_api_base.BaseApiTest): self.assertEqual(http_client.BAD_REQUEST, response.status_code) self.assertTrue(response.json['error_message']) - def test_update_fails_bad_driver_info(self): + @mock.patch.object(notification_utils, '_emit_api_notification') + def test_update_fails_bad_driver_info(self, mock_notify): fake_err = 'Fake Error Message' self.mock_update_node.side_effect = ( exception.InvalidParameterValue(fake_err)) @@ -1155,6 +1167,14 @@ class TestPatch(test_api_base.BaseApiTest): self.mock_update_node.assert_called_once_with( mock.ANY, mock.ANY, 'test-topic') + mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'update', + obj_fields.NotificationLevel.INFO, + obj_fields.NotificationStatus.START, + chassis_uuid=self.chassis.uuid), + mock.call(mock.ANY, mock.ANY, 'update', + obj_fields.NotificationLevel.ERROR, + obj_fields.NotificationStatus.ERROR, + chassis_uuid=self.chassis.uuid)]) def test_update_fails_bad_driver(self): self.mock_gtf.side_effect = exception.NoValidHost('Fake Error') @@ -1765,8 +1785,12 @@ class TestPost(test_api_base.BaseApiTest): expected_location) return result - def test_create_node(self): + @mock.patch.object(notification_utils.LOG, 'exception', autospec=True) + @mock.patch.object(notification_utils.LOG, 'warning', autospec=True) + def test_create_node(self, mock_warning, mock_exception): self._test_create_node() + self.assertFalse(mock_warning.called) + self.assertFalse(mock_exception.called) def test_create_node_chassis_uuid_always_in_response(self): result = self._test_create_node(chassis_uuid=None) @@ -2037,7 +2061,8 @@ class TestPost(test_api_base.BaseApiTest): self.assertEqual(urlparse.urlparse(response.location).path, expected_location) - def test_create_node_with_chassis_uuid(self): + @mock.patch.object(notification_utils, '_emit_api_notification') + def test_create_node_with_chassis_uuid(self, mock_notify): ndict = test_api_utils.post_get_test_node( chassis_uuid=self.chassis.uuid) response = self.post_json('/nodes', ndict) @@ -2050,6 +2075,14 @@ class TestPost(test_api_base.BaseApiTest): expected_location = '/v1/nodes/%s' % ndict['uuid'] self.assertEqual(urlparse.urlparse(response.location).path, expected_location) + mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'create', + obj_fields.NotificationLevel.INFO, + obj_fields.NotificationStatus.START, + chassis_uuid=self.chassis.uuid), + mock.call(mock.ANY, mock.ANY, 'create', + obj_fields.NotificationLevel.INFO, + obj_fields.NotificationStatus.END, + chassis_uuid=self.chassis.uuid)]) def test_create_node_chassis_uuid_not_found(self): ndict = test_api_utils.post_get_test_node( @@ -2145,11 +2178,20 @@ class TestDelete(test_api_base.BaseApiTest): self.mock_gtf.return_value = 'test-topic' self.addCleanup(p.stop) + @mock.patch.object(notification_utils, '_emit_api_notification') @mock.patch.object(rpcapi.ConductorAPI, 'destroy_node') - def test_delete_node(self, mock_dn): + def test_delete_node(self, mock_dn, mock_notify): node = obj_utils.create_test_node(self.context) self.delete('/nodes/%s' % node.uuid) mock_dn.assert_called_once_with(mock.ANY, node.uuid, 'test-topic') + mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'delete', + obj_fields.NotificationLevel.INFO, + obj_fields.NotificationStatus.START, + chassis_uuid=None), + mock.call(mock.ANY, mock.ANY, 'delete', + obj_fields.NotificationLevel.INFO, + obj_fields.NotificationStatus.END, + chassis_uuid=None)]) @mock.patch.object(rpcapi.ConductorAPI, 'destroy_node') def test_delete_node_by_name_unsupported(self, mock_dn): @@ -2224,8 +2266,9 @@ class TestDelete(test_api_base.BaseApiTest): headers={'X-OpenStack-Ironic-API-Version': '1.24'}) self.assertEqual(http_client.FORBIDDEN, response.status_int) + @mock.patch.object(notification_utils, '_emit_api_notification') @mock.patch.object(rpcapi.ConductorAPI, 'destroy_node') - def test_delete_associated(self, mock_dn): + def test_delete_associated(self, mock_dn, mock_notify): node = obj_utils.create_test_node( self.context, instance_uuid='aaaaaaaa-1111-bbbb-2222-cccccccccccc') @@ -2235,6 +2278,14 @@ class TestDelete(test_api_base.BaseApiTest): response = self.delete('/nodes/%s' % node.uuid, expect_errors=True) self.assertEqual(http_client.CONFLICT, response.status_int) mock_dn.assert_called_once_with(mock.ANY, node.uuid, 'test-topic') + mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'delete', + obj_fields.NotificationLevel.INFO, + obj_fields.NotificationStatus.START, + chassis_uuid=None), + mock.call(mock.ANY, mock.ANY, 'delete', + obj_fields.NotificationLevel.ERROR, + obj_fields.NotificationStatus.ERROR, + chassis_uuid=None)]) @mock.patch.object(objects.Node, 'get_by_uuid') @mock.patch.object(rpcapi.ConductorAPI, 'update_node') diff --git a/ironic/tests/unit/api/v1/test_notification_utils.py b/ironic/tests/unit/api/v1/test_notification_utils.py new file mode 100644 index 0000000000..9ac428a75e --- /dev/null +++ b/ironic/tests/unit/api/v1/test_notification_utils.py @@ -0,0 +1,143 @@ +# 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. + +"""Test class for ironic-api notification utilities.""" + +import mock +from oslo_utils import uuidutils +from wsme import types as wtypes + +from ironic.api.controllers.v1 import notification_utils as notif_utils +from ironic.objects import fields +from ironic.objects import notification +from ironic.tests import base as tests_base +from ironic.tests.unit.objects import utils as obj_utils + + +class CRUDNotifyTestCase(tests_base.TestCase): + + def setUp(self): + super(CRUDNotifyTestCase, self).setUp() + self.node_notify_mock = mock.Mock() + self.port_notify_mock = mock.Mock() + self.chassis_notify_mock = mock.Mock() + self.node_notify_mock.__name__ = 'NodeCRUDNotification' + self.port_notify_mock.__name__ = 'PortCRUDNotification' + self.chassis_notify_mock.__name__ = 'ChassisCRUDNotification' + _notification_mocks = { + 'chassis': (self.chassis_notify_mock, + notif_utils.CRUD_NOTIFY_OBJ['chassis'][1]), + 'node': (self.node_notify_mock, + notif_utils.CRUD_NOTIFY_OBJ['node'][1]), + 'port': (self.port_notify_mock, + notif_utils.CRUD_NOTIFY_OBJ['port'][1]) + } + self.addCleanup(self._restore, notif_utils.CRUD_NOTIFY_OBJ.copy()) + notif_utils.CRUD_NOTIFY_OBJ = _notification_mocks + + def _restore(self, value): + notif_utils.CRUD_NOTIFY_OBJ = value + + def test_common_params(self): + self.config(host='fake-host') + node = obj_utils.get_test_node(self.context) + test_level = fields.NotificationLevel.INFO + test_status = fields.NotificationStatus.SUCCESS + notif_utils._emit_api_notification(self.context, node, 'create', + test_level, test_status, + chassis_uuid=None) + init_kwargs = self.node_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('ironic-api', publisher.service) + self.assertEqual('create', event_type.action) + self.assertEqual(test_status, event_type.status) + self.assertEqual(test_level, level) + + def test_node_notification(self): + chassis_uuid = uuidutils.generate_uuid() + node = obj_utils.get_test_node(self.context, + instance_info={'foo': 'baz'}, + driver_info={'param': 104}) + test_level = fields.NotificationLevel.INFO + test_status = fields.NotificationStatus.SUCCESS + notif_utils._emit_api_notification(self.context, node, 'create', + test_level, test_status, + chassis_uuid=chassis_uuid) + init_kwargs = self.node_notify_mock.call_args[1] + payload = init_kwargs['payload'] + event_type = init_kwargs['event_type'] + self.assertEqual('node', event_type.object) + self.assertEqual(node.uuid, payload.uuid) + self.assertEqual({'foo': 'baz'}, payload.instance_info) + self.assertEqual({'param': 104}, payload.driver_info) + self.assertEqual(chassis_uuid, payload.chassis_uuid) + + def test_node_notification_mask_secrets(self): + test_info = {'password': 'secret123', 'some_value': 'fake-value'} + node = obj_utils.get_test_node(self.context, + driver_info=test_info) + notification.mask_secrets(node) + self.assertEqual('******', node.driver_info['password']) + self.assertEqual('fake-value', node.driver_info['some_value']) + + def test_notification_uuid_unset(self): + node = obj_utils.get_test_node(self.context) + test_level = fields.NotificationLevel.INFO + test_status = fields.NotificationStatus.SUCCESS + notif_utils._emit_api_notification(self.context, node, 'create', + test_level, test_status, + chassis_uuid=wtypes.Unset) + init_kwargs = self.node_notify_mock.call_args[1] + payload = init_kwargs['payload'] + self.assertIsNone(payload.chassis_uuid) + + def test_chassis_notification(self): + chassis = obj_utils.get_test_chassis(self.context, + extra={'foo': 'boo'}, + description='bare01') + test_level = fields.NotificationLevel.INFO + test_status = fields.NotificationStatus.SUCCESS + notif_utils._emit_api_notification(self.context, chassis, 'create', + test_level, test_status) + init_kwargs = self.chassis_notify_mock.call_args[1] + payload = init_kwargs['payload'] + event_type = init_kwargs['event_type'] + self.assertEqual('chassis', event_type.object) + self.assertEqual(chassis.uuid, payload.uuid) + self.assertEqual({'foo': 'boo'}, payload.extra) + self.assertEqual('bare01', payload.description) + + def test_port_notification(self): + node_uuid = uuidutils.generate_uuid() + port = obj_utils.get_test_port(self.context, + address='11:22:33:77:88:99', + local_link_connection={'a': 25}, + extra={'as': 34}, + pxe_enabled=False) + test_level = fields.NotificationLevel.INFO + test_status = fields.NotificationStatus.SUCCESS + notif_utils._emit_api_notification(self.context, port, 'create', + test_level, test_status, + node_uuid=node_uuid) + init_kwargs = self.port_notify_mock.call_args[1] + payload = init_kwargs['payload'] + event_type = init_kwargs['event_type'] + self.assertEqual('port', event_type.object) + self.assertEqual(port.uuid, payload.uuid) + self.assertEqual(node_uuid, payload.node_uuid) + self.assertEqual('11:22:33:77:88:99', payload.address) + self.assertEqual({'a': 25}, payload.local_link_connection) + self.assertEqual({'as': 34}, payload.extra) + self.assertEqual(False, payload.pxe_enabled) diff --git a/ironic/tests/unit/api/v1/test_ports.py b/ironic/tests/unit/api/v1/test_ports.py index 43bcc34090..39d2a0a935 100644 --- a/ironic/tests/unit/api/v1/test_ports.py +++ b/ironic/tests/unit/api/v1/test_ports.py @@ -29,11 +29,14 @@ from wsme import types as wtypes from ironic.api.controllers import base as api_base from ironic.api.controllers import v1 as api_v1 +from ironic.api.controllers.v1 import notification_utils from ironic.api.controllers.v1 import port as api_port from ironic.api.controllers.v1 import utils as api_utils from ironic.api.controllers.v1 import versions from ironic.common import exception from ironic.conductor import rpcapi +from ironic import objects +from ironic.objects import fields as obj_fields from ironic.tests import base from ironic.tests.unit.api import base as test_api_base from ironic.tests.unit.api import utils as apiutils @@ -467,7 +470,8 @@ class TestPatch(test_api_base.BaseApiTest): self.mock_gtf.return_value = 'test-topic' self.addCleanup(p.stop) - def test_update_byid(self, mock_upd): + @mock.patch.object(notification_utils, '_emit_api_notification') + def test_update_byid(self, mock_notify, mock_upd): extra = {'foo': 'bar'} mock_upd.return_value = self.port mock_upd.return_value.extra = extra @@ -481,6 +485,14 @@ class TestPatch(test_api_base.BaseApiTest): kargs = mock_upd.call_args[0][1] self.assertEqual(extra, kargs.extra) + mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'update', + obj_fields.NotificationLevel.INFO, + obj_fields.NotificationStatus.START, + node_uuid=self.node.uuid), + mock.call(mock.ANY, mock.ANY, 'update', + obj_fields.NotificationLevel.INFO, + obj_fields.NotificationStatus.END, + node_uuid=self.node.uuid)]) def test_update_byaddress_not_allowed(self, mock_upd): extra = {'foo': 'bar'} @@ -524,7 +536,8 @@ class TestPatch(test_api_base.BaseApiTest): kargs = mock_upd.call_args[0][1] self.assertEqual(address, kargs.address) - def test_replace_address_already_exist(self, mock_upd): + @mock.patch.object(notification_utils, '_emit_api_notification') + def test_replace_address_already_exist(self, mock_notify, mock_upd): address = 'aa:aa:aa:aa:aa:aa' mock_upd.side_effect = exception.MACAlreadyExists(mac=address) response = self.patch_json('/ports/%s' % self.port.uuid, @@ -539,6 +552,14 @@ class TestPatch(test_api_base.BaseApiTest): kargs = mock_upd.call_args[0][1] self.assertEqual(address, kargs.address) + mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'update', + obj_fields.NotificationLevel.INFO, + obj_fields.NotificationStatus.START, + node_uuid=self.node.uuid), + mock.call(mock.ANY, mock.ANY, 'update', + obj_fields.NotificationLevel.ERROR, + obj_fields.NotificationStatus.ERROR, + node_uuid=self.node.uuid)]) def test_replace_node_uuid(self, mock_upd): mock_upd.return_value = self.port @@ -935,8 +956,9 @@ class TestPost(test_api_base.BaseApiTest): self.headers = {api_base.Version.string: str( versions.MAX_VERSION_STRING)} + @mock.patch.object(notification_utils, '_emit_api_notification') @mock.patch.object(timeutils, 'utcnow') - def test_create_port(self, mock_utcnow): + def test_create_port(self, mock_utcnow, mock_notify): pdict = post_get_test_port() test_time = datetime.datetime(2000, 1, 1, 0, 0) mock_utcnow.return_value = test_time @@ -954,6 +976,14 @@ class TestPost(test_api_base.BaseApiTest): expected_location = '/v1/ports/%s' % pdict['uuid'] self.assertEqual(urlparse.urlparse(response.location).path, expected_location) + mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'create', + obj_fields.NotificationLevel.INFO, + obj_fields.NotificationStatus.START, + node_uuid=self.node.uuid), + mock.call(mock.ANY, mock.ANY, 'create', + obj_fields.NotificationLevel.INFO, + obj_fields.NotificationStatus.END, + node_uuid=self.node.uuid)]) def test_create_port_min_api_version(self): pdict = post_get_test_port( @@ -979,7 +1009,9 @@ class TestPost(test_api_base.BaseApiTest): # Check that 'id' is not in first arg of positional args self.assertNotIn('id', cp_mock.call_args[0][0]) - def test_create_port_generate_uuid(self): + @mock.patch.object(notification_utils.LOG, 'exception', autospec=True) + @mock.patch.object(notification_utils.LOG, 'warning', autospec=True) + def test_create_port_generate_uuid(self, mock_warning, mock_exception): pdict = post_get_test_port() del pdict['uuid'] response = self.post_json('/ports', pdict, headers=self.headers) @@ -987,6 +1019,24 @@ class TestPost(test_api_base.BaseApiTest): headers=self.headers) self.assertEqual(pdict['address'], result['address']) self.assertTrue(uuidutils.is_uuid_like(result['uuid'])) + self.assertFalse(mock_warning.called) + self.assertFalse(mock_exception.called) + + @mock.patch.object(notification_utils, '_emit_api_notification') + @mock.patch.object(objects.Port, 'create') + def test_create_port_error(self, mock_create, mock_notify): + mock_create.side_effect = Exception() + pdict = post_get_test_port() + self.post_json('/ports', pdict, headers=self.headers, + expect_errors=True) + mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'create', + obj_fields.NotificationLevel.INFO, + obj_fields.NotificationStatus.START, + node_uuid=self.node.uuid), + mock.call(mock.ANY, mock.ANY, 'create', + obj_fields.NotificationLevel.ERROR, + obj_fields.NotificationStatus.ERROR, + node_uuid=self.node.uuid)]) def test_create_port_valid_extra(self): pdict = post_get_test_port(extra={'str': 'foo', 'int': 123, @@ -1325,11 +1375,21 @@ class TestDelete(test_api_base.BaseApiTest): self.assertEqual('application/json', response.content_type) self.assertIn(self.port.address, response.json['error_message']) - def test_delete_port_byid(self, mock_dpt): + @mock.patch.object(notification_utils, '_emit_api_notification') + def test_delete_port_byid(self, mock_notify, mock_dpt): self.delete('/ports/%s' % self.port.uuid, expect_errors=True) self.assertTrue(mock_dpt.called) + mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'delete', + obj_fields.NotificationLevel.INFO, + obj_fields.NotificationStatus.START, + node_uuid=self.node.uuid), + mock.call(mock.ANY, mock.ANY, 'delete', + obj_fields.NotificationLevel.INFO, + obj_fields.NotificationStatus.END, + node_uuid=self.node.uuid)]) - def test_delete_port_node_locked(self, mock_dpt): + @mock.patch.object(notification_utils, '_emit_api_notification') + def test_delete_port_node_locked(self, mock_notify, mock_dpt): self.node.reserve(self.context, 'fake', self.node.uuid) mock_dpt.side_effect = exception.NodeLocked(node='fake-node', host='fake-host') @@ -1337,6 +1397,14 @@ class TestDelete(test_api_base.BaseApiTest): self.assertEqual(http_client.CONFLICT, ret.status_code) self.assertTrue(ret.json['error_message']) self.assertTrue(mock_dpt.called) + mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'delete', + obj_fields.NotificationLevel.INFO, + obj_fields.NotificationStatus.START, + node_uuid=self.node.uuid), + mock.call(mock.ANY, mock.ANY, 'delete', + obj_fields.NotificationLevel.ERROR, + obj_fields.NotificationStatus.ERROR, + node_uuid=self.node.uuid)]) def test_portgroups_subresource_delete(self, mock_dpt): portgroup = obj_utils.create_test_portgroup(self.context, diff --git a/ironic/tests/unit/conductor/test_notification_utils.py b/ironic/tests/unit/conductor/test_notification_utils.py index 3126d9d50a..fabef0ea20 100644 --- a/ironic/tests/unit/conductor/test_notification_utils.py +++ b/ironic/tests/unit/conductor/test_notification_utils.py @@ -24,6 +24,7 @@ from ironic.conductor import notification_utils as notif_utils from ironic.conductor import task_manager from ironic.objects import fields from ironic.objects import node as node_objects +from ironic.objects import notification from ironic.tests import base as tests_base from ironic.tests.unit.db import base from ironic.tests.unit.objects import utils as obj_utils @@ -69,7 +70,7 @@ class TestNotificationUtils(base.DbTestCase): to_power=states.POWER_ON ) - @mock.patch.object(notif_utils, 'mask_secrets') + @mock.patch.object(notification, 'mask_secrets') def test__emit_conductor_node_notification(self, mock_secrets): mock_notify_method = mock.Mock() # Required for exception handling @@ -124,7 +125,7 @@ class TestNotificationUtils(base.DbTestCase): self.assertFalse(mock_notify_method.called) - @mock.patch.object(notif_utils, 'mask_secrets') + @mock.patch.object(notification, 'mask_secrets') def test__emit_conductor_node_notification_known_notify_exc(self, mock_secrets): """Test exception caught for a known notification exception.""" @@ -190,7 +191,7 @@ class ProvisionNotifyTestCase(tests_base.TestCase): 'some_value': 'fake-value'} node = obj_utils.get_test_node(self.context, instance_info=test_info) - notif_utils.mask_secrets(node) + notification.mask_secrets(node) self.assertEqual('******', node.instance_info['configdrive']) self.assertEqual('******', node.instance_info['image_url']) self.assertEqual('fake-value', node.instance_info['some_value']) diff --git a/ironic/tests/unit/objects/test_objects.py b/ironic/tests/unit/objects/test_objects.py index 82a6cfec5d..65dc949287 100644 --- a/ironic/tests/unit/objects/test_objects.py +++ b/ironic/tests/unit/objects/test_objects.py @@ -412,17 +412,23 @@ expected_object_fingerprints = { 'Conductor': '1.1-5091f249719d4a465062a1b3dc7f860d', 'EventType': '1.1-aa2ba1afd38553e3880c267404e8d370', 'NotificationPublisher': '1.0-51a09397d6c0687771fb5be9a999605d', - 'NodePayload': '1.1-d895cf6411ac666f9e982f85ea0a9499', + 'NodePayload': '1.2-f4e7a1def3b2a5784863eeed46e3a25f', 'NodeSetPowerStateNotification': '1.0-59acc533c11d306f149846f922739c15', - 'NodeSetPowerStatePayload': '1.1-b8fab1bea5a2da5900445ab515e41715', + 'NodeSetPowerStatePayload': '1.2-06b6daec792fdef69c672ab5899c6a07', 'NodeCorrectedPowerStateNotification': '1.0-59acc533c11d306f149846f922739' 'c15', - 'NodeCorrectedPowerStatePayload': '1.1-5d1544defc858ae8a722f4cadd511bac', + 'NodeCorrectedPowerStatePayload': '1.2-ef6515d2f20944f4ed3d3e06a6476396', 'NodeSetProvisionStateNotification': '1.0-59acc533c11d306f149846f922739c15', - 'NodeSetProvisionStatePayload': '1.1-743be1f5748f346e3da33390983172b1', + 'NodeSetProvisionStatePayload': '1.2-2695d18d1eccbb0f5d3bbcb0575630dc', 'VolumeConnector': '1.0-3e0252c0ab6e6b9d158d09238a577d97', 'VolumeTarget': '1.0-0b10d663d8dae675900b2c7548f76f5e', + 'ChassisCRUDNotification': '1.0-59acc533c11d306f149846f922739c15', + 'ChassisCRUDPayload': '1.0-dce63895d8186279a7dd577cffccb202', + 'NodeCRUDNotification': '1.0-59acc533c11d306f149846f922739c15', + 'NodeCRUDPayload': '1.0-37bb4cdd2c84b59fd6ad0547dbf713a0', + 'PortCRUDNotification': '1.0-59acc533c11d306f149846f922739c15', + 'PortCRUDPayload': '1.0-88acd98c9b08b4c8810e77793152057b' } diff --git a/releasenotes/notes/resources-crud-notifications-70cba9f761da3afe.yaml b/releasenotes/notes/resources-crud-notifications-70cba9f761da3afe.yaml new file mode 100644 index 0000000000..ab39186435 --- /dev/null +++ b/releasenotes/notes/resources-crud-notifications-70cba9f761da3afe.yaml @@ -0,0 +1,7 @@ +--- +features: + - Adds notifications for creation, updates, or deletions of ironic resources + (node, port and chassis). Event types are formatted as follows + "baremetal..{create,update,delete}.{start,end,error}". + For more details, see the developer documentation + /http://docs.openstack.org/developer/ironic/deploy/notifications.html.