diff --git a/etc/oslo-config-generator/neutron.conf b/etc/oslo-config-generator/neutron.conf index 56a40a7c104..35bb6b2a52b 100644 --- a/etc/oslo-config-generator/neutron.conf +++ b/etc/oslo-config-generator/neutron.conf @@ -7,6 +7,7 @@ namespace = neutron.agent namespace = neutron.db namespace = neutron.extensions namespace = nova.auth +namespace = ironic.auth namespace = oslo.log namespace = oslo.db namespace = oslo.policy diff --git a/lower-constraints.txt b/lower-constraints.txt index 81e82435202..7bf8707be79 100644 --- a/lower-constraints.txt +++ b/lower-constraints.txt @@ -110,6 +110,7 @@ pyroute2==0.5.3 python-dateutil==2.5.3 python-designateclient==2.7.0 python-editor==1.0.3 +python-ironicclient==2.7.0 python-keystoneclient==3.8.0 python-mimeparse==1.6.0 python-neutronclient==6.7.0 diff --git a/neutron/common/config.py b/neutron/common/config.py index f144f64841c..62011f2651e 100644 --- a/neutron/common/config.py +++ b/neutron/common/config.py @@ -72,6 +72,13 @@ common_config.register_placement_opts() 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): cfg.CONF(args=args, project='neutron', diff --git a/neutron/conf/common.py b/neutron/conf/common.py index 0536827c62e..a4bc5ff7111 100644 --- a/neutron/conf/common.py +++ b/neutron/conf/common.py @@ -190,3 +190,43 @@ placement_opts = [ def register_placement_opts(cfg=cfg.CONF): 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) diff --git a/neutron/db/db_base_plugin_v2.py b/neutron/db/db_base_plugin_v2.py index b20884e866d..cec85737708 100644 --- a/neutron/db/db_base_plugin_v2.py +++ b/neutron/db/db_base_plugin_v2.py @@ -171,6 +171,10 @@ class NeutronDbPluginV2(db_base_plugin_common.DbBasePluginCommon, db_api.sqla_listen( models_v2.Port.status, 'set', 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, events.BEFORE_UPDATE, diff --git a/neutron/notifiers/ironic.py b/neutron/notifiers/ironic.py new file mode 100644 index 00000000000..978c0c8f4bd --- /dev/null +++ b/neutron/notifiers/ironic.py @@ -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 != ''}) diff --git a/neutron/opts.py b/neutron/opts.py index 02b5d0ca135..cb539276298 100644 --- a/neutron/opts.py +++ b/neutron/opts.py @@ -63,6 +63,7 @@ import neutron.wsgi NOVA_GROUP = 'nova' +IRONIC_GROUP = 'ironic' CONF = cfg.CONF @@ -75,6 +76,8 @@ deprecations = {'nova.cafile': [cfg.DeprecatedOpt('ca_certificates_file', _nova_options = ks_loading.register_session_conf_options( CONF, NOVA_GROUP, deprecated_opts=deprecations) +_ironic_options = ks_loading.register_session_conf_options( + CONF, IRONIC_GROUP) def list_agent_opts(): @@ -141,6 +144,10 @@ def list_opts(): itertools.chain( 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) ] @@ -318,6 +325,20 @@ def list_auth_opts(): 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(): return [ ('xenapi', diff --git a/neutron/tests/unit/notifiers/test_ironic.py b/neutron/tests/unit/notifiers/test_ironic.py new file mode 100644 index 00000000000..e40855c9338 --- /dev/null +++ b/neutron/tests/unit/notifiers/test_ironic.py @@ -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) diff --git a/releasenotes/notes/notifier-ironic-66391e083d78fee2.yaml b/releasenotes/notes/notifier-ironic-66391e083d78fee2.yaml new file mode 100644 index 00000000000..8ee8a4780bc --- /dev/null +++ b/releasenotes/notes/notifier-ironic-66391e083d78fee2.yaml @@ -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``). diff --git a/requirements.txt b/requirements.txt index 010434e655b..f073b454418 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 python-novaclient>=9.1.0 # Apache-2.0 +python-ironicclient>=2.7.0 # Apache-2.0 python-designateclient>=2.7.0 # Apache-2.0 os-xenapi>=0.3.1 # Apache-2.0 diff --git a/setup.cfg b/setup.cfg index cb616d2a391..529d74ede42 100644 --- a/setup.cfg +++ b/setup.cfg @@ -128,6 +128,7 @@ neutron.agent.linux.pd_drivers = neutron.services.external_dns_drivers = designate = neutron.services.externaldns.drivers.designate.driver:Designate oslo.config.opts = + ironic.auth = neutron.opts:list_ironic_auth_opts neutron = neutron.opts:list_opts neutron.agent = neutron.opts:list_agent_opts neutron.az.agent = neutron.opts:list_az_agent_opts