Add ironic resources CRUD notifications

This patch adds notifications for create, update or delete ironic
resources (node, port and chassis). Event types general form are:
baremetal.<resource>.{create, update, delete}.{start,end,error}.
Developer documentation updated.

Partial-Bug: #1606520
Change-Id: I95c64d9aa806ff2d7e7dae54ced169c98282c67d
This commit is contained in:
Yuriy Zveryanskyy 2016-11-04 11:25:19 +02:00
parent 9cd777fe2a
commit 499ef55dd1
17 changed files with 908 additions and 96 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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.<resource>.{create,update,delete}.{start,end,error}".
For more details, see the developer documentation
/http://docs.openstack.org/developer/ironic/deploy/notifications.html.