Notify ironic on port status changes

This patch adds an ironic notifier that sends notifications
to ironic endpoint /v1/events. The events are triggered by
port updates and deletions. Only ports with vnic_type
baremetal are honored.

Story: 1304673
Task: 22263
Closes-Bug: #1828367
Implements: blueprint event-notifier-ironic
Authored-By: Vasyl Saienko <vsaienko@mirantis.com>
Co-Authored-By: Harald Jensås <hjensas@redhat.com>
Co-Authored-By: Julia Kreger <juliaashleykreger@gmail.com>
Change-Id: I0bb3187a88a7f20adb8c60e24945db159afb83f1
This commit is contained in:
Harald Jensås 2019-05-09 10:38:15 +02:00
parent 3f837836f6
commit afff649a39
11 changed files with 446 additions and 0 deletions

View File

@ -7,6 +7,7 @@ namespace = neutron.agent
namespace = neutron.db namespace = neutron.db
namespace = neutron.extensions namespace = neutron.extensions
namespace = nova.auth namespace = nova.auth
namespace = ironic.auth
namespace = oslo.log namespace = oslo.log
namespace = oslo.db namespace = oslo.db
namespace = oslo.policy namespace = oslo.policy

View File

@ -110,6 +110,7 @@ pyroute2==0.5.3
python-dateutil==2.5.3 python-dateutil==2.5.3
python-designateclient==2.7.0 python-designateclient==2.7.0
python-editor==1.0.3 python-editor==1.0.3
python-ironicclient==2.7.0
python-keystoneclient==3.8.0 python-keystoneclient==3.8.0
python-mimeparse==1.6.0 python-mimeparse==1.6.0
python-neutronclient==6.7.0 python-neutronclient==6.7.0

View File

@ -72,6 +72,13 @@ common_config.register_placement_opts()
logging.register_options(cfg.CONF) logging.register_options(cfg.CONF)
# Register the ironic configuration options
ks_loading.register_auth_conf_options(cfg.CONF,
common_config.IRONIC_CONF_SECTION)
ks_loading.register_session_conf_options(cfg.CONF,
common_config.IRONIC_CONF_SECTION)
common_config.register_ironic_opts()
def init(args, default_config_files=None, **kwargs): def init(args, default_config_files=None, **kwargs):
cfg.CONF(args=args, project='neutron', cfg.CONF(args=args, project='neutron',

View File

@ -190,3 +190,43 @@ placement_opts = [
def register_placement_opts(cfg=cfg.CONF): def register_placement_opts(cfg=cfg.CONF):
cfg.register_opts(placement_opts, group=PLACEMENT_CONF_SECTION) cfg.register_opts(placement_opts, group=PLACEMENT_CONF_SECTION)
IRONIC_CONF_SECTION = 'ironic'
ironic_opts = [
cfg.BoolOpt('enable_notifications', default=False,
help=_("Send notification events to ironic. (For example on "
"relevant port status changes.)")),
cfg.StrOpt('region_name',
help=_('Name of region used to get Ironic endpoints. Useful if'
'keystone manages more than one region.')),
cfg.StrOpt('endpoint_type',
default='public',
choices=['public', 'admin', 'internal'],
help=_('Type of the ironic endpoint to use. This endpoint '
'will be looked up in the keystone catalog and should '
'be one of public, internal or admin.')),
cfg.StrOpt('auth_strategy',
default='keystone',
choices=('keystone', 'noauth'),
help=_('Method to use for authentication: noauth or '
'keystone.')),
cfg.StrOpt('ironic_url',
default='http://localhost:6385/',
help=_('Ironic API URL, used to set Ironic API URL when '
'auth_strategy option is noauth to work with standalone '
'Ironic without keystone.')),
cfg.IntOpt('retry_interval',
default=2,
help=_('Interval between retries in case of conflict error '
'(HTTP 409).')),
cfg.IntOpt('max_retries',
default=30,
help=_('Maximum number of retries in case of conflict error '
'(HTTP 409).')),
]
def register_ironic_opts(cfg=cfg.CONF):
cfg.register_opts(ironic_opts, group=IRONIC_CONF_SECTION)

View File

@ -171,6 +171,10 @@ class NeutronDbPluginV2(db_base_plugin_common.DbBasePluginCommon,
db_api.sqla_listen( db_api.sqla_listen(
models_v2.Port.status, 'set', models_v2.Port.status, 'set',
self.nova_notifier.record_port_status_changed) self.nova_notifier.record_port_status_changed)
if cfg.CONF.ironic.enable_notifications:
# Import ironic notifier conditionally
from neutron.notifiers import ironic
self.ironic_notifier = ironic.Notifier.get_instance()
@registry.receives(resources.RBAC_POLICY, [events.BEFORE_CREATE, @registry.receives(resources.RBAC_POLICY, [events.BEFORE_CREATE,
events.BEFORE_UPDATE, events.BEFORE_UPDATE,

159
neutron/notifiers/ironic.py Normal file
View File

@ -0,0 +1,159 @@
# Copyright (c) 2019 OpenStack Foundation.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from ironicclient import client
from ironicclient import exc as ironic_exc
from keystoneauth1 import loading as ks_loading
from neutron_lib.api.definitions import port as port_def
from neutron_lib.api.definitions import portbindings as portbindings_def
from neutron_lib.callbacks import events
from neutron_lib.callbacks import registry
from neutron_lib.callbacks import resources
from neutron_lib import constants as n_const
from oslo_config import cfg
from oslo_log import log as logging
from neutron.notifiers import batch_notifier
LOG = logging.getLogger(__name__)
BAREMETAL_EVENT_TYPE = 'network'
IRONIC_API_VERSION = 'latest'
IRONIC_SESSION = None
IRONIC_CONF_SECTION = 'ironic'
IRONIC_CLIENT_VERSION = 1
@registry.has_registry_receivers
class Notifier(object):
_instance = None
@classmethod
def get_instance(cls):
if cls._instance is None:
cls._instance = cls()
return cls._instance
def __init__(self):
self.batch_notifier = batch_notifier.BatchNotifier(
cfg.CONF.send_events_interval, self.send_events)
self.irclient = self._get_ironic_client()
def _get_session(self, group):
auth = ks_loading.load_auth_from_conf_options(cfg.CONF, group)
session = ks_loading.load_session_from_conf_options(
cfg.CONF, group, auth=auth)
return session
def _get_ironic_client(self):
"""Get Ironic client instance."""
# NOTE: To support standalone ironic without keystone
if cfg.CONF.ironic.auth_strategy == 'noauth':
args = {'token': 'noauth',
'endpoint': cfg.CONF.ironic.ironic_url}
else:
global IRONIC_SESSION
if not IRONIC_SESSION:
IRONIC_SESSION = self._get_session(IRONIC_CONF_SECTION)
args = {'session': IRONIC_SESSION,
'region_name': cfg.CONF.ironic.region_name,
'endpoint_type': cfg.CONF.ironic.endpoint_type}
args['os_ironic_api_version'] = IRONIC_API_VERSION
args['max_retries'] = cfg.CONF.ironic.max_retries
args['retry_interval'] = cfg.CONF.ironic.retry_interval
return client.Client(IRONIC_CLIENT_VERSION, **args)
def send_events(self, batched_events):
# NOTE(TheJulia): Friendly exception handling so operators
# can decouple updates.
try:
self.irclient.events.create(events=batched_events)
except ironic_exc.NotFound:
LOG.error('The ironic API appears to not support posting events. '
'The API likely needs to be upgraded.')
except Exception as e:
LOG.error('Unknown error encountered posting the event to '
'ironic. {error}'.format(error=e))
@registry.receives(resources.PORT, [events.AFTER_UPDATE])
def process_port_update_event(self, resource, event, trigger,
original_port=None, port=None,
**kwargs):
# We only want to notify about baremetal ports.
if not (port[portbindings_def.VNIC_TYPE] ==
portbindings_def.VNIC_BAREMETAL):
# TODO(TheJulia): Add the smartnic flag at some point...
return
original_port_status = original_port['status']
current_port_status = port['status']
port_event = None
if (original_port_status == n_const.PORT_STATUS_ACTIVE and
current_port_status in [n_const.PORT_STATUS_DOWN,
n_const.PORT_STATUS_ERROR]):
port_event = 'unbind_port'
elif (original_port_status == n_const.PORT_STATUS_DOWN and
current_port_status in [n_const.PORT_STATUS_ACTIVE,
n_const.PORT_STATUS_ERROR]):
port_event = 'bind_port'
LOG.debug('Queuing event for {event_type} for port {port} '
'for status {status}.'.format(event_type=port_event,
port=port['id'],
status=current_port_status))
if port_event:
notify_event = {
'event': '.'.join([BAREMETAL_EVENT_TYPE, port_event]),
'port_id': port['id'],
'mac_address': port[port_def.PORT_MAC_ADDRESS],
'status': current_port_status,
'device_id': port['device_id'],
'binding:host_id': port[portbindings_def.HOST_ID],
'binding:vnic_type': port[portbindings_def.VNIC_TYPE]
}
# Filter keys with empty string as value. In case a type UUID field
# or similar is not set the API won't accept empty string.
self.batch_notifier.queue_event(
{k: v for k, v in notify_event.items() if v != ''})
@registry.receives(resources.PORT, [events.AFTER_DELETE])
def process_port_delete_event(self, resource, event, trigger,
original_port=None, port=None,
**kwargs):
# We only want to notify about baremetal ports.
if not (port[portbindings_def.VNIC_TYPE] ==
portbindings_def.VNIC_BAREMETAL):
# TODO(TheJulia): Add the smartnic flag at some point...
return
port_event = 'delete_port'
LOG.debug('Queuing event for {event_type} for port {port} '
'for status {status}.'.format(event_type=port_event,
port=port['id'],
status='DELETED'))
notify_event = {
'event': '.'.join([BAREMETAL_EVENT_TYPE, port_event]),
'port_id': port['id'],
'mac_address': port[port_def.PORT_MAC_ADDRESS],
'status': 'DELETED',
'device_id': port['device_id'],
'binding:host_id': port[portbindings_def.HOST_ID],
'binding:vnic_type': port[portbindings_def.VNIC_TYPE]
}
# Filter keys with empty string as value. In case a type UUID field
# or similar is not set the API won't accept empty string.
self.batch_notifier.queue_event(
{k: v for k, v in notify_event.items() if v != ''})

View File

@ -63,6 +63,7 @@ import neutron.wsgi
NOVA_GROUP = 'nova' NOVA_GROUP = 'nova'
IRONIC_GROUP = 'ironic'
CONF = cfg.CONF CONF = cfg.CONF
@ -75,6 +76,8 @@ deprecations = {'nova.cafile': [cfg.DeprecatedOpt('ca_certificates_file',
_nova_options = ks_loading.register_session_conf_options( _nova_options = ks_loading.register_session_conf_options(
CONF, NOVA_GROUP, deprecated_opts=deprecations) CONF, NOVA_GROUP, deprecated_opts=deprecations)
_ironic_options = ks_loading.register_session_conf_options(
CONF, IRONIC_GROUP)
def list_agent_opts(): def list_agent_opts():
@ -141,6 +144,10 @@ def list_opts():
itertools.chain( itertools.chain(
neutron.conf.common.nova_opts) neutron.conf.common.nova_opts)
), ),
(neutron.conf.common.IRONIC_CONF_SECTION,
itertools.chain(
neutron.conf.common.ironic_opts)
),
('quotas', neutron.conf.quota.core_quota_opts) ('quotas', neutron.conf.quota.core_quota_opts)
] ]
@ -318,6 +325,20 @@ def list_auth_opts():
return [(NOVA_GROUP, opt_list)] return [(NOVA_GROUP, opt_list)]
def list_ironic_auth_opts():
opt_list = copy.deepcopy(_ironic_options)
opt_list.insert(0, ks_loading.get_auth_common_conf_options()[0])
# NOTE(mhickey): There are a lot of auth plugins, we just generate
# the config options for a few common ones
plugins = ['password', 'v2password', 'v3password']
for name in plugins:
for plugin_option in ks_loading.get_auth_plugin_conf_options(name):
if all(option.name != plugin_option.name for option in opt_list):
opt_list.append(plugin_option)
opt_list.sort(key=operator.attrgetter('name'))
return [(IRONIC_GROUP, opt_list)]
def list_xenapi_opts(): def list_xenapi_opts():
return [ return [
('xenapi', ('xenapi',

View File

@ -0,0 +1,202 @@
# Copyright (c) 2019 OpenStack Foundation.
# All Rights Reserved.
#
# 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 eventlet
import mock
from ironicclient import client
from ironicclient import exc as ironic_exc
from neutron_lib.api.definitions import portbindings as portbindings_def
from neutron_lib import constants as n_const
from neutron.notifiers import batch_notifier
from neutron.notifiers import ironic
from neutron.tests import base
DEVICE_OWNER_BAREMETAL = n_const.DEVICE_OWNER_BAREMETAL_PREFIX + 'fake'
def get_fake_port():
return {'id': '11111111-aaaa-bbbb-cccc-555555555555',
'binding:host_id': '22222222-aaaa-bbbb-cccc-555555555555',
'binding:vnic_type': portbindings_def.VNIC_BAREMETAL,
'device_id': '22222222-aaaa-bbbb-cccc-555555555555',
'mac_address': 'de:ad:ca:fe:ba:be'}
class TestIronicNotifier(base.BaseTestCase):
def setUp(self):
super(TestIronicNotifier, self).setUp()
self.ironic_notifier = ironic.Notifier()
@mock.patch.object(batch_notifier.BatchNotifier, 'queue_event',
autospec=True)
def test_process_port_update_event_bind_port(self, mock_queue_event):
port = get_fake_port()
port.update({'status': n_const.PORT_STATUS_ACTIVE})
original_port = get_fake_port()
original_port.update({'status': n_const.PORT_STATUS_DOWN})
self.ironic_notifier.process_port_update_event(
'fake_resource', 'fake_event', 'fake_trigger',
original_port=original_port, port=port, **{})
mock_queue_event.assert_called_with(
self.ironic_notifier.batch_notifier,
{'event': 'network.bind_port',
'binding:host_id': '22222222-aaaa-bbbb-cccc-555555555555',
'binding:vnic_type': portbindings_def.VNIC_BAREMETAL,
'device_id': '22222222-aaaa-bbbb-cccc-555555555555',
'port_id': '11111111-aaaa-bbbb-cccc-555555555555',
'mac_address': 'de:ad:ca:fe:ba:be',
'status': n_const.PORT_STATUS_ACTIVE})
@mock.patch.object(batch_notifier.BatchNotifier, 'queue_event',
autospec=True)
def test_process_port_update_event_bind_port_err(self, mock_queue_event):
port = get_fake_port()
port.update({'status': n_const.PORT_STATUS_ERROR})
original_port = get_fake_port()
original_port.update({'status': n_const.PORT_STATUS_DOWN})
self.ironic_notifier.process_port_update_event(
'fake_resource', 'fake_event', 'fake_trigger',
original_port=original_port, port=port, **{})
mock_queue_event.assert_called_with(
self.ironic_notifier.batch_notifier,
{'event': 'network.bind_port',
'binding:host_id': '22222222-aaaa-bbbb-cccc-555555555555',
'binding:vnic_type': portbindings_def.VNIC_BAREMETAL,
'device_id': '22222222-aaaa-bbbb-cccc-555555555555',
'port_id': '11111111-aaaa-bbbb-cccc-555555555555',
'mac_address': 'de:ad:ca:fe:ba:be',
'status': n_const.PORT_STATUS_ERROR})
@mock.patch.object(batch_notifier.BatchNotifier, 'queue_event',
autospec=True)
def test_process_port_update_event_unbind_port(self, mock_queue_event):
port = get_fake_port()
port.update({'status': n_const.PORT_STATUS_DOWN})
original_port = get_fake_port()
original_port.update({'status': n_const.PORT_STATUS_ACTIVE})
self.ironic_notifier.process_port_update_event(
'fake_resource', 'fake_event', 'fake_trigger',
original_port=original_port, port=port, **{})
mock_queue_event.assert_called_with(
self.ironic_notifier.batch_notifier,
{'event': 'network.unbind_port',
'binding:host_id': '22222222-aaaa-bbbb-cccc-555555555555',
'binding:vnic_type': portbindings_def.VNIC_BAREMETAL,
'device_id': '22222222-aaaa-bbbb-cccc-555555555555',
'port_id': '11111111-aaaa-bbbb-cccc-555555555555',
'mac_address': 'de:ad:ca:fe:ba:be',
'status': n_const.PORT_STATUS_DOWN})
@mock.patch.object(batch_notifier.BatchNotifier, 'queue_event',
autospec=True)
def test_process_port_update_event_unbind_port_err(self, mock_queue_event):
port = get_fake_port()
port.update({'status': n_const.PORT_STATUS_ERROR})
original_port = get_fake_port()
original_port.update({'status': n_const.PORT_STATUS_ACTIVE})
self.ironic_notifier.process_port_update_event(
'fake_resource', 'fake_event', 'fake_trigger',
original_port=original_port, port=port, **{})
mock_queue_event.assert_called_with(
self.ironic_notifier.batch_notifier,
{'event': 'network.unbind_port',
'binding:host_id': '22222222-aaaa-bbbb-cccc-555555555555',
'binding:vnic_type': portbindings_def.VNIC_BAREMETAL,
'device_id': '22222222-aaaa-bbbb-cccc-555555555555',
'port_id': '11111111-aaaa-bbbb-cccc-555555555555',
'mac_address': 'de:ad:ca:fe:ba:be',
'status': n_const.PORT_STATUS_ERROR})
@mock.patch.object(batch_notifier.BatchNotifier, 'queue_event',
autospec=True)
def test_process_port_delete_event(self, mock_queue_event):
port = get_fake_port()
self.ironic_notifier.process_port_delete_event(
'fake_resource', 'fake_event', 'fake_trigger', original_port=None,
port=port, **{})
mock_queue_event.assert_called_with(
self.ironic_notifier.batch_notifier,
{'event': 'network.delete_port',
'binding:host_id': '22222222-aaaa-bbbb-cccc-555555555555',
'binding:vnic_type': portbindings_def.VNIC_BAREMETAL,
'device_id': '22222222-aaaa-bbbb-cccc-555555555555',
'port_id': '11111111-aaaa-bbbb-cccc-555555555555',
'mac_address': 'de:ad:ca:fe:ba:be',
'status': 'DELETED'})
@mock.patch.object(batch_notifier.BatchNotifier, 'queue_event',
autospec=True)
def test_process_port_event_empty_uuid_field(self, mock_queue_event):
port = get_fake_port()
port.update({'device_id': ''})
self.ironic_notifier.process_port_delete_event(
'fake_resource', 'fake_event', 'fake_trigger', original_port=None,
port=port, **{})
mock_queue_event.assert_called_with(
self.ironic_notifier.batch_notifier,
{'event': 'network.delete_port',
'binding:host_id': '22222222-aaaa-bbbb-cccc-555555555555',
'binding:vnic_type': portbindings_def.VNIC_BAREMETAL,
'port_id': '11111111-aaaa-bbbb-cccc-555555555555',
'mac_address': 'de:ad:ca:fe:ba:be',
'status': 'DELETED'})
@mock.patch.object(eventlet, 'spawn_n', autospec=True)
def test_queue_events(self, mock_spawn_n):
port = get_fake_port()
self.ironic_notifier.process_port_delete_event(
'fake_resource', 'fake_event', 'fake_trigger', original_port=None,
port=port, **{})
port = get_fake_port()
port.update({'status': n_const.PORT_STATUS_ACTIVE})
original_port = get_fake_port()
original_port.update({'status': n_const.PORT_STATUS_DOWN})
self.ironic_notifier.process_port_update_event(
'fake_resource', 'fake_event', 'fake_trigger',
original_port=original_port, port=port, **{})
self.assertEqual(
2, len(self.ironic_notifier.batch_notifier.pending_events))
self.assertEqual(2, mock_spawn_n.call_count)
@mock.patch.object(client, 'Client', autospec=False)
def test_send_events(self, mock_client):
self.ironic_notifier.irclient = mock_client
self.ironic_notifier.send_events(['test', 'events'])
mock_client.events.create.assert_called_with(events=['test', 'events'])
@mock.patch.object(ironic.LOG, 'error', autospec=True)
@mock.patch.object(client, 'Client', autospec=False)
def test_send_event_method_not_found(self, mock_client, mock_log):
self.ironic_notifier.irclient = mock_client
exception = ironic_exc.NotFound()
mock_client.events.create.side_effect = exception
self.ironic_notifier.send_events(['test', 'events'])
self.assertEqual(1, mock_log.call_count)
mock_log.assert_called_with('The ironic API appears to not support '
'posting events. The API likely needs to '
'be upgraded.')
@mock.patch.object(ironic.LOG, 'error', autospec=True)
@mock.patch.object(client, 'Client', autospec=False)
def test_send_event_exception(self, mock_client, mock_log):
self.ironic_notifier.irclient = mock_client
mock_client.events.create.side_effect = Exception()
self.ironic_notifier.send_events(['test', 'events'])
self.assertEqual(1, mock_log.call_count)

View File

@ -0,0 +1,9 @@
---
features:
- |
A notifier for the Openstack Baremetal service (``ironic``) is introduced.
When enabled notifications are sent to the Baremetal service on relevant
resource events/changes. By default notifications to the Baremetal service
is *disabled*. To *enable* notifications to the Baremetal service set
``[ironic]/enable_notifications`` to ``True`` in the Networking service
configuration (``neutron.conf``).

View File

@ -51,5 +51,6 @@ pyroute2>=0.5.3;sys_platform!='win32' # Apache-2.0 (+ dual licensed GPL2)
weakrefmethod>=1.0.2;python_version=='2.7' # PSF weakrefmethod>=1.0.2;python_version=='2.7' # PSF
python-novaclient>=9.1.0 # Apache-2.0 python-novaclient>=9.1.0 # Apache-2.0
python-ironicclient>=2.7.0 # Apache-2.0
python-designateclient>=2.7.0 # Apache-2.0 python-designateclient>=2.7.0 # Apache-2.0
os-xenapi>=0.3.1 # Apache-2.0 os-xenapi>=0.3.1 # Apache-2.0

View File

@ -128,6 +128,7 @@ neutron.agent.linux.pd_drivers =
neutron.services.external_dns_drivers = neutron.services.external_dns_drivers =
designate = neutron.services.externaldns.drivers.designate.driver:Designate designate = neutron.services.externaldns.drivers.designate.driver:Designate
oslo.config.opts = oslo.config.opts =
ironic.auth = neutron.opts:list_ironic_auth_opts
neutron = neutron.opts:list_opts neutron = neutron.opts:list_opts
neutron.agent = neutron.opts:list_agent_opts neutron.agent = neutron.opts:list_agent_opts
neutron.az.agent = neutron.opts:list_az_agent_opts neutron.az.agent = neutron.opts:list_az_agent_opts